Files
gupi-osint-board/src/play.tsx
T

102 lines
5.5 KiB
TypeScript
Raw Normal View History

2026-08-22 15:12:56 +02:00
import { useCallback, useEffect, useState } from 'react'
import { CutsceneHost, DialoguePlayer, MeritHost, type PlaythroughState } from './narrative'
2026-08-22 15:51:32 +02:00
type MysterySummary = { slug: string; title: string }
2026-08-22 15:12:56 +02:00
// The game's front door and campaign runtime. Shows the splash when there's no
// active playthrough; otherwise walks the story graph — cutscene and dialogue
// nodes render here, and when the graph reaches a LEVEL node it hands off to the
// board route (/level/<clone id>), which App renders.
export function Play() {
const [state, setState] = useState<PlaythroughState | null>(null)
const [resumable, setResumable] = useState<PlaythroughState | null>(null)
2026-08-22 15:51:32 +02:00
const [mysteries, setMysteries] = useState<MysterySummary[]>([])
2026-08-22 15:12:56 +02:00
const [splash, setSplash] = useState(false)
const [busy, setBusy] = useState(false)
const [status, setStatus] = useState('')
const apply = useCallback((next: PlaythroughState | null) => {
if (!next) { setSplash(true); return }
if (next.node?.kind === 'level' && next.node.levelSlug) { window.location.assign(`/level/${encodeURIComponent(next.node.levelSlug)}`); return }
if (!next.node) { setState(null); setSplash(true); setStatus('CASE CLOSED — begin another'); return }
setState(next); setSplash(false)
}, [])
const ensurePlaythroughId = async (): Promise<string | null> => {
const cur = await fetch('/api/playthroughs/current')
if (cur.ok && cur.status !== 204) return (await cur.json())?.playthrough?.id ?? null
const made = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mystery: 'glass-harbor' }) })
return made.ok ? (await made.json())?.playthrough?.id ?? null : null
}
useEffect(() => {
let cancelled = false
;(async () => {
// /node/:id — dev teleport to a specific story node (level nodes -> the board).
const nodeMatch = window.location.pathname.match(/^\/node\/(.+)$/)
if (nodeMatch) {
const id = await ensurePlaythroughId()
if (cancelled || !id) { if (!cancelled) setStatus('NO PLAYTHROUGH'); return }
const res = await fetch(`/api/playthroughs/${id}/goto`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nodeId: decodeURIComponent(nodeMatch[1]) }) })
if (!cancelled) res.ok ? apply(await res.json()) : setStatus('NODE NOT FOUND')
return
}
2026-08-22 15:51:32 +02:00
// 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')])
2026-08-22 15:12:56 +02:00
if (cancelled) return
2026-08-22 15:51:32 +02:00
if (cases.ok) setMysteries(await cases.json())
if (current.ok && current.status !== 204) setResumable(await current.json())
setSplash(true)
2026-08-22 15:51:32 +02:00
setStatus('SELECT A CASE FILE')
2026-08-22 15:12:56 +02:00
})()
return () => { cancelled = true }
}, [apply])
2026-08-22 15:51:32 +02:00
const newGame = async (slug: string) => {
2026-08-22 15:12:56 +02:00
setBusy(true)
try {
2026-08-22 15:51:32 +02:00
const res = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mystery: slug }) })
2026-08-22 15:12:56 +02:00
if (res.ok) apply(await res.json())
2026-08-22 15:51:32 +02:00
else setStatus('COULD NOT OPEN CASE')
2026-08-22 15:12:56 +02:00
} finally { setBusy(false) }
}
const advance = async (terminalKey?: string) => {
if (!state) return
const res = await fetch(`/api/playthroughs/${state.playthrough.id}/advance`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ terminalKey }),
})
if (res.ok) apply(await res.json())
}
2026-08-22 15:51:32 +02:00
if (splash) return <div className="splash">
<div className="splash-plate">
<div className="seal">GU</div>
<h1 className="splash-title">PRINCIPAL INVESTIGATOR</h1>
<p className="splash-sub">Glitch University · Case Files</p>
<div className="splash-cases">
{mysteries.map(mystery => {
const canResume = resumable?.playthrough.mysterySlug === mystery.slug
return <button key={mystery.slug} className="splash-case" disabled={busy}
onClick={() => (canResume && resumable) ? apply(resumable) : newGame(mystery.slug)}>
<span className="splash-case-title">{mystery.title}</span>
<span className="splash-case-action">{canResume ? 'RESUME ▸' : 'BEGIN ▸'}</span>
</button>
})}
{!mysteries.length && <p className="splash-status">NO CASE FILES AVAILABLE</p>}
</div>
<small className="splash-status">{busy ? 'OPENING CASE FILE…' : status}</small>
</div>
</div>
2026-08-22 15:12:56 +02:00
const node = state?.node
if (!node) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status || 'OPENING CASE FILE…'}</small></div>
if (node.kind === 'cutscene') return <CutsceneHost componentKey={node.componentKey} label={node.label} onComplete={() => { void advance() }} />
if (node.kind === 'merit') return <MeritHost componentKey={node.componentKey} label={node.label} awardsFlag={node.awardsFlag} onComplete={() => { void advance() }} />
if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }}
onExit={terminalKey => { void advance(terminalKey) }}
onAward={utteranceId => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/utterances/${utteranceId}/reach`, { method: 'POST' }) }} />
2026-08-22 15:12:56 +02:00
return <div className="boot"><div className="seal">GU</div><small>{node.label}</small></div>
}