2026-08-22 15:12:56 +02:00
import { useCallback , useEffect , useState } from 'react'
2026-08-22 16:11:53 +02:00
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 21:45:38 +02:00
type Player = { id : string ; handle : string ; displayName : string ; avatarUrl : string | null }
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 ( '' )
2026-08-22 21:45:38 +02:00
const [ player , setPlayer ] = useState < Player | null >( 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' )
}, [])
2026-08-22 15:12:56 +02:00
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 19:49:00 +02:00
// ?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 }
}
2026-08-22 21:45:38 +02:00
// The bare root is the front door — but you must be signed in first.
const me = await fetch ( '/api/auth/me' )
2026-08-22 15:12:56 +02:00
if ( cancelled ) return
2026-08-22 21:45:38 +02:00
setAuthChecked ( true )
if ( me . status !== 200 ) return // not signed in -> the enrolment / sign-in screen
setPlayer (( await me . json ()). user )
await openFrontDoor ()
2026-08-22 15:12:56 +02:00
})()
return () => { cancelled = true }
2026-08-22 21:45:38 +02:00
}, [ apply , openFrontDoor ])
const onAuthed = ( user : Player ) => { setPlayer ( user ); void openFrontDoor () }
const signOut = async () => { await fetch ( '/api/auth/logout' , { method : 'POST' }); window . location . assign ( '/' ) }
2026-08-22 15:12:56 +02:00
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 21:45:38 +02:00
// Front door requires a signed-in investigator (path A: GUPI-issued account).
if ( ! splash && ! state && authChecked && ! player ) return < AuthScreen onAuthed = { onAuthed } />
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 >
2026-08-22 21:45:38 +02:00
{ player && < p className = "splash-investigator" >< span className = "splash-avatar" title = "Avatar coming soon" >{ player . displayName . slice ( 0 , 1 ). toUpperCase ()}</ span >{ player . displayName }< button className = "splash-signout" onClick = { signOut }> sign out </ button ></ p >}
2026-08-22 15:51:32 +02:00
< 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 () }} />
2026-08-22 16:11:53 +02:00
if ( node . kind === 'merit' ) return < MeritHost componentKey = { node . componentKey } label = { node . label } awardsFlag = { node . awardsFlag } onComplete = {() => { void advance () }} />
2026-08-22 18:34:52 +02:00
if ( node . kind === 'dialogue' && node . utterances ) return < DialoguePlayer node = {{ utterances : node.utterances , rootId : node.rootId ?? null }}
onExit = { terminalKey => { void advance ( terminalKey ) }}
2026-08-22 20:11:15 +02:00
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 }) }) }} />
2026-08-22 15:12:56 +02:00
return < div className = "boot" >< div className = "seal" > GU </ div >< small >{ node . label }</ small ></ div >
}
2026-08-22 21:45:38 +02:00
// 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 < div className = "splash" >
< div className = "splash-plate auth-plate" >
< div className = "seal" > GU </ div >
< h1 className = "splash-title" > PRINCIPAL INVESTIGATOR </ h1 >
< p className = "splash-sub" > Glitch University · { mode === 'register' ? 'Enrolment' : 'Sign in' }</ p >
< div className = "auth-tabs" >
< button className = { mode === 'register' ? 'on' : '' } onClick = {() => setMode ( 'register' )}> New investigator </ button >
< button className = { mode === 'login' ? 'on' : '' } onClick = {() => setMode ( 'login' )}> Sign in </ button >
</ div >
{ mode === 'register' && < div className = "auth-avatar" title = "Avatar coming soon" >{( displayName . trim () || '?' ). slice ( 0 , 1 ). toUpperCase ()}</ div >}
< div className = "auth-fields" >
{ mode === 'register' && < label > Display name < input value = { displayName } onChange = { event => setDisplayName ( event . target . value )} placeholder = "Investigator name" onKeyDown = { e => e . key === 'Enter' && submit ()} /></ label >}
< label > Handle < input value = { handle } onChange = { event => setHandle ( event . target . value . toLowerCase ())} placeholder = "handle" autoCapitalize = "off" autoCorrect = "off" spellCheck = { false } onKeyDown = { e => e . key === 'Enter' && submit ()} /></ label >
< label > Password < input type = "password" value = { password } onChange = { event => setPassword ( event . target . value )} onKeyDown = { e => e . key === 'Enter' && submit ()} /></ label >
</ div >
{ error && < p className = "auth-error" >{ error }</ p >}
< button className = "splash-button primary" disabled = { busy } onClick = { submit }>{ busy ? 'PLEASE WAIT…' : mode === 'register' ? 'ENROL ▸' : 'SIGN IN ▸' }</ button >
</ div >
</ div >
}