Phone directory foundation: NPC contacts + phone node + terminal→NPC

NPCs gain phone_number and email (the phone book). A new 'phone' node type
holds a directory whose terminals each bind to an NPC via npc_id (the callee
reached by dialing that number) and wire to the dialogue that plays on
connect. Migration 029 adds the columns/type; repositories and admin API
thread contacts and the per-terminal NPC binding. Authoring foundation only
— the dial runtime and tools sidebar come next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 16:55:45 +02:00
co-authored by Claude Opus 4.8
parent 778c6d972f
commit a925d61b7e
4 changed files with 34 additions and 17 deletions
+12
View File
@@ -0,0 +1,12 @@
-- Phone book + phone directory nodes. NPCs gain contact details; a phone node's
-- terminals each bind to an NPC (the callee) whose number the player dials.
ALTER TABLE osint.npcs ADD COLUMN phone_number TEXT;
ALTER TABLE osint.npcs ADD COLUMN email TEXT;
ALTER TABLE osint.story_nodes DROP CONSTRAINT story_nodes_node_type_check;
ALTER TABLE osint.story_nodes ADD CONSTRAINT story_nodes_node_type_check
CHECK (node_type IN ('cutscene','dialogue','level','det_gate','llm_gate','merit','phone'));
-- A terminal on a phone node binds to the NPC you reach by dialing their number.
ALTER TABLE osint.story_node_terminals ADD COLUMN npc_id UUID REFERENCES osint.npcs(id) ON DELETE SET NULL;
+3 -3
View File
@@ -30,7 +30,7 @@ const textExtractor = createTextExtractorFromEnv()
const levels = createLevelRepository(pool, editingEnabled, objectStorage) const levels = createLevelRepository(pool, editingEnabled, objectStorage)
const narrative = createNarrativeRepository(pool, objectStorage) const narrative = createNarrativeRepository(pool, objectStorage)
const storyGraph = createStoryGraphRepository(pool) const storyGraph = createStoryGraphRepository(pool)
const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate', 'merit'] const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate', 'merit', 'phone']
function wantsEdit(req: express.Request) { function wantsEdit(req: express.Request) {
return editingEnabled && req.query.edit === '1' && hasAdminClaim(req) return editingEnabled && req.query.edit === '1' && hasAdminClaim(req)
@@ -236,13 +236,13 @@ app.post('/api/admin/npcs', requireAdmin, async (req, res, next) => {
try { try {
if (!requireEditing(res)) return if (!requireEditing(res)) return
if (!req.body?.key) return res.status(400).json({ error: 'An NPC key is required' }) if (!req.body?.key) return res.status(400).json({ error: 'An NPC key is required' })
res.status(201).json(await narrative.createNpc({ key: String(req.body.key), name: String(req.body.name || ''), role: String(req.body.role || ''), defaultPose: req.body.defaultPose || null })) res.status(201).json(await narrative.createNpc({ key: String(req.body.key), name: String(req.body.name || ''), role: String(req.body.role || ''), defaultPose: req.body.defaultPose || null, phoneNumber: req.body.phoneNumber ?? null, email: req.body.email ?? null }))
} catch (error) { next(error) } } catch (error) { next(error) }
}) })
app.patch('/api/admin/npcs/:id', requireAdmin, async (req, res, next) => { app.patch('/api/admin/npcs/:id', requireAdmin, async (req, res, next) => {
try { try {
if (!requireEditing(res)) return if (!requireEditing(res)) return
const npc = await narrative.updateNpc(String(req.params.id), { name: req.body?.name, role: req.body?.role, defaultPose: req.body?.defaultPose }) const npc = await narrative.updateNpc(String(req.params.id), { name: req.body?.name, role: req.body?.role, defaultPose: req.body?.defaultPose, phoneNumber: req.body?.phoneNumber, email: req.body?.email })
npc ? res.json(npc) : res.status(404).json({ error: 'NPC not found' }) npc ? res.json(npc) : res.status(404).json({ error: 'NPC not found' })
} catch (error) { next(error) } } catch (error) { next(error) }
}) })
+11 -8
View File
@@ -6,7 +6,7 @@ import type { ObjectStorage } from './objectStorage.js'
export type UploadedFile = { buffer: Buffer; originalname: string; mimetype: string; size: number } export type UploadedFile = { buffer: Buffer; originalname: string; mimetype: string; size: number }
export type AssetDto = { id: string; originalName: string; mimeType: string; byteSize: number; url: string } export type AssetDto = { id: string; originalName: string; mimeType: string; byteSize: number; url: string }
export type PoseDto = { poseKey: string; assetId: string; url: string } export type PoseDto = { poseKey: string; assetId: string; url: string }
export type NpcDto = { id: string; key: string; name: string; role: string; defaultPose: string | null; poses: PoseDto[]; inUse: boolean } export type NpcDto = { id: string; key: string; name: string; role: string; defaultPose: string | null; phoneNumber: string | null; email: string | null; poses: PoseDto[]; inUse: boolean }
export type MysterySummary = { id: string; slug: string; title: string; nodes: number } export type MysterySummary = { id: string; slug: string; title: string; nodes: number }
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' }
@@ -58,8 +58,8 @@ export interface NarrativeRepository {
listAssets(): Promise<AssetDto[]> listAssets(): Promise<AssetDto[]>
deleteAsset(id: string): Promise<'deleted' | 'in_use' | 'not_found'> deleteAsset(id: string): Promise<'deleted' | 'in_use' | 'not_found'>
listNpcs(): Promise<NpcDto[]> listNpcs(): Promise<NpcDto[]>
createNpc(input: { key: string; name: string; role?: string; defaultPose?: string | null }): Promise<NpcDto> createNpc(input: { key: string; name: string; role?: string; defaultPose?: string | null; phoneNumber?: string | null; email?: string | null }): Promise<NpcDto>
updateNpc(id: string, input: { name?: string; role?: string; defaultPose?: string | null }): Promise<NpcDto | null> updateNpc(id: string, input: { name?: string; role?: string; defaultPose?: string | null; phoneNumber?: string | null; email?: string | null }): Promise<NpcDto | null>
deleteNpc(id: string): Promise<'deleted' | 'in_use' | 'not_found'> deleteNpc(id: string): Promise<'deleted' | 'in_use' | 'not_found'>
addPose(npcId: string, poseKey: string, file: UploadedFile): Promise<NpcDto | null> addPose(npcId: string, poseKey: string, file: UploadedFile): Promise<NpcDto | null>
deletePose(npcId: string, poseKey: string): Promise<NpcDto | null> deletePose(npcId: string, poseKey: string): Promise<NpcDto | null>
@@ -173,8 +173,8 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
} }
async function loadNpc(id: string): Promise<NpcDto | null> { async function loadNpc(id: string): Promise<NpcDto | null> {
const npc = (await pool.query<{ id: string; npc_key: string; name: string; role: string; default_pose_key: string | null }>( const npc = (await pool.query<{ id: string; npc_key: string; name: string; role: string; default_pose_key: string | null; phone_number: string | null; email: string | null }>(
'SELECT id,npc_key,name,role,default_pose_key FROM osint.npcs WHERE id=$1 AND mystery_id IS NULL', [id])).rows[0] 'SELECT id,npc_key,name,role,default_pose_key,phone_number,email FROM osint.npcs WHERE id=$1 AND mystery_id IS NULL', [id])).rows[0]
if (!npc) return null if (!npc) return null
const [poses, usage] = await Promise.all([ const [poses, usage] = await Promise.all([
pool.query<{ pose_key: string; asset_id: string }>('SELECT pose_key,asset_id FROM osint.npc_poses WHERE npc_id=$1 AND asset_id IS NOT NULL ORDER BY pose_key', [id]), pool.query<{ pose_key: string; asset_id: string }>('SELECT pose_key,asset_id FROM osint.npc_poses WHERE npc_id=$1 AND asset_id IS NOT NULL ORDER BY pose_key', [id]),
@@ -182,6 +182,7 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
]) ])
return { return {
id: npc.id, key: npc.npc_key, name: npc.name, role: npc.role, defaultPose: npc.default_pose_key, id: npc.id, key: npc.npc_key, name: npc.name, role: npc.role, defaultPose: npc.default_pose_key,
phoneNumber: npc.phone_number, email: npc.email,
poses: poses.rows.map(pose => ({ poseKey: pose.pose_key, assetId: pose.asset_id, url: `/api/assets/${pose.asset_id}` })), poses: poses.rows.map(pose => ({ poseKey: pose.pose_key, assetId: pose.asset_id, url: `/api/assets/${pose.asset_id}` })),
inUse: Number(usage.rows[0].count) > 0, inUse: Number(usage.rows[0].count) > 0,
} }
@@ -372,17 +373,19 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
const key = input.key.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') const key = input.key.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '')
if (!key) throw new Error('An NPC key is required') if (!key) throw new Error('An NPC key is required')
const id = randomUUID() const id = randomUUID()
await pool.query('INSERT INTO osint.npcs (id,mystery_id,npc_key,name,role,default_pose_key) VALUES ($1,NULL,$2,$3,$4,$5)', await pool.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)',
[id, key, input.name.trim() || key, input.role?.trim() || '', input.defaultPose || null]) [id, key, input.name.trim() || key, input.role?.trim() || '', input.defaultPose || null, input.phoneNumber?.trim() || null, input.email?.trim() || null])
return (await loadNpc(id))! return (await loadNpc(id))!
}, },
async updateNpc(id, input) { async updateNpc(id, input) {
const existing = await loadNpc(id) const existing = await loadNpc(id)
if (!existing) return null if (!existing) return null
await pool.query('UPDATE osint.npcs SET name=$2,role=$3,default_pose_key=$4 WHERE id=$1 AND mystery_id IS NULL', [ await pool.query('UPDATE osint.npcs SET name=$2,role=$3,default_pose_key=$4,phone_number=$5,email=$6 WHERE id=$1 AND mystery_id IS NULL', [
id, input.name?.trim() ?? existing.name, input.role?.trim() ?? existing.role, id, input.name?.trim() ?? existing.name, input.role?.trim() ?? existing.role,
input.defaultPose === undefined ? existing.defaultPose : (input.defaultPose || null), input.defaultPose === undefined ? existing.defaultPose : (input.defaultPose || null),
input.phoneNumber === undefined ? existing.phoneNumber : (input.phoneNumber?.trim() || null),
input.email === undefined ? existing.email : (input.email?.trim() || null),
]) ])
return loadNpc(id) return loadNpc(id)
}, },
+8 -6
View File
@@ -9,8 +9,8 @@ export type GraphSpecNode = {
} }
export type GraphSpec = { entry: string; nodes: GraphSpecNode[] } export type GraphSpec = { entry: string; nodes: GraphSpecNode[] }
export type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate' | 'merit' export type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate' | 'merit' | 'phone'
export type TerminalDto = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number } export type TerminalDto = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number; npcId: string | null }
export type StoryNodeDto = { export type StoryNodeDto = {
id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean
xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; musicAssetId: string | null; musicVolume: number xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; musicAssetId: string | null; musicVolume: number
@@ -34,6 +34,7 @@ const DEFAULT_TERMINALS: Record<StoryNodeType, { key: string; label: string }[]>
det_gate: [{ key: 'pass', label: 'Pass' }], det_gate: [{ key: 'pass', label: 'Pass' }],
llm_gate: [{ key: 'pass', label: 'Pass' }], llm_gate: [{ key: 'pass', label: 'Pass' }],
merit: [{ key: 'continue', label: 'Continue' }], merit: [{ key: 'continue', label: 'Continue' }],
phone: [], // a phone node's terminals are added per contact (each bound to an NPC)
} }
export interface StoryGraphRepository { export interface StoryGraphRepository {
@@ -42,7 +43,7 @@ export interface StoryGraphRepository {
updateNode(nodeId: string, input: Partial<{ label: string; xpos: number; ypos: number; hasUtterances: boolean; componentKey: string | null; levelTemplateVersionId: string | null; musicAssetId: string | null; musicVolume: number; awardsFlag: string | null }>): Promise<StoryNodeDto | null> updateNode(nodeId: string, input: Partial<{ label: string; xpos: number; ypos: number; hasUtterances: boolean; componentKey: string | null; levelTemplateVersionId: string | null; musicAssetId: string | null; musicVolume: number; awardsFlag: string | null }>): Promise<StoryNodeDto | null>
deleteNode(nodeId: string): Promise<boolean> deleteNode(nodeId: string): Promise<boolean>
addTerminal(nodeId: string, input: { terminalKey: string; label?: string }): Promise<StoryNodeDto | null> addTerminal(nodeId: string, input: { terminalKey: string; label?: string }): Promise<StoryNodeDto | null>
updateTerminal(terminalId: string, input: Partial<{ label: string; sortOrder: number; toNodeId: string | null }>): Promise<{ ok: boolean; error?: string }> updateTerminal(terminalId: string, input: Partial<{ label: string; sortOrder: number; toNodeId: string | null; npcId: string | null }>): Promise<{ ok: boolean; error?: string }>
deleteTerminal(terminalId: string): Promise<boolean> deleteTerminal(terminalId: string): Promise<boolean>
setEntryNode(mysteryId: string, nodeId: string | null): Promise<{ ok: boolean; error?: string }> setEntryNode(mysteryId: string, nodeId: string | null): Promise<{ ok: boolean; error?: string }>
listLevelTemplates(): Promise<LevelTemplateOption[]> listLevelTemplates(): Promise<LevelTemplateOption[]>
@@ -65,14 +66,14 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
const [nodes, terminals] = await Promise.all([ const [nodes, terminals] = await Promise.all([
pool.query<{ id: string; node_type: StoryNodeType; label: string; has_utterances: boolean; xpos: number; ypos: number; level_template_version_id: string | null; component_key: string | null; music_asset_id: string | null; music_volume: number; awards_flag: string | null }>( pool.query<{ id: string; node_type: StoryNodeType; label: string; has_utterances: boolean; xpos: number; ypos: number; level_template_version_id: string | null; component_key: string | null; music_asset_id: string | null; music_volume: number; awards_flag: string | null }>(
'SELECT id,node_type,label,has_utterances,xpos,ypos,level_template_version_id,component_key,music_asset_id,music_volume,awards_flag FROM osint.story_nodes WHERE mystery_id=$1 ORDER BY created_at', [mysteryId]), 'SELECT id,node_type,label,has_utterances,xpos,ypos,level_template_version_id,component_key,music_asset_id,music_volume,awards_flag FROM osint.story_nodes WHERE mystery_id=$1 ORDER BY created_at', [mysteryId]),
pool.query<{ id: string; parent_node_id: string; terminal_key: string; label: string; to_node_id: string | null; sort_order: number }>( pool.query<{ id: string; parent_node_id: string; terminal_key: string; label: string; to_node_id: string | null; sort_order: number; npc_id: string | null }>(
`SELECT t.id,t.parent_node_id,t.terminal_key,t.label,t.to_node_id,t.sort_order FROM osint.story_node_terminals t `SELECT t.id,t.parent_node_id,t.terminal_key,t.label,t.to_node_id,t.sort_order,t.npc_id FROM osint.story_node_terminals t
JOIN osint.story_nodes n ON n.id=t.parent_node_id WHERE n.mystery_id=$1 ORDER BY t.sort_order,t.terminal_key`, [mysteryId]), JOIN osint.story_nodes n ON n.id=t.parent_node_id WHERE n.mystery_id=$1 ORDER BY t.sort_order,t.terminal_key`, [mysteryId]),
]) ])
const byNode = new Map<string, TerminalDto[]>() const byNode = new Map<string, TerminalDto[]>()
for (const row of terminals.rows) { for (const row of terminals.rows) {
const list = byNode.get(row.parent_node_id) || [] const list = byNode.get(row.parent_node_id) || []
list.push({ id: row.id, terminalKey: row.terminal_key, label: row.label, toNodeId: row.to_node_id, sortOrder: row.sort_order }) list.push({ id: row.id, terminalKey: row.terminal_key, label: row.label, toNodeId: row.to_node_id, sortOrder: row.sort_order, npcId: row.npc_id })
byNode.set(row.parent_node_id, list) byNode.set(row.parent_node_id, list)
} }
return { return {
@@ -158,6 +159,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
if (input.label !== undefined) set('label', input.label.trim()) if (input.label !== undefined) set('label', input.label.trim())
if (input.sortOrder !== undefined) set('sort_order', input.sortOrder) if (input.sortOrder !== undefined) set('sort_order', input.sortOrder)
if (input.toNodeId !== undefined) set('to_node_id', input.toNodeId) if (input.toNodeId !== undefined) set('to_node_id', input.toNodeId)
if (input.npcId !== undefined) set('npc_id', input.npcId || null)
if (sets.length) await pool.query(`UPDATE osint.story_node_terminals SET ${sets.join(',')} WHERE id=$1`, values) if (sets.length) await pool.query(`UPDATE osint.story_node_terminals SET ${sets.join(',')} WHERE id=$1`, values)
return { ok: true } return { ok: true }
}, },