Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc0147aebc | ||
|
|
cbe107e0b3 | ||
|
|
9c496bfe19 |
@@ -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}$');
|
||||||
@@ -18,11 +18,11 @@ type MysteryGraph = {
|
|||||||
entry: string
|
entry: string
|
||||||
nodes: { key: string; type: 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate' | 'merit' | 'phone'; label?: string; x: number; y: 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
|
componentKey?: string; templateSlug?: string; version?: number; awardsFlag?: string
|
||||||
terminals?: { key: string; label?: string; to?: string | null }[]
|
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 = {
|
type MysteryNarrative = {
|
||||||
cast: { key: string; name: string; role?: string; defaultPose?: string; poses?: { poseKey: string; assetId: string }[] }[]
|
cast: { key: string; name: string; role?: string; defaultPose?: string; phoneNumber?: string; email?: string; poses?: { poseKey: string; assetId: string }[] }[]
|
||||||
graph?: MysteryGraph
|
graph?: MysteryGraph
|
||||||
}
|
}
|
||||||
type MysteryGoal = {
|
type MysteryGoal = {
|
||||||
|
|||||||
@@ -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 })
|
result.ok ? res.json({ earned: result.earned }) : res.status(result.error === 'Playthrough not found' ? 404 : 400).json({ error: result.error })
|
||||||
} catch (error) { next(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.
|
// Dev teleport: jump the playthrough to an explicit story node. Powers /node/:id.
|
||||||
app.post('/api/playthroughs/:id/goto', async (req, res, next) => {
|
app.post('/api/playthroughs/:id/goto', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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 PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: 'active' | 'finished' }
|
||||||
export type RuntimeUtterance = {
|
export type RuntimeUtterance = {
|
||||||
id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }
|
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 = {
|
export type RuntimeNode = {
|
||||||
id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string
|
id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string
|
||||||
@@ -32,7 +32,7 @@ export type PlaythroughAdvanceResult = {
|
|||||||
export type MysteryAuthoring = {
|
export type MysteryAuthoring = {
|
||||||
slug: string
|
slug: string
|
||||||
title: string
|
title: string
|
||||||
cast: { key: string; name: string; role?: string; defaultPose?: string; poses?: { poseKey: string; assetId: string }[] }[]
|
cast: { key: string; name: string; role?: string; defaultPose?: string; phoneNumber?: string; email?: string; poses?: { poseKey: string; assetId: string }[] }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,6 +58,7 @@ export interface NarrativeRepository {
|
|||||||
advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<PlaythroughAdvanceResult>
|
advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<PlaythroughAdvanceResult>
|
||||||
listAchievements(playthroughId: string): Promise<string[] | null>
|
listAchievements(playthroughId: string): Promise<string[] | null>
|
||||||
awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }>
|
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 }>
|
gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
|
||||||
listMysteries(): Promise<MysterySummary[]>
|
listMysteries(): Promise<MysterySummary[]>
|
||||||
listPlayableMysteries(): Promise<{ slug: string; title: string }[]>
|
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
|
// 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.
|
// 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([
|
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 }>(
|
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,n.name,n.role,n.default_pose_key
|
`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]),
|
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 }>(
|
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
|
`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]),
|
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]),
|
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>>()
|
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) }
|
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 terminalKey = new Map(terminals.rows.map(row => [row.id, row.terminal_key]))
|
||||||
const children = new Map<string, string[]>()
|
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])
|
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 = utterances.rows.find(row => !row.parent_utterance_id)
|
const root = rows.find(row => !row.parent_utterance_id)
|
||||||
return {
|
return {
|
||||||
rootId: root?.id ?? null,
|
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
|
const assetId = row.npc_id ? resolvePoseAssetId(poseAssets.get(row.npc_id) || {}, row.pose_key, row.default_pose_key) : null
|
||||||
return {
|
return {
|
||||||
id: row.id, utterer: row.utterer, speaker: { name: row.name || '', role: row.role || '' },
|
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,
|
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]
|
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
|
if (!node) return null
|
||||||
const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null
|
const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null
|
||||||
const musicVolume = node.music_volume / 100
|
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 === '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 === '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 }
|
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
|
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
|
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]
|
WHERE p.id=$1`, [playthroughId])).rows[0]
|
||||||
if (!row) return null
|
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 }
|
return { playthrough: { id: row.id, mysterySlug: row.mystery_slug, levelSlug: row.level_slug, status: row.status }, node }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,8 +217,8 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
const existing = await client.query('SELECT 1 FROM osint.npcs WHERE npc_key=$1 AND mystery_id IS NULL', [npc.key])
|
const existing = await client.query('SELECT 1 FROM osint.npcs WHERE npc_key=$1 AND mystery_id IS NULL', [npc.key])
|
||||||
if (existing.rows[0]) continue
|
if (existing.rows[0]) continue
|
||||||
const npcId = randomUUID()
|
const npcId = randomUUID()
|
||||||
await client.query('INSERT INTO osint.npcs (id,mystery_id,npc_key,name,role,default_pose_key) VALUES ($1,NULL,$2,$3,$4,$5)',
|
await client.query('INSERT INTO osint.npcs (id,mystery_id,npc_key,name,role,default_pose_key,phone_number,email) VALUES ($1,NULL,$2,$3,$4,$5,$6,$7)',
|
||||||
[npcId, npc.key, npc.name, npc.role || '', npc.defaultPose || null])
|
[npcId, npc.key, npc.name, npc.role || '', npc.defaultPose || null, npc.phoneNumber || null, npc.email || null])
|
||||||
for (const pose of npc.poses || []) await client.query(
|
for (const pose of npc.poses || []) await client.query(
|
||||||
'INSERT INTO osint.npc_poses (id,npc_id,pose_key,asset_id) VALUES ($1,$2,$3,$4)', [randomUUID(), npcId, pose.poseKey, pose.assetId])
|
'INSERT INTO osint.npc_poses (id,npc_id,pose_key,asset_id) VALUES ($1,$2,$3,$4)', [randomUUID(), npcId, pose.poseKey, pose.assetId])
|
||||||
}
|
}
|
||||||
@@ -261,6 +266,20 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
return Boolean(result.rowCount)
|
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) {
|
async listAchievements(playthroughId) {
|
||||||
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null
|
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
|
const rows = (await pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.achievements WHERE playthrough_id=$1 ORDER BY flag_key', [playthroughId])).rows
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import type { Pool, PoolClient } from 'pg'
|
|||||||
export type GraphSpecNode = {
|
export type GraphSpecNode = {
|
||||||
key: string; type: StoryNodeType; label?: string; x: number; y: number
|
key: string; type: StoryNodeType; label?: string; x: number; y: number
|
||||||
componentKey?: string; templateSlug?: string; version?: number; awardsFlag?: string
|
componentKey?: string; templateSlug?: string; version?: number; awardsFlag?: string
|
||||||
terminals?: { key: string; label?: string; to?: string | null }[]
|
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[] }
|
export type GraphSpec = { entry: string; nodes: GraphSpecNode[] }
|
||||||
|
|
||||||
@@ -262,11 +265,17 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
|
|||||||
if (!versionId) throw new Error(`Graph node ${node.key}: unknown level template ${node.templateSlug}`)
|
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,awards_flag) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)',
|
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])
|
[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()) {
|
for (const [index, terminal] of (node.terminals || []).entries()) {
|
||||||
const terminalId = randomUUID(); terminalIds.set(`${node.key}:${terminal.key}`, terminalId)
|
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)',
|
let npcId: string | null = null
|
||||||
[terminalId, id, terminal.key, terminal.label || terminal.key, index])
|
if (terminal.npc) { // phone-node terminal bound to an NPC (the callee)
|
||||||
|
const npc = await client.query<{ id: string }>('SELECT id FROM osint.npcs WHERE npc_key=$1 AND mystery_id IS NULL', [terminal.npc])
|
||||||
|
npcId = npc.rows[0]?.id ?? null
|
||||||
|
if (!npcId) throw new Error(`Graph node ${node.key}: terminal ${terminal.key} references unknown NPC ${terminal.npc}`)
|
||||||
|
}
|
||||||
|
await client.query('INSERT INTO osint.story_node_terminals (id,parent_node_id,terminal_key,label,sort_order,npc_id) VALUES ($1,$2,$3,$4,$5,$6)',
|
||||||
|
[terminalId, id, terminal.key, terminal.label || terminal.key, index, npcId])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Wire terminals now that all nodes exist.
|
// Wire terminals now that all nodes exist.
|
||||||
@@ -279,24 +288,38 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
|
|||||||
// Utterances (linear seed): create, then chain them and exit the last one via
|
// 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.
|
// the node's first terminal, so the crafter shows a connected flow.
|
||||||
for (const node of spec.nodes) {
|
for (const node of spec.nodes) {
|
||||||
|
const spec2 = node.utterances || []
|
||||||
const created: string[] = []
|
const created: string[] = []
|
||||||
for (const [index, utterance] of (node.utterances || []).entries()) {
|
const uttKeyToId = new Map<string, string>()
|
||||||
|
for (const [index, utterance] of spec2.entries()) {
|
||||||
let npcId: string | null = null
|
let npcId: string | null = null
|
||||||
if (utterance.npc) {
|
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])
|
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
|
npcId = npc.rows[0]?.id ?? null
|
||||||
}
|
}
|
||||||
const utteranceId = randomUUID(); created.push(utteranceId)
|
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)',
|
if (utterance.key) uttKeyToId.set(utterance.key, utteranceId)
|
||||||
[utteranceId, nodeIds.get(node.key), utterance.utterer || 'npc', npcId, utterance.pose || null, utterance.text, 60, 60 + index * 120, index])
|
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)
|
const entryId = nodeIds.get(spec.entry)
|
||||||
if (!entryId) throw new Error(`Graph entry node ${spec.entry} not found`)
|
if (!entryId) throw new Error(`Graph entry node ${spec.entry} not found`)
|
||||||
|
|||||||
+12
-2
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type FC } from 'react'
|
import { useEffect, useMemo, useRef, useState, type FC } from 'react'
|
||||||
import { audio } from './audio'
|
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 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 PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string }
|
||||||
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
|
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
|
// 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.
|
// 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 byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances])
|
||||||
const [currentId, setCurrentId] = useState<string | null>(startId ?? node.rootId)
|
const [currentId, setCurrentId] = useState<string | null>(startId ?? node.rootId)
|
||||||
|
const onAwardRef = useRef(onAward)
|
||||||
|
onAwardRef.current = onAward
|
||||||
// In preview, clicking an utterance card jumps the walk to that line.
|
// In preview, clicking an utterance card jumps the walk to that line.
|
||||||
useEffect(() => { if (startId !== undefined) setCurrentId(startId ?? node.rootId) }, [startId, node.rootId])
|
useEffect(() => { if (startId !== undefined) setCurrentId(startId ?? node.rootId) }, [startId, node.rootId])
|
||||||
const [charCount, setCharCount] = useState(0)
|
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()
|
if (ch && ch !== ' ' && charCount % 2 === 0) audio.type()
|
||||||
}, [charCount]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [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) => {
|
const pick = (choice: RuntimeUtterance) => {
|
||||||
if (!inline) audio.sfx('choice')
|
if (!inline) audio.sfx('choice')
|
||||||
|
if (!inline && choice.awardsFlag) onAwardRef.current?.(choice.id)
|
||||||
if (choice.childIds.length > 0) setCurrentId(choice.childIds[0])
|
if (choice.childIds.length > 0) setCurrentId(choice.childIds[0])
|
||||||
else onExit(choice.terminalKey ?? undefined)
|
else onExit(choice.terminalKey ?? undefined)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -94,6 +94,8 @@ export function Play() {
|
|||||||
if (!node) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status || 'OPENING CASE FILE…'}</small></div>
|
if (!node) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status || 'OPENING CASE FILE…'}</small></div>
|
||||||
if (node.kind === 'cutscene') return <CutsceneHost componentKey={node.componentKey} label={node.label} onComplete={() => { void advance() }} />
|
if (node.kind === 'cutscene') return <CutsceneHost componentKey={node.componentKey} label={node.label} onComplete={() => { void advance() }} />
|
||||||
if (node.kind === 'merit') return <MeritHost componentKey={node.componentKey} label={node.label} awardsFlag={node.awardsFlag} onComplete={() => { void advance() }} />
|
if (node.kind === 'merit') return <MeritHost componentKey={node.componentKey} label={node.label} awardsFlag={node.awardsFlag} onComplete={() => { void advance() }} />
|
||||||
if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }} onExit={terminalKey => { void advance(terminalKey) }} />
|
if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }}
|
||||||
|
onExit={terminalKey => { void advance(terminalKey) }}
|
||||||
|
onAward={utteranceId => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/utterances/${utteranceId}/reach`, { method: 'POST' }) }} />
|
||||||
return <div className="boot"><div className="seal">GU</div><small>{node.label}</small></div>
|
return <div className="boot"><div className="seal">GU</div><small>{node.label}</small></div>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user