diff --git a/migrations/034_utterance_flags.sql b/migrations/034_utterance_flags.sql new file mode 100644 index 0000000..2f27f84 --- /dev/null +++ b/migrations/034_utterance_flags.sql @@ -0,0 +1,10 @@ +-- Utterance-level flags: a dialogue line can AWARD an achievement when reached, and +-- an option can REQUIRE an achievement to be offered (gates player choices on prior +-- discoveries). Mirrors the merit node's award and node-enable requirements — this is +-- how asking Dobby the name grants dobby.knows_barricelli_name, and how Glitch Hunter's +-- "…a Norwegian-Italian mathematician" option only shows once you know it. + +ALTER TABLE osint.utterances ADD COLUMN awards_flag TEXT + CHECK (awards_flag IS NULL OR awards_flag ~ '^[a-z][a-z0-9_.-]{0,63}$'); +ALTER TABLE osint.utterances ADD COLUMN requires_flag TEXT + CHECK (requires_flag IS NULL OR requires_flag ~ '^[a-z][a-z0-9_.-]{0,63}$'); diff --git a/scripts/importMysteryTemplate.ts b/scripts/importMysteryTemplate.ts index 41edf19..8e2e348 100644 --- a/scripts/importMysteryTemplate.ts +++ b/scripts/importMysteryTemplate.ts @@ -19,7 +19,7 @@ type MysteryGraph = { 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; npc?: string }[] - utterances?: { npc?: string; pose?: string; text: string; utterer?: 'npc' | 'player' }[] }[] + utterances?: { key?: string; parent?: string; terminal?: string; npc?: string; pose?: string; text: string; utterer?: 'npc' | 'player'; awardsFlag?: string; requiresFlag?: string }[] }[] } type MysteryNarrative = { cast: { key: string; name: string; role?: string; defaultPose?: string; phoneNumber?: string; email?: string; poses?: { poseKey: string; assetId: string }[] }[] diff --git a/server/index.ts b/server/index.ts index c3d5ab9..670b186 100644 --- a/server/index.ts +++ b/server/index.ts @@ -508,6 +508,14 @@ app.post('/api/playthroughs/:id/achievements', async (req, res, next) => { result.ok ? res.json({ earned: result.earned }) : res.status(result.error === 'Playthrough not found' ? 404 : 400).json({ error: result.error }) } catch (error) { next(error) } }) +// A dialogue line was reached in play — grant its authored achievement (validated +// server-side against the player's current node, so players can't forge flags). +app.post('/api/playthroughs/:id/utterances/:uid/reach', async (req, res, next) => { + try { + const result = await narrative.reachUtterance(String(req.params.id), String(req.params.uid)) + result.ok ? res.json({ earned: result.earned ?? false }) : res.status(404).json({ error: 'Not found' }) + } catch (error) { next(error) } +}) // Dev teleport: jump the playthrough to an explicit story node. Powers /node/:id. app.post('/api/playthroughs/:id/goto', async (req, res, next) => { try { diff --git a/server/narrativeRepository.ts b/server/narrativeRepository.ts index c901d3c..e21d9a1 100644 --- a/server/narrativeRepository.ts +++ b/server/narrativeRepository.ts @@ -12,7 +12,7 @@ export type MysterySummary = { id: string; slug: string; title: string; nodes: n export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: 'active' | 'finished' } export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string } - poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null + poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null; awardsFlag: string | null } export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string @@ -58,6 +58,7 @@ export interface NarrativeRepository { advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise listAchievements(playthroughId: string): Promise awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }> + reachUtterance(playthroughId: string, utteranceId: string): Promise<{ ok: boolean; earned?: boolean }> gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }> listMysteries(): Promise listPlayableMysteries(): Promise<{ slug: string; title: string }[]> @@ -87,43 +88,46 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora // Resolve a dialogue node's whole utterance tree for the client to walk: each // utterance carries its ordered children and (if it exits the node) its terminal key. - async function resolveDialogueGraph(nodeId: string): Promise<{ utterances: RuntimeUtterance[]; rootId: string | null }> { + // earnedFlags gates player options: any utterance whose requires_flag isn't held is + // dropped (so it can't be offered). Pass undefined (authoring preview) to show all. + async function resolveDialogueGraph(nodeId: string, earnedFlags?: Set): Promise<{ utterances: RuntimeUtterance[]; rootId: string | null }> { const [utterances, poses, terminals] = await Promise.all([ - pool.query<{ id: string; utterer: 'npc' | 'player'; npc_id: string | null; pose_key: string | null; text: string; parent_utterance_id: string | null; terminal_id: string | null; name: string | null; role: string | null; default_pose_key: string | null }>( - `SELECT u.id,u.utterer,u.npc_id,u.pose_key,u.text,u.parent_utterance_id,u.terminal_id,n.name,n.role,n.default_pose_key + pool.query<{ id: string; utterer: 'npc' | 'player'; npc_id: string | null; pose_key: string | null; text: string; parent_utterance_id: string | null; terminal_id: string | null; awards_flag: string | null; requires_flag: string | null; name: string | null; role: string | null; default_pose_key: string | null }>( + `SELECT u.id,u.utterer,u.npc_id,u.pose_key,u.text,u.parent_utterance_id,u.terminal_id,u.awards_flag,u.requires_flag,n.name,n.role,n.default_pose_key FROM osint.utterances u LEFT JOIN osint.npcs n ON n.id=u.npc_id WHERE u.node_id=$1 ORDER BY u.sort_order`, [nodeId]), pool.query<{ npc_id: string; pose_key: string; asset_id: string | null }>( `SELECT p.npc_id,p.pose_key,p.asset_id FROM osint.npc_poses p WHERE p.npc_id IN (SELECT DISTINCT npc_id FROM osint.utterances WHERE node_id=$1 AND npc_id IS NOT NULL)`, [nodeId]), pool.query<{ id: string; terminal_key: string }>('SELECT id,terminal_key FROM osint.story_node_terminals WHERE parent_node_id=$1', [nodeId]), ]) + const rows = earnedFlags ? utterances.rows.filter(row => !row.requires_flag || earnedFlags.has(row.requires_flag)) : utterances.rows const poseAssets = new Map>() for (const row of poses.rows) { const map = poseAssets.get(row.npc_id) || {}; map[row.pose_key] = row.asset_id; poseAssets.set(row.npc_id, map) } const terminalKey = new Map(terminals.rows.map(row => [row.id, row.terminal_key])) const children = new Map() - for (const row of utterances.rows) if (row.parent_utterance_id) children.set(row.parent_utterance_id, [...(children.get(row.parent_utterance_id) || []), row.id]) - const root = utterances.rows.find(row => !row.parent_utterance_id) + for (const row of rows) if (row.parent_utterance_id) children.set(row.parent_utterance_id, [...(children.get(row.parent_utterance_id) || []), row.id]) + const root = rows.find(row => !row.parent_utterance_id) return { rootId: root?.id ?? null, - utterances: utterances.rows.map(row => { + utterances: rows.map(row => { const assetId = row.npc_id ? resolvePoseAssetId(poseAssets.get(row.npc_id) || {}, row.pose_key, row.default_pose_key) : null return { id: row.id, utterer: row.utterer, speaker: { name: row.name || '', role: row.role || '' }, - poseUrl: assetId ? `/api/assets/${assetId}` : null, text: row.text, + poseUrl: assetId ? `/api/assets/${assetId}` : null, text: row.text, awardsFlag: row.awards_flag, childIds: children.get(row.id) || [], terminalKey: row.terminal_id ? (terminalKey.get(row.terminal_id) ?? null) : null, } }), } } - async function resolveNodeForPlay(nodeId: string, levelSlug: string | null): Promise { + async function resolveNodeForPlay(nodeId: string, levelSlug: string | null, earnedFlags?: Set): Promise { const node = (await pool.query('SELECT id,node_type,label,component_key,level_template_version_id,music_asset_id,music_volume,awards_flag FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0] if (!node) return null const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null const musicVolume = node.music_volume / 100 if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key, musicUrl, musicVolume } if (node.node_type === 'level') return { id: node.id, kind: 'level', label: node.label, levelSlug, musicUrl, musicVolume } - if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, musicUrl, musicVolume, ...(await resolveDialogueGraph(node.id)) } + if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, musicUrl, musicVolume, ...(await resolveDialogueGraph(node.id, earnedFlags)) } if (node.node_type === 'merit') return { id: node.id, kind: 'merit', label: node.label, componentKey: node.component_key, awardsFlag: node.awards_flag, musicUrl, musicVolume } return null // gates are auto-resolved during advance and never surfaced } @@ -160,7 +164,8 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora FROM osint.playthroughs p JOIN osint.mysteries m ON m.id=p.mystery_id LEFT JOIN osint.levels l ON l.id=p.current_level_id WHERE p.id=$1`, [playthroughId])).rows[0] if (!row) return null - const node = row.current_node_id ? await resolveNodeForPlay(row.current_node_id, row.level_slug) : null + const earned = new Set((await pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.achievements WHERE playthrough_id=$1', [playthroughId])).rows.map(r => r.flag_key)) + const node = row.current_node_id ? await resolveNodeForPlay(row.current_node_id, row.level_slug, earned) : null return { playthrough: { id: row.id, mysterySlug: row.mystery_slug, levelSlug: row.level_slug, status: row.status }, node } } @@ -261,6 +266,20 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora return Boolean(result.rowCount) }, + // A dialogue line was reached in play: grant its authored achievement, but only if + // the utterance really belongs to the player's current node (so it can't be forged). + async reachUtterance(playthroughId, utteranceId) { + const row = (await pool.query<{ awards_flag: string | null; node_id: string; current_node_id: string | null }>( + `SELECT u.awards_flag,u.node_id,p.current_node_id FROM osint.utterances u + JOIN osint.playthroughs p ON p.id=$2 WHERE u.id=$1`, [utteranceId, playthroughId])).rows[0] + if (!row) return { ok: false } + if (!row.awards_flag || row.node_id !== row.current_node_id) return { ok: true, earned: false } + const result = await pool.query( + 'INSERT INTO osint.achievements (playthrough_id,flag_key,awarded_by_node_id) VALUES ($1,$2,$3) ON CONFLICT (playthrough_id,flag_key) DO NOTHING', + [playthroughId, row.awards_flag, row.node_id]) + return { ok: true, earned: (result.rowCount || 0) > 0 } + }, + async listAchievements(playthroughId) { if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null const rows = (await pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.achievements WHERE playthrough_id=$1 ORDER BY flag_key', [playthroughId])).rows diff --git a/server/storyGraphRepository.ts b/server/storyGraphRepository.ts index c032fb2..482693d 100644 --- a/server/storyGraphRepository.ts +++ b/server/storyGraphRepository.ts @@ -5,7 +5,10 @@ export type GraphSpecNode = { key: string; type: StoryNodeType; label?: string; x: number; y: number componentKey?: string; templateSlug?: string; version?: number; awardsFlag?: string terminals?: { key: string; label?: string; to?: string | null; npc?: string }[] - utterances?: { npc?: string; pose?: string; text: string; utterer?: Utterer }[] + // Linear form: an ordered list (chained automatically). Branching form: give each + // utterance a `key` and set `parent` (its predecessor) + `terminal` (its exit); + // multiple children of one parent become player options. + utterances?: { key?: string; parent?: string; terminal?: string; npc?: string; pose?: string; text: string; utterer?: Utterer; awardsFlag?: string; requiresFlag?: string }[] } export type GraphSpec = { entry: string; nodes: GraphSpecNode[] } @@ -285,24 +288,38 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository { // Utterances (linear seed): create, then chain them and exit the last one via // the node's first terminal, so the crafter shows a connected flow. for (const node of spec.nodes) { + const spec2 = node.utterances || [] const created: string[] = [] - for (const [index, utterance] of (node.utterances || []).entries()) { + const uttKeyToId = new Map() + for (const [index, utterance] of spec2.entries()) { let npcId: string | null = null if (utterance.npc) { const npc = await client.query<{ id: string }>('SELECT id FROM osint.npcs WHERE npc_key=$1 AND mystery_id IS NULL', [utterance.npc]) npcId = npc.rows[0]?.id ?? null } const utteranceId = randomUUID(); created.push(utteranceId) - await client.query('INSERT INTO osint.utterances (id,node_id,utterer,npc_id,pose_key,text,xpos,ypos,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)', - [utteranceId, nodeIds.get(node.key), utterance.utterer || 'npc', npcId, utterance.pose || null, utterance.text, 60, 60 + index * 120, index]) + if (utterance.key) uttKeyToId.set(utterance.key, utteranceId) + await client.query('INSERT INTO osint.utterances (id,node_id,utterer,npc_id,pose_key,text,awards_flag,requires_flag,xpos,ypos,sort_order) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)', + [utteranceId, nodeIds.get(node.key), utterance.utterer || 'npc', npcId, utterance.pose || null, utterance.text, utterance.awardsFlag || null, utterance.requiresFlag || null, 60, 60 + index * 120, index]) + } + const branching = spec2.some(utterance => utterance.key) + if (branching) { + // Explicit tree: wire each utterance's parent + exit terminal by key. + for (const utterance of spec2) { + const id = utterance.key ? uttKeyToId.get(utterance.key) : undefined + if (!id) continue + if (utterance.parent) await client.query('UPDATE osint.utterances SET parent_utterance_id=$2 WHERE id=$1', [id, uttKeyToId.get(utterance.parent) ?? null]) + if (utterance.terminal) await client.query('UPDATE osint.utterances SET terminal_id=$2 WHERE id=$1', [id, terminalIds.get(`${node.key}:${utterance.terminal}`) ?? null]) + } + } else { + // Linear: each line follows the previous; the last exits via the first terminal. + for (let i = 1; i < created.length; i++) + await client.query('UPDATE osint.utterances SET parent_utterance_id=$2 WHERE id=$1', [created[i], created[i - 1]]) + const firstTerminalKey = node.terminals?.[0]?.key + const exitTerminalId = firstTerminalKey ? terminalIds.get(`${node.key}:${firstTerminalKey}`) : undefined + if (created.length && exitTerminalId) + await client.query('UPDATE osint.utterances SET terminal_id=$2 WHERE id=$1', [created[created.length - 1], exitTerminalId]) } - // Chain via parent: each line follows the previous one (one child = linear). - for (let i = 1; i < created.length; i++) - await client.query('UPDATE osint.utterances SET parent_utterance_id=$2 WHERE id=$1', [created[i], created[i - 1]]) - const firstTerminalKey = node.terminals?.[0]?.key - const exitTerminalId = firstTerminalKey ? terminalIds.get(`${node.key}:${firstTerminalKey}`) : undefined - if (created.length && exitTerminalId) - await client.query('UPDATE osint.utterances SET terminal_id=$2 WHERE id=$1', [created[created.length - 1], exitTerminalId]) } const entryId = nodeIds.get(spec.entry) if (!entryId) throw new Error(`Graph entry node ${spec.entry} not found`) diff --git a/src/narrative.tsx b/src/narrative.tsx index 1557f9f..ed05a7b 100644 --- a/src/narrative.tsx +++ b/src/narrative.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState, type FC } from 'react' import { audio } from './audio' -export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null } +export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null; awardsFlag?: string | null } export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string; componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number; awardsFlag?: string | null; utterances?: RuntimeUtterance[]; rootId?: string | null } export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string } export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null } @@ -81,9 +81,11 @@ export function CutsceneHost({ componentKey, label, onComplete }: { componentKey // Walk a dialogue node's utterance tree: play NPC lines, present player options at a // branch, follow a chosen option to the next line or out through its exit terminal. -export function DialoguePlayer({ node, onExit, inline, startId }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void; inline?: boolean; startId?: string | null }) { +export function DialoguePlayer({ node, onExit, onAward, inline, startId }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void; onAward?: (utteranceId: string) => void; inline?: boolean; startId?: string | null }) { const byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances]) const [currentId, setCurrentId] = useState(startId ?? node.rootId) + const onAwardRef = useRef(onAward) + onAwardRef.current = onAward // In preview, clicking an utterance card jumps the walk to that line. useEffect(() => { if (startId !== undefined) setCurrentId(startId ?? node.rootId) }, [startId, node.rootId]) const [charCount, setCharCount] = useState(0) @@ -110,8 +112,16 @@ export function DialoguePlayer({ node, onExit, inline, startId }: { node: { utte if (ch && ch !== ' ' && charCount % 2 === 0) audio.type() }, [charCount]) // eslint-disable-line react-hooks/exhaustive-deps + // Grant a line's authored achievement when it becomes current (play mode only). + useEffect(() => { + if (inline || !currentId) return + const utterance = byId.get(currentId) + if (utterance?.awardsFlag) onAwardRef.current?.(utterance.id) + }, [currentId, inline, byId]) + const pick = (choice: RuntimeUtterance) => { if (!inline) audio.sfx('choice') + if (!inline && choice.awardsFlag) onAwardRef.current?.(choice.id) if (choice.childIds.length > 0) setCurrentId(choice.childIds[0]) else onExit(choice.terminalKey ?? undefined) } diff --git a/src/play.tsx b/src/play.tsx index c074154..bbee83a 100644 --- a/src/play.tsx +++ b/src/play.tsx @@ -94,6 +94,8 @@ export function Play() { if (!node) return
GU

GLITCH UNIVERSITY NETWORK TERMINAL

{status || 'OPENING CASE FILE…'}
if (node.kind === 'cutscene') return { void advance() }} /> if (node.kind === 'merit') return { void advance() }} /> - if (node.kind === 'dialogue' && node.utterances) return { void advance(terminalKey) }} /> + if (node.kind === 'dialogue' && node.utterances) return { void advance(terminalKey) }} + onAward={utteranceId => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/utterances/${utteranceId}/reach`, { method: 'POST' }) }} /> return
GU
{node.label}
}