diff --git a/mysteries/barricelli-scene-7/mystery.json b/mysteries/barricelli-scene-7/mystery.json deleted file mode 100644 index 8130cc0..0000000 --- a/mysteries/barricelli-scene-7/mystery.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "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": [] - }, - "documents": [], - "folders": [], - "claims": [ - { - "key": "nils-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", - "requiredFlags": ["scene7.nils_inventor_proved"] - } - ], - "evidenceMatchRules": [ - { - "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, - "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 - } - ] - }, - { - "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, - "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 - } - ] - } - ], - "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" - } - ] -} diff --git a/scripts/importMysteryTemplate.ts b/scripts/importMysteryTemplate.ts index a52031b..6805814 100644 --- a/scripts/importMysteryTemplate.ts +++ b/scripts/importMysteryTemplate.ts @@ -16,8 +16,8 @@ type MysteryDocument = { } type MysteryGraph = { entry: string - nodes: { key: string; type: 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'; label?: string; x: number; y: number - componentKey?: string; templateSlug?: string; version?: number + nodes: { key: string; type: 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate' | 'merit' | 'phone'; label?: string; x: number; y: number + componentKey?: string; templateSlug?: string; version?: number; awardsFlag?: string terminals?: { key: string; label?: string; to?: string | null }[] utterances?: { npc?: string; pose?: string; text: string; utterer?: 'npc' | 'player' }[] }[] } diff --git a/server/api.integration.test.ts b/server/api.integration.test.ts index b1a265b..f0b993d 100644 --- a/server/api.integration.test.ts +++ b/server/api.integration.test.ts @@ -9,6 +9,7 @@ import jwt from 'jsonwebtoken' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import type { CaseState, DocumentExhibit, EventExhibit, FolderExhibit, NoteExhibit, PartyExhibit, TimelineView } from '../src/types.js' import { runMigrations } from './migrations.js' +import type { StoryGraphDto } from './storyGraphRepository.js' const { Client } = pg const baseDatabaseUrl = process.env.TEST_DATABASE_URL @@ -252,6 +253,7 @@ suite('normalized level persistence API', () => { const { importMysteryTemplate } = await import('../scripts/importMysteryTemplate.js') const manifestPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'mysteries', 'barricelli-scene-7', 'mystery.json') const imported = await importMysteryTemplate(manifestPath, baseUrl, adminAuthorization.replace(/^Bearer /, '')) + expect(imported.mystery).toEqual({ slug:'barricelli-inventor-proof' }) expect(imported.playableLevel).toMatchObject({ title:'The Barricelli Files',exhibits:[expect.objectContaining({ type:'claim',statement:'Nils Aall Barricelli was an inventor.' })],report:expect.objectContaining({ title:'Barricelli Inventor Finding',requiredForCompletion:true,status:'draft', }),goals:[expect.objectContaining({ @@ -270,6 +272,12 @@ suite('normalized level persistence API', () => { expect.objectContaining({ goalKey:'barricelli.inventor-proof',targetSubject:'Nils Aall Barricelli', relatedFlagKey:'scene7.father_inventor_discovered' }), ]) + const mysteries = await (await adminFetch(`${baseUrl}/api/admin/mysteries`)).json() as { id:string;slug:string }[] + const mysteryId = mysteries.find(mystery => mystery.slug === 'barricelli-inventor-proof')!.id + const graph = await (await adminFetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`)).json() as StoryGraphDto + expect(graph.nodes).toHaveLength(2) + expect(graph.nodes.find(node => node.nodeType === 'level')).toMatchObject({ label:'Demonstrate OSINT skill',levelTemplateVersionId:expect.any(String) }) + expect(graph.nodes.find(node => node.nodeType === 'merit')).toMatchObject({ label:'The Barricelli Luggage',awardsFlag:'barricelli_luggage' }) const fixtureDir = path.join(path.dirname(manifestPath), 'fixtures') const fatherUpload = new FormData() diff --git a/server/narrative.integration.test.ts b/server/narrative.integration.test.ts index d8cefe1..4492e4e 100644 --- a/server/narrative.integration.test.ts +++ b/server/narrative.integration.test.ts @@ -144,8 +144,12 @@ suite('narrative graph runtime', () => { const mysteries = await (await authFetch(`${baseUrl}/api/admin/mysteries`, adminAuthorization)).json() as { id:string;slug:string }[] const mysteryId = mysteries.find(mystery => mystery.slug === 'goal-mystery')!.id expect((await authFetch(`${baseUrl}/api/admin/mysteries/${mysteryId}/graph`, adminAuthorization, { method:'POST',headers:json, - body:JSON.stringify({ entry:'level',nodes:[{ key:'level',type:'level',label:'Prove it',templateSlug:'goal-chapter',x:0,y:0, - terminals:[{ key:'continue',label:'Continue',to:null }] }] }) })).status).toBe(201) + body:JSON.stringify({ entry:'level',nodes:[ + { key:'level',type:'level',label:'Prove it',templateSlug:'goal-chapter',x:0,y:0, + terminals:[{ key:'continue',label:'Continue',to:'merit' }] }, + { key:'merit',type:'merit',label:'Inventor Merit',awardsFlag:'demo.inventor-merit',x:200,y:0, + terminals:[{ key:'continue',label:'Accept',to:null }] }, + ] }) })).status).toBe(201) const created = await authFetch(`${baseUrl}/api/playthroughs`, undefined, { method:'POST',headers:json,body:JSON.stringify({ mystery:'goal-mystery' }) }) expect(created.status).toBe(201) @@ -167,10 +171,15 @@ suite('narrative graph runtime', () => { const completed = await authFetch(`${baseUrl}/api/playthroughs/${playthroughId}/advance`, undefined, { method:'POST',headers:json,body:'{}' }) expect(completed.status).toBe(200) - expect((await completed.json() as PlaythroughState).playthrough.status).toBe('finished') - expect(await (await authFetch(`${baseUrl}/api/playthroughs/${playthroughId}/achievements`, undefined)).json()).toContain('demo.inventor-proved') + expect(await completed.json()).toMatchObject({ playthrough:{ status:'active' },node:{ kind:'merit',label:'Inventor Merit',awardsFlag:'demo.inventor-merit' } }) + expect(await (await authFetch(`${baseUrl}/api/playthroughs/${playthroughId}/achievements`, undefined)).json()) + .toEqual(expect.arrayContaining(['demo.inventor-proved','demo.inventor-merit'])) const achievement = await appPool.query<{ awarded_by_node_id:string | null }>( 'SELECT awarded_by_node_id FROM osint.achievements WHERE playthrough_id=$1 AND flag_key=$2', [playthroughId,'demo.inventor-proved']) expect(achievement.rows[0].awarded_by_node_id).toBe(atLevel.node!.id) + + const finished = await authFetch(`${baseUrl}/api/playthroughs/${playthroughId}/advance`, undefined, { method:'POST',headers:json,body:'{}' }) + expect(finished.status).toBe(200) + expect((await finished.json() as PlaythroughState).playthrough.status).toBe('finished') }) }) diff --git a/server/storyGraphRepository.ts b/server/storyGraphRepository.ts index 809885f..9cac7f6 100644 --- a/server/storyGraphRepository.ts +++ b/server/storyGraphRepository.ts @@ -3,7 +3,7 @@ import type { Pool, PoolClient } from 'pg' export type GraphSpecNode = { key: string; type: StoryNodeType; label?: string; x: number; y: number - componentKey?: string; templateSlug?: string; version?: number + componentKey?: string; templateSlug?: string; version?: number; awardsFlag?: string terminals?: { key: string; label?: string; to?: string | null }[] utterances?: { npc?: string; pose?: string; text: string; utterer?: Utterer }[] } @@ -261,8 +261,8 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository { versionId = version.rows[0]?.id ?? null if (!versionId) throw new Error(`Graph node ${node.key}: unknown level template ${node.templateSlug}`) } - await client.query('INSERT INTO osint.story_nodes (id,mystery_id,node_type,label,xpos,ypos,has_utterances,level_template_version_id,component_key) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)', - [id, mysteryId, node.type, node.label || node.type, node.x, node.y, Boolean(node.utterances?.length), versionId, node.componentKey || null]) + await client.query('INSERT INTO osint.story_nodes (id,mystery_id,node_type,label,xpos,ypos,has_utterances,level_template_version_id,component_key,awards_flag) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)', + [id, mysteryId, node.type, node.label || node.type, node.x, node.y, Boolean(node.utterances?.length), versionId, node.componentKey || null, node.awardsFlag?.trim() || null]) for (const [index, terminal] of (node.terminals || []).entries()) { const terminalId = randomUUID(); terminalIds.set(`${node.key}:${terminal.key}`, terminalId) await client.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order) VALUES ($1,$2,$3,$4,$5)',