2026-08-22 15:12:56 +02:00
import { useCallback , useEffect , useState } from 'react'
2026-08-22 15:51:32 +02:00
import { CutsceneHost , DialoguePlayer , type PlaythroughState } from './narrative'
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 )
2026-08-22 15:43:08 +02:00
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 ())
2026-08-22 15:43:08 +02:00
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 === 'dialogue' && node . utterances ) return < DialoguePlayer node = {{ utterances : node.utterances , rootId : node.rootId ?? null }} onExit = { terminalKey => { void advance ( terminalKey ) }} />
return < div className = "boot" >< div className = "seal" > GU </ div >< small >{ node . label }</ small ></ div >
}