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:
+10
-2
@@ -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 { 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 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'
|
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 { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
||||||
import type { PlaythroughState } from './narrative'
|
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_W = 2400
|
||||||
const BOARD_H = 1500
|
const BOARD_H = 1500
|
||||||
const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [
|
const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [
|
||||||
@@ -71,6 +74,7 @@ export function App() {
|
|||||||
const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState<string | null>(null)
|
const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState<string | null>(null)
|
||||||
const [threadDraft, setThreadDraft] = useState<Connection | null>(null)
|
const [threadDraft, setThreadDraft] = useState<Connection | null>(null)
|
||||||
const [flagsOpen, setFlagsOpen] = useState(false)
|
const [flagsOpen, setFlagsOpen] = useState(false)
|
||||||
|
const [inventoryOpen, setInventoryOpen] = useState(false)
|
||||||
const [matchRulesOpen, setMatchRulesOpen] = useState(false)
|
const [matchRulesOpen, setMatchRulesOpen] = useState(false)
|
||||||
const [arrivingExhibitIds, setArrivingExhibitIds] = useState<string[]>([])
|
const [arrivingExhibitIds, setArrivingExhibitIds] = useState<string[]>([])
|
||||||
const [activePlaythroughId, setActivePlaythroughId] = useState<string | null>(null)
|
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')
|
const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline')
|
||||||
return <main className="desktop">
|
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">
|
<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>
|
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
|
||||||
<nav>
|
<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>
|
<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>}
|
{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>
|
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
|
||||||
|
{activePlaythroughId && <button className={inventoryOpen ? 'active' : ''} onClick={() => setInventoryOpen(true)}>INVENTORY</button>}
|
||||||
<button onClick={() => setHelpOpen(true)}>HELP</button>
|
<button onClick={() => setHelpOpen(true)}>HELP</button>
|
||||||
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
|
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
|
||||||
<button className={adminMenuOpen ? 'active' : ''} aria-haspopup="menu" aria-expanded={adminMenuOpen} onClick={() => setAdminMenuOpen(open => !open)}>ADMIN</button>
|
<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>}
|
{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-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>
|
<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 className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE METADATA</button></div>
|
||||||
</div>
|
</div>
|
||||||
</form></div>
|
</form></div>
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
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
|
// 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
|
// 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" />
|
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 [index, setIndex] = useState(0)
|
||||||
const [active, setActive] = useState<string | null>(null)
|
const [active, setActive] = useState<string | null>(null)
|
||||||
|
|
||||||
if (active) return <div className="inv-tool">
|
if (active) return <div className="inv-tool">
|
||||||
<button className="inv-back" onClick={() => setActive(null)}>‹ TOOLS</button>
|
<button className="inv-back" onClick={() => setActive(null)}>‹ TOOLS</button>
|
||||||
{active === 'phone' ? <PhonePreview /> : <NotebookTool />}
|
{active === 'phone' ? <PhonePreview session={session} /> : <NotebookTool />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
return <div className="inv-backdrop">
|
return <div className="inv-backdrop">
|
||||||
|
|||||||
+37
-16
@@ -264,16 +264,22 @@ function PhoneScreen({ visible, mode, dialed, callee }: { visible: boolean; mode
|
|||||||
</div>
|
</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 [open, setOpen] = useState(false)
|
||||||
const [screenOn, setScreenOn] = useState(false)
|
const [screenOn, setScreenOn] = useState(false)
|
||||||
const [mode, setMode] = useState<Mode>('home')
|
const [mode, setMode] = useState<Mode>('home')
|
||||||
const [dialed, setDialed] = useState('')
|
const [dialed, setDialed] = useState('')
|
||||||
const [callee, setCallee] = 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 [achieved, setAchieved] = useState<Set<string>>(new Set())
|
||||||
|
const [directory, setDirectory] = useState<{ number: string; name: string }[]>([])
|
||||||
const [called, setCalled] = useState<Set<string>>(new Set())
|
const [called, setCalled] = useState<Set<string>>(new Set())
|
||||||
const callTimer = useRef<number | undefined>(undefined)
|
const callTimer = useRef<number | undefined>(undefined)
|
||||||
|
const digits = (value: string) => value.replace(/\D/g, '')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) { const t = setTimeout(() => setScreenOn(true), 560); return () => clearTimeout(t) }
|
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`)
|
const res = await fetch(`/api/playthroughs/${id}/achievements`)
|
||||||
if (res.ok) setAchieved(new Set(await res.json() as string[]))
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(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
|
let id: string | null = null
|
||||||
const cur = await fetch('/api/playthroughs/current')
|
const cur = await fetch('/api/playthroughs/current')
|
||||||
if (cur.ok && cur.status !== 204) id = (await cur.json())?.playthrough?.id ?? null
|
if (cur.ok && cur.status !== 204) id = (await cur.json())?.playthrough?.id ?? null
|
||||||
@@ -300,11 +311,11 @@ export function PhonePreview() {
|
|||||||
await refreshAchievements(id)
|
await refreshAchievements(id)
|
||||||
})()
|
})()
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [])
|
}, [session])
|
||||||
|
|
||||||
const connectable = (num: string) => { const c = DIRECTORY[num]; return c ? c.requires.every(f => achieved.has(f)) : false }
|
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 an enabled, not-yet-called number is waiting.
|
// Glow the handset when a callable, not-yet-called number is waiting.
|
||||||
const glow = Object.keys(DIRECTORY).some(num => connectable(num) && !called.has(num))
|
const glow = session ? directory.some(d => !called.has(digits(d.number))) : Object.keys(DIRECTORY).some(num => connectable(num) && !called.has(num))
|
||||||
|
|
||||||
const grantElias = async () => {
|
const grantElias = async () => {
|
||||||
if (!playthroughId) return
|
if (!playthroughId) return
|
||||||
@@ -315,12 +326,20 @@ export function PhonePreview() {
|
|||||||
const resolveCall = (num: string) => {
|
const resolveCall = (num: string) => {
|
||||||
sfx.ring()
|
sfx.ring()
|
||||||
setMode('calling')
|
setMode('calling')
|
||||||
const contact = DIRECTORY[num]
|
|
||||||
window.clearTimeout(callTimer.current)
|
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 }
|
if (!contact) { sfx.unobtainable(); setMode('unknown'); return }
|
||||||
setCallee(contact.name)
|
setCallee(contact.name)
|
||||||
setCalled(prev => new Set(prev).add(num))
|
|
||||||
if (connectable(num)) setMode('connected')
|
if (connectable(num)) setMode('connected')
|
||||||
else { sfx.voicemail(); setMode('voicemail') }
|
else { sfx.voicemail(); setMode('voicemail') }
|
||||||
}, 950)
|
}, 950)
|
||||||
@@ -355,11 +374,13 @@ export function PhonePreview() {
|
|||||||
<PhoneScreen visible={screenOn} mode={mode} dialed={dialed} callee={callee} />
|
<PhoneScreen visible={screenOn} mode={mode} dialed={dialed} callee={callee} />
|
||||||
</div>
|
</div>
|
||||||
<button className={`phone-open-btn${glow && !open ? ' glow' : ''}`} onClick={() => setOpen(o => !o)}>{open ? 'CLOSE' : 'OPEN'}</button>
|
<button className={`phone-open-btn${glow && !open ? ' glow' : ''}`} onClick={() => setOpen(o => !o)}>{open ? 'CLOSE' : 'OPEN'}</button>
|
||||||
<div className="phone-dev">
|
{session
|
||||||
<button className="phone-dev-btn" disabled={!playthroughId || achieved.has('elias_number_callable')} onClick={grantElias}>
|
? <p className="phone-hint">Key in a number, then press Call.</p>
|
||||||
{achieved.has('elias_number_callable') ? '✓ elias_number_callable' : '▸ grant elias_number_callable'}
|
: <div className="phone-dev">
|
||||||
</button>
|
<button className="phone-dev-btn" disabled={!playthroughId || achieved.has('elias_number_callable')} onClick={grantElias}>
|
||||||
<p className="phone-hint">dial <code>55501</code> Elias (voicemail → connect once granted) · <code>55502</code> Voss (voicemail) · else unobtainable</p>
|
{achieved.has('elias_number_callable') ? '✓ elias_number_callable' : '▸ grant elias_number_callable'}
|
||||||
</div>
|
</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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ export function Play() {
|
|||||||
if (!cancelled) res.ok ? apply(await res.json()) : setStatus('NODE NOT FOUND')
|
if (!cancelled) res.ok ? apply(await res.json()) : setStatus('NODE NOT FOUND')
|
||||||
return
|
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
|
// 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.
|
// 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')])
|
const [cases, current] = await Promise.all([fetch('/api/mysteries'), fetch('/api/playthroughs/current')])
|
||||||
|
|||||||
Reference in New Issue
Block a user