Utterance award + requirement, and branching dialogue seeding

A dialogue line can award an achievement when reached (granted server-side,
validated against the player's current node so it can't be forged) and an
option can require an achievement to be offered (filtered out of the resolved
tree otherwise). authorGraph gains a branching form (utterance key/parent/
terminal) so option trees — not just linear lines — can be seeded. This is
gaps 1 & 2 for the Barricelli Dobby/Glitch-Hunter dialogues; phone dialing
stays stubbed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 18:34:52 +02:00
co-authored by Claude Opus 4.8
parent 9c496bfe19
commit cbe107e0b3
7 changed files with 92 additions and 26 deletions
+30 -11
View File
@@ -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<PlaythroughAdvanceResult>
listAchievements(playthroughId: string): Promise<string[] | null>
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<MysterySummary[]>
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<string>): 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<string, Record<string, string | null>>()
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<string, string[]>()
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<RuntimeNode | null> {
async function resolveNodeForPlay(nodeId: string, levelSlug: string | null, earnedFlags?: Set<string>): Promise<RuntimeNode | null> {
const node = (await pool.query<GraphNodeRow>('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