import { useCallback, useEffect, useState } from 'react' import { CutsceneHost, DialoguePlayer, MeritHost, type PlaythroughState } from './narrative' type MysterySummary = { slug: string; title: string } // 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/), which App renders. export function Play() { const [state, setState] = useState(null) const [resumable, setResumable] = useState(null) const [mysteries, setMysteries] = useState([]) 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 => { 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 } // 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')]) if (cancelled) return if (cases.ok) setMysteries(await cases.json()) if (current.ok && current.status !== 204) setResumable(await current.json()) setSplash(true) setStatus('SELECT A CASE FILE') })() return () => { cancelled = true } }, [apply]) const newGame = async (slug: string) => { setBusy(true) try { const res = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mystery: slug }) }) if (res.ok) apply(await res.json()) else setStatus('COULD NOT OPEN CASE') } 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()) } if (splash) return
GU

PRINCIPAL INVESTIGATOR

Glitch University · Case Files

{mysteries.map(mystery => { const canResume = resumable?.playthrough.mysterySlug === mystery.slug return })} {!mysteries.length &&

NO CASE FILES AVAILABLE

}
{busy ? 'OPENING CASE FILE…' : status}
const node = state?.node if (!node) return
GU

GLITCH UNIVERSITY NETWORK TERMINAL

{status || 'OPENING CASE FILE…'}
if (node.kind === 'cutscene') return { void advance() }} /> if (node.kind === 'merit') return { void advance() }} /> if (node.kind === 'dialogue' && node.utterances) return { void advance(terminalKey) }} /> return
GU
{node.label}
}