The splash now requires a signed-in investigator: a rudimentary character screen (display name + handle + password, with a placeholder avatar) enrols or signs in, then reveals the case picker. GET /api/auth/me gates the front door; the signed-in investigator's name/avatar and a sign-out show on the splash. /node and ?resume paths are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
169 lines
9.8 KiB
TypeScript
169 lines
9.8 KiB
TypeScript
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/<clone id>), which App renders.
|
|
export function Play() {
|
|
const [state, setState] = useState<PlaythroughState | null>(null)
|
|
const [resumable, setResumable] = useState<PlaythroughState | null>(null)
|
|
const [mysteries, setMysteries] = useState<MysterySummary[]>([])
|
|
const [splash, setSplash] = useState(false)
|
|
const [busy, setBusy] = useState(false)
|
|
const [status, setStatus] = useState('')
|
|
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')
|
|
}, [])
|
|
|
|
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
|
|
}
|
|
// ?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 <AuthScreen onAuthed={onAuthed} />
|
|
|
|
if (splash) return <div className="splash">
|
|
<div className="splash-plate">
|
|
<div className="seal">GU</div>
|
|
<h1 className="splash-title">PRINCIPAL INVESTIGATOR</h1>
|
|
{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>}
|
|
<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>
|
|
|
|
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' }) }}
|
|
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 <div className="boot"><div className="seal">GU</div><small>{node.label}</small></div>
|
|
}
|
|
|
|
// 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>
|
|
}
|