From e69558e0e76287ff10f9b1cdb90f475b8b6a028d Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Sat, 22 Aug 2026 20:11:15 +0200 Subject: [PATCH] Field notebook: capture NPC lines, tear pages onto the board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- index.html | 3 +++ migrations/035_notebook.sql | 10 ++++++++++ server/index.ts | 17 +++++++++++++++++ server/narrativeRepository.ts | 23 +++++++++++++++++++++++ src/App.tsx | 9 +++++---- src/inventory.tsx | 29 +++++++++++++++++++++++------ src/narrative.tsx | 6 ++++-- src/play.tsx | 3 ++- src/styles.css | 14 ++++++++++++-- 9 files changed, 99 insertions(+), 15 deletions(-) create mode 100644 migrations/035_notebook.sql diff --git a/index.html b/index.html index f40471a..83ce924 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,9 @@ + + + GUPI OSINT Board diff --git a/migrations/035_notebook.sql b/migrations/035_notebook.sql new file mode 100644 index 0000000..39f7fcc --- /dev/null +++ b/migrations/035_notebook.sql @@ -0,0 +1,10 @@ +-- The player's field notebook: lines captured from NPCs during play, per playthrough. +-- A page can later be torn onto the board as a note exhibit. +CREATE TABLE osint.notebook_pages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + playthrough_id UUID NOT NULL REFERENCES osint.playthroughs(id) ON DELETE CASCADE, + text TEXT NOT NULL, + source_utterance_id UUID REFERENCES osint.utterances(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX notebook_pages_playthrough_idx ON osint.notebook_pages (playthrough_id, created_at); diff --git a/server/index.ts b/server/index.ts index 912f442..3793364 100644 --- a/server/index.ts +++ b/server/index.ts @@ -516,6 +516,23 @@ app.post('/api/playthroughs/:id/utterances/:uid/reach', async (req, res, next) = result.ok ? res.json({ earned: result.earned ?? false }) : res.status(404).json({ error: 'Not found' }) } catch (error) { next(error) } }) +// Field notebook: capture NPC lines during play, list them, and remove (on tear/discard). +app.get('/api/playthroughs/:id/notebook', async (req, res, next) => { + try { res.json(await narrative.notebookPages(String(req.params.id))) } + catch (error) { next(error) } +}) +app.post('/api/playthroughs/:id/notebook', async (req, res, next) => { + try { + const page = await narrative.addNotebookPage(String(req.params.id), String(req.body?.text || ''), req.body?.utteranceId ? String(req.body.utteranceId) : null) + page ? res.status(201).json(page) : res.status(400).json({ error: 'Empty text or unknown playthrough' }) + } catch (error) { next(error) } +}) +app.delete('/api/playthroughs/:id/notebook/:pageId', async (req, res, next) => { + try { + const ok = await narrative.removeNotebookPage(String(req.params.id), String(req.params.pageId)) + ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Not found' }) + } catch (error) { next(error) } +}) // The phone tool: the directory available on the current node, and dialing a number. app.get('/api/playthroughs/:id/phone', async (req, res, next) => { try { res.json(await narrative.phoneDirectory(String(req.params.id))) } diff --git a/server/narrativeRepository.ts b/server/narrativeRepository.ts index bb4315b..e8dfa60 100644 --- a/server/narrativeRepository.ts +++ b/server/narrativeRepository.ts @@ -59,6 +59,9 @@ export interface NarrativeRepository { listAchievements(playthroughId: string): Promise 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 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) { diff --git a/src/App.tsx b/src/App.tsx index 2e6ecc2..ce9802f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -214,8 +214,8 @@ export function App() { setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED') } - const addNote = () => { - const content = window.prompt('What do you think this evidence means?')?.trim() + const addNote = (preset?: string) => { + const content = typeof preset === 'string' ? preset : window.prompt('What do you think this evidence means?')?.trim() if (!content || !caseState) return const { viewport } = caseState const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 }) @@ -543,7 +543,8 @@ export function App() { const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline') return
{inventoryOpen && activePlaythroughId && - window.location.assign('/?resume=1') }} onClose={() => setInventoryOpen(false)} /> + window.location.assign('/?resume=1') }} + onTearToBoard={text => { addNote(text); setInventoryOpen(false) }} onClose={() => setInventoryOpen(false)} /> }
GUOSINT BOARD / {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}
@@ -608,7 +609,7 @@ export function App() { - + diff --git a/src/inventory.tsx b/src/inventory.tsx index 57666bc..52f70b1 100644 --- a/src/inventory.tsx +++ b/src/inventory.tsx @@ -85,13 +85,13 @@ function ToolRack({ index }: { index: number }) { return
} -export function Inventory({ onClose, session }: { onClose?: () => void; session?: PhoneSession }) { +export function Inventory({ onClose, session, onTearToBoard }: { onClose?: () => void; session?: PhoneSession; onTearToBoard?: (text: string) => void }) { const [index, setIndex] = useState(0) const [active, setActive] = useState(null) if (active) return
- {active === 'phone' ? : } + {active === 'phone' ? : }
return
@@ -108,13 +108,30 @@ export function Inventory({ onClose, session }: { onClose?: () => void; session?
} -// Placeholder notebook tool — a lined page you can scribble on (not yet persisted). -function NotebookTool() { - const [text, setText] = useState('') +// Field notebook: the lines captured from NPCs during play. A page can be torn off +// onto the board as a note (when a board tear handler is available). +function NotebookTool({ playthroughId, onTear }: { playthroughId?: string; onTear?: (text: string) => void }) { + const [pages, setPages] = useState<{ id: string; text: string }[]>([]) + useEffect(() => { + if (!playthroughId) return + fetch(`/api/playthroughs/${playthroughId}/notebook`).then(r => r.ok ? r.json() : []).then(setPages).catch(() => {}) + }, [playthroughId]) + const remove = async (id: string) => { + if (playthroughId) await fetch(`/api/playthroughs/${playthroughId}/notebook/${id}`, { method: 'DELETE' }) + setPages(list => list.filter(page => page.id !== id)) + } + const tear = (page: { id: string; text: string }) => { onTear?.(page.text); void remove(page.id) } return
FIELD NOTEBOOK
-