import { useCallback, useEffect, useState } from 'react' import { CutsceneHost, DialoguePlayer, MeritHost, type PlaythroughState } from './narrative' type MysterySummary = { slug: string; title: string } type Player = { id: string; handle: string; displayName: string; avatarUrl: string | null } // 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 [player, setPlayer] = useState(null) const [authChecked, setAuthChecked] = useState(false) const openFrontDoor = useCallback(async () => { const [cases, current] = await Promise.all([fetch('/api/mysteries'), fetch('/api/playthroughs/current')]) if (cases.ok) setMysteries(await cases.json()) if (current.ok && current.status !== 204) setResumable(await current.json()) setSplash(true); setStatus('SELECT A CASE FILE') }, []) 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 } // ?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 the front door — but you must be signed in first. const me = await fetch('/api/auth/me') if (cancelled) return setAuthChecked(true) if (me.status !== 200) return // not signed in -> the enrolment / sign-in screen setPlayer((await me.json()).user) await openFrontDoor() })() return () => { cancelled = true } }, [apply, openFrontDoor]) const onAuthed = (user: Player) => { setPlayer(user); void openFrontDoor() } const signOut = async () => { await fetch('/api/auth/logout', { method: 'POST' }); window.location.assign('/') } 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()) } // Front door requires a signed-in investigator (path A: GUPI-issued account). if (!splash && !state && authChecked && !player) return if (splash) return
GU

PRINCIPAL INVESTIGATOR

{player &&

{player.displayName.slice(0, 1).toUpperCase()}{player.displayName}

}

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) }} onAward={utteranceId => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/utterances/${utteranceId}/reach`, { method: 'POST' }) }} onCapture={(text, utteranceId) => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/notebook`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, utteranceId }) }) }} /> return
GU
{node.label}
} // Rudimentary character screen: enrol (display name + handle + password) or sign in. // The avatar is a placeholder (initial) for now; extend later. function AuthScreen({ onAuthed }: { onAuthed: (user: Player) => void }) { const [mode, setMode] = useState<'register' | 'login'>('register') const [handle, setHandle] = useState('') const [password, setPassword] = useState('') const [displayName, setDisplayName] = useState('') const [busy, setBusy] = useState(false) const [error, setError] = useState('') const submit = async () => { setBusy(true); setError('') try { const url = mode === 'register' ? '/api/auth/register' : '/api/auth/login' const body = mode === 'register' ? { handle, password, displayName } : { handle, password } const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }) const data = await res.json().catch(() => ({})) if (!res.ok) { setError(data.error || 'Something went wrong'); return } onAuthed(data.user) } finally { setBusy(false) } } return
GU

PRINCIPAL INVESTIGATOR

Glitch University · {mode === 'register' ? 'Enrolment' : 'Sign in'}

{mode === 'register' &&
{(displayName.trim() || '?').slice(0, 1).toUpperCase()}
}
{mode === 'register' && }
{error &&

{error}

}
}