Implement Scene 7 evidence goal flow

This commit is contained in:
2026-08-22 16:02:42 +02:00
parent 34aa23237e
commit a7f99a2a39
31 changed files with 1620 additions and 52 deletions
+45 -3
View File
@@ -20,6 +20,13 @@ export type RuntimeNode = {
utterances?: RuntimeUtterance[]; rootId?: string | null
}
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
export type PlaythroughAdvanceResult = {
ok: boolean
state?: PlaythroughState
error?: string
errorCode?: 'goals_incomplete'
pendingGoals?: { key: string; title: string }[]
}
export type MysteryAuthoring = {
slug: string
@@ -46,7 +53,8 @@ export interface NarrativeRepository {
resolveDialogue(nodeId: string): Promise<{ utterances: RuntimeUtterance[]; rootId: string | null }>
createPlaythrough(userId: string, mysterySlug?: string): Promise<PlaythroughState | null>
getCurrentPlaythrough(userId: string): Promise<PlaythroughState | null>
advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
ownsActiveLevel(userId: string, levelSlug: string): Promise<boolean>
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 }>
gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
@@ -234,6 +242,13 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
return row ? stateForPlaythrough(row.id) : null
},
async ownsActiveLevel(userId, levelSlug) {
const result = await pool.query(`SELECT 1 FROM osint.playthroughs playthrough
JOIN osint.levels level ON level.id=playthrough.current_level_id
WHERE playthrough.user_id=$1 AND playthrough.status='active' AND level.slug=$2`, [userId,levelSlug])
return Boolean(result.rowCount)
},
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
@@ -278,11 +293,38 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
const client = await pool.connect()
try {
await client.query('BEGIN')
const playthrough = (await client.query<{ current_node_id: string | null; mystery_slug: string }>(
`SELECT p.current_node_id,m.slug AS mystery_slug FROM osint.playthroughs p JOIN osint.mysteries m ON m.id=p.mystery_id
const playthrough = (await client.query<{ current_node_id: string | null; current_level_id: string | null; mystery_slug: string }>(
`SELECT p.current_node_id,p.current_level_id,m.slug AS mystery_slug FROM osint.playthroughs p JOIN osint.mysteries m ON m.id=p.mystery_id
WHERE p.id=$1 AND p.user_id=$2 AND p.status='active' FOR UPDATE OF p`, [playthroughId, userId])).rows[0]
if (!playthrough) { await client.query('ROLLBACK'); return { ok: false, error: 'Playthrough not found' } }
if (!playthrough.current_node_id) { await client.query('ROLLBACK'); return { ok: false, error: 'Playthrough already finished' } }
const currentNode = (await client.query<{ node_type:string }>('SELECT node_type FROM osint.story_nodes WHERE id=$1', [playthrough.current_node_id])).rows[0]
if (currentNode?.node_type === 'level' && playthrough.current_level_id) {
const pendingGoals = (await client.query<{ goal_key:string; title:string }>(`SELECT goal.goal_key,goal.title
FROM osint.level_goals goal JOIN osint.levels level ON level.board_id=goal.board_id
WHERE level.id=$1 AND goal.enabled AND (
NOT EXISTS (SELECT 1 FROM osint.level_goal_flag_requirements requirement WHERE requirement.goal_id=goal.id)
OR EXISTS (
SELECT 1 FROM osint.level_goal_flag_requirements requirement
WHERE requirement.goal_id=goal.id AND NOT EXISTS (
SELECT 1 FROM osint.level_flags flag
WHERE flag.level_id=level.id AND flag.flag_key=requirement.flag_key
)
)
) ORDER BY goal.created_at,goal.id`, [playthrough.current_level_id])).rows
if (pendingGoals.length) {
await client.query('ROLLBACK')
return { ok:false,error:'Complete the level objective before continuing',errorCode:'goals_incomplete',
pendingGoals:pendingGoals.map(goal => ({ key:goal.goal_key,title:goal.title })) }
}
await client.query(`INSERT INTO osint.achievements (playthrough_id,flag_key,awarded_by_node_id)
SELECT $1,requirement.flag_key,$3 FROM osint.levels level
JOIN osint.level_goals goal ON goal.board_id=level.board_id AND goal.enabled
JOIN osint.level_goal_flag_requirements requirement ON requirement.goal_id=goal.id
JOIN osint.level_flags flag ON flag.level_id=level.id AND flag.flag_key=requirement.flag_key
WHERE level.id=$2 ON CONFLICT (playthrough_id,flag_key) DO NOTHING`,
[playthroughId,playthrough.current_level_id,playthrough.current_node_id])
}
const terminals = (await client.query<{ terminal_key: string; to_node_id: string | null }>(
'SELECT terminal_key,to_node_id FROM osint.story_node_terminals WHERE parent_node_id=$1 ORDER BY sort_order', [playthrough.current_node_id])).rows
const wired = terminals.filter(t => t.to_node_id)