Phone runtime (2b): inventory phone on the board dials for real

The phone gains a session mode that reads the live directory (/phone) and
dials (/dial); the inventory threads it through. On a campaign level the
board shows an INVENTORY nav button that opens the tool rack, and connecting
a call hands back to the campaign via /?resume=1 (a fast-path that resumes
the current node instead of the splash). On Barricelli's note-board: open
inventory -> phone -> dial 5550100 -> Glitch Hunter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 19:49:00 +02:00
co-authored by Claude Opus 4.8
parent 56bf2f97d0
commit 80bc9b21a7
4 changed files with 57 additions and 21 deletions
+10 -2
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, CircleHelp, ClipboardCheck, FileText, FolderOpen, Hand, Image as ImageIcon, Images, Info, Link2, Minus, MousePointer2, Network, Newspaper, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
import { AdminPanel } from './admin'
@@ -6,6 +6,9 @@ import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen,
import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
import type { PlaythroughState } from './narrative'
// three.js stays out of the board bundle until the player opens the inventory.
const Inventory = lazy(() => import('./inventory').then(m => ({ default: m.Inventory })))
const BOARD_W = 2400
const BOARD_H = 1500
const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [
@@ -71,6 +74,7 @@ export function App() {
const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState<string | null>(null)
const [threadDraft, setThreadDraft] = useState<Connection | null>(null)
const [flagsOpen, setFlagsOpen] = useState(false)
const [inventoryOpen, setInventoryOpen] = useState(false)
const [matchRulesOpen, setMatchRulesOpen] = useState(false)
const [arrivingExhibitIds, setArrivingExhibitIds] = useState<string[]>([])
const [activePlaythroughId, setActivePlaythroughId] = useState<string | null>(null)
@@ -537,6 +541,9 @@ export function App() {
})
const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline')
return <main className="desktop">
{inventoryOpen && activePlaythroughId && <Suspense fallback={null}>
<Inventory session={{ playthroughId: activePlaythroughId, onConnect: () => window.location.assign('/?resume=1') }} onClose={() => setInventoryOpen(false)} />
</Suspense>}
<header className="menubar">
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
<nav>
@@ -544,6 +551,7 @@ export function App() {
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{briefAttentionCount > 0 && <b className="brief-count">{briefAttentionCount}</b>}</button>
{caseState.report && <button className={reportOpen ? 'active' : ''} aria-label="Case report" onClick={() => reportOpen ? setReportOpen(false) : void openCaseReport()}>CASE REPORT{reportAttentionCount > 0 && <b className="brief-count">{reportAttentionCount}</b>}</button>}
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
{activePlaythroughId && <button className={inventoryOpen ? 'active' : ''} onClick={() => setInventoryOpen(true)}>INVENTORY</button>}
<button onClick={() => setHelpOpen(true)}>HELP</button>
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
<button className={adminMenuOpen ? 'active' : ''} aria-haspopup="menu" aria-expanded={adminMenuOpen} onClick={() => setAdminMenuOpen(open => !open)}>ADMIN</button>
@@ -1315,7 +1323,7 @@ function FileEditor({ document, canEditGates, onClose, onSave }: { document: Cas
{canEditGates && <label className="field gate-field"><span>REVEAL FLAGS · ALL REQUIRED</span><input value={requiredFlags} placeholder="tip.received, archive.unlocked" pattern="[a-z0-9_.\-, ]*" onChange={event => setRequiredFlags(event.target.value)}/><small>Leave blank to show this document when the level first loads.</small></label>}
<div className="metadata-heading"><div><b>ADDITIONAL METADATA</b><small>FREE-FORM KEY / VALUE FIELDS</small></div><button type="button" onClick={() => setMetadata(rows => [...rows, { id: uid('metadata'), key: '', value: '' }])}><Plus size={13}/> ADD FIELD</button></div>
<div className="metadata-rows">{metadata.length === 0 && <p>NO ADDITIONAL METADATA</p>}{metadata.map(row => <div className="metadata-row" key={row.id}><input aria-label="Metadata key" placeholder="FIELD" value={row.key} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, key: event.target.value } : candidate))}/><input aria-label="Metadata value" placeholder="VALUE" value={row.value} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, value: event.target.value } : candidate))}/><button type="button" aria-label="Remove metadata field" onClick={() => setMetadata(rows => rows.filter(candidate => candidate.id !== row.id))}><Trash2 size={13}/></button></div>)}</div>
<p className="folder-editor-note">This metadata belongs to the source file, not to any folder that contains it.</p>
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE METADATA</button></div>
</div>
</form></div>
+3 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import * as THREE from 'three'
import { buildPhone, CLOSED_ANGLE, PhonePreview } from './phone'
import { buildPhone, CLOSED_ANGLE, PhonePreview, type PhoneSession } from './phone'
// A reusable "tools" inventory: a three.js rack you cycle through, one 3D tool at a
// time, then USE to open it. Tool-driven (see TOOLS below), so it isn't tied to any
@@ -85,13 +85,13 @@ function ToolRack({ index }: { index: number }) {
return <div ref={stageRef} className="inv-stage" />
}
export function Inventory({ onClose }: { onClose?: () => void }) {
export function Inventory({ onClose, session }: { onClose?: () => void; session?: PhoneSession }) {
const [index, setIndex] = useState(0)
const [active, setActive] = useState<string | null>(null)
if (active) return <div className="inv-tool">
<button className="inv-back" onClick={() => setActive(null)}> TOOLS</button>
{active === 'phone' ? <PhonePreview /> : <NotebookTool />}
{active === 'phone' ? <PhonePreview session={session} /> : <NotebookTool />}
</div>
return <div className="inv-backdrop">
+33 -12
View File
@@ -264,16 +264,22 @@ function PhoneScreen({ visible, mode, dialed, callee }: { visible: boolean; mode
</div>
}
export function PhonePreview() {
// In-game the phone runs against a live playthrough: it dials the connected phone
// node's directory and, on connect, hands back to the campaign runtime.
export type PhoneSession = { playthroughId: string; onConnect: () => void }
export function PhonePreview({ session }: { session?: PhoneSession } = {}) {
const [open, setOpen] = useState(false)
const [screenOn, setScreenOn] = useState(false)
const [mode, setMode] = useState<Mode>('home')
const [dialed, setDialed] = useState('')
const [callee, setCallee] = useState('')
const [playthroughId, setPlaythroughId] = useState<string | null>(null)
const [playthroughId, setPlaythroughId] = useState<string | null>(session?.playthroughId ?? null)
const [achieved, setAchieved] = useState<Set<string>>(new Set())
const [directory, setDirectory] = useState<{ number: string; name: string }[]>([])
const [called, setCalled] = useState<Set<string>>(new Set())
const callTimer = useRef<number | undefined>(undefined)
const digits = (value: string) => value.replace(/\D/g, '')
useEffect(() => {
if (open) { const t = setTimeout(() => setScreenOn(true), 560); return () => clearTimeout(t) }
@@ -284,10 +290,15 @@ export function PhonePreview() {
const res = await fetch(`/api/playthroughs/${id}/achievements`)
if (res.ok) setAchieved(new Set(await res.json() as string[]))
}
// Attach to the player's live playthrough (or create one) and load its case-state.
// Game session -> load the connected directory. Spike -> a demo playthrough + case-state.
useEffect(() => {
let cancelled = false
;(async () => {
if (session) {
const res = await fetch(`/api/playthroughs/${session.playthroughId}/phone`)
if (!cancelled && res.ok) setDirectory((await res.json()).numbers || [])
return
}
let id: string | null = null
const cur = await fetch('/api/playthroughs/current')
if (cur.ok && cur.status !== 204) id = (await cur.json())?.playthrough?.id ?? null
@@ -300,11 +311,11 @@ export function PhonePreview() {
await refreshAchievements(id)
})()
return () => { cancelled = true }
}, [])
}, [session])
const connectable = (num: string) => { const c = DIRECTORY[num]; return c ? c.requires.every(f => achieved.has(f)) : false }
// Glow the handset when an enabled, not-yet-called number is waiting.
const glow = Object.keys(DIRECTORY).some(num => connectable(num) && !called.has(num))
const connectable = (num: string) => session ? directory.some(d => digits(d.number) === num) : (DIRECTORY[num] ? DIRECTORY[num].requires.every(f => achieved.has(f)) : false)
// Glow the handset when a callable, not-yet-called number is waiting.
const glow = session ? directory.some(d => !called.has(digits(d.number))) : Object.keys(DIRECTORY).some(num => connectable(num) && !called.has(num))
const grantElias = async () => {
if (!playthroughId) return
@@ -315,12 +326,20 @@ export function PhonePreview() {
const resolveCall = (num: string) => {
sfx.ring()
setMode('calling')
const contact = DIRECTORY[num]
window.clearTimeout(callTimer.current)
callTimer.current = window.setTimeout(() => {
callTimer.current = window.setTimeout(async () => {
setCalled(prev => new Set(prev).add(num))
if (session) {
const res = await fetch(`/api/playthroughs/${session.playthroughId}/dial`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ number: num }) })
const result = res.ok ? await res.json() : { outcome: 'unknown' }
if (result.outcome === 'connect') { setCallee(result.name || ''); setMode('connected'); window.setTimeout(() => session.onConnect(), 950) }
else if (result.outcome === 'voicemail') { setCallee(result.name || ''); sfx.voicemail(); setMode('voicemail') }
else { sfx.unobtainable(); setMode('unknown') }
return
}
const contact = DIRECTORY[num]
if (!contact) { sfx.unobtainable(); setMode('unknown'); return }
setCallee(contact.name)
setCalled(prev => new Set(prev).add(num))
if (connectable(num)) setMode('connected')
else { sfx.voicemail(); setMode('voicemail') }
}, 950)
@@ -355,11 +374,13 @@ export function PhonePreview() {
<PhoneScreen visible={screenOn} mode={mode} dialed={dialed} callee={callee} />
</div>
<button className={`phone-open-btn${glow && !open ? ' glow' : ''}`} onClick={() => setOpen(o => !o)}>{open ? 'CLOSE' : 'OPEN'}</button>
<div className="phone-dev">
{session
? <p className="phone-hint">Key in a number, then press Call.</p>
: <div className="phone-dev">
<button className="phone-dev-btn" disabled={!playthroughId || achieved.has('elias_number_callable')} onClick={grantElias}>
{achieved.has('elias_number_callable') ? '✓ elias_number_callable' : '▸ grant elias_number_callable'}
</button>
<p className="phone-hint">dial <code>55501</code> Elias (voicemail connect once granted) · <code>55502</code> Voss (voicemail) · else unobtainable</p>
</div>
</div>}
</div>
}
+7
View File
@@ -41,6 +41,13 @@ export function Play() {
if (!cancelled) res.ok ? apply(await res.json()) : setStatus('NODE NOT FOUND')
return
}
// ?resume=1 (e.g. handed back from the in-board phone) drops straight into the
// current node instead of the splash.
if (new URLSearchParams(window.location.search).has('resume')) {
const res = await fetch('/api/playthroughs/current')
if (cancelled) return
if (res.ok && res.status !== 204) { apply(await res.json()); return }
}
// The bare root is always the front door: list the playable cases, and offer
// Resume on the one already in progress rather than auto-resuming into it.
const [cases, current] = await Promise.all([fetch('/api/mysteries'), fetch('/api/playthroughs/current')])