Field notebook: capture NPC lines, tear pages onto the board

A dialogue line gains a "✎ Note this" capture that saves it to a
per-playthrough notebook (migration 035 + notebook endpoints). The inventory
notebook lists captured pages in a handwriting font (Google "Reenie Beanie")
and "✂ Tear to board" drops a page onto the current board as a note exhibit
(reusing addNote), then removes the page. Board notes render handwritten too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 20:11:15 +02:00
co-authored by Claude Opus 4.8
parent 90ef7b25e8
commit e69558e0e7
9 changed files with 99 additions and 15 deletions
+23
View File
@@ -59,6 +59,9 @@ export interface NarrativeRepository {
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 }>
notebookPages(playthroughId: string): Promise<{ id: string; text: string; createdAt: string }[]>
addNotebookPage(playthroughId: string, text: string, sourceUtteranceId?: string | null): Promise<{ id: string; text: string } | null>
removeNotebookPage(playthroughId: string, pageId: string): Promise<boolean>
phoneDirectory(playthroughId: string): Promise<{ available: boolean; numbers: { number: string; name: string }[] }>
dial(playthroughId: string, number: string): Promise<{ outcome: 'connect' | 'voicemail' | 'unknown'; name?: string; state?: PlaythroughState }>
gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
@@ -282,6 +285,26 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
return { ok: true, earned: (result.rowCount || 0) > 0 }
},
// Field notebook: lines the player captured from NPCs during this playthrough.
async notebookPages(playthroughId) {
const rows = (await pool.query<{ id: string; text: string; created_at: Date }>(
'SELECT id,text,created_at FROM osint.notebook_pages WHERE playthrough_id=$1 ORDER BY created_at', [playthroughId])).rows
return rows.map(row => ({ id: row.id, text: row.text, createdAt: row.created_at.toISOString() }))
},
async addNotebookPage(playthroughId, text, sourceUtteranceId) {
const clean = text.trim()
if (!clean) return null
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null
const row = (await pool.query<{ id: string }>(
'INSERT INTO osint.notebook_pages (playthrough_id,text,source_utterance_id) VALUES ($1,$2,$3) RETURNING id',
[playthroughId, clean, sourceUtteranceId || null])).rows[0]
return { id: row.id, text: clean }
},
async removeNotebookPage(playthroughId, pageId) {
const result = await pool.query('DELETE FROM osint.notebook_pages WHERE id=$1 AND playthrough_id=$2', [pageId, playthroughId])
return (result.rowCount || 0) > 0
},
// The phone directory available on the player's current node: the terminals of a
// phone node the current node is wired to. No connected phone node => nobody's listed.
async phoneDirectory(playthroughId) {