diff --git a/src/play.tsx b/src/play.tsx index 0699c5c..6a4c56d 100644 --- a/src/play.tsx +++ b/src/play.tsx @@ -2,6 +2,7 @@ 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 @@ -14,6 +15,15 @@ export function Play() { 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 } @@ -48,17 +58,19 @@ export function Play() { if (cancelled) return if (res.ok && res.status !== 204) { apply(await res.json()); 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')]) + // The bare root is the front door — but you must be signed in first. + const me = await fetch('/api/auth/me') 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') + 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]) + }, [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) @@ -77,10 +89,14 @@ export function Play() { 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 => { @@ -107,3 +123,46 @@ export function Play() { 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}

} + +
+
+} diff --git a/src/styles.css b/src/styles.css index 916be54..1ea7fa1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -808,3 +808,21 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .dialogue-capture:hover { border-color: #cdea6a; color: #eafaa0; } /* A note torn to the board reads as handwriting too. */ .evidence-card.note .card-content p { font-family: "Reenie Beanie", cursive; font-size: 19px; line-height: 1.05; color: #26356b; } + +/* === Player auth + character screen (src/play.tsx) ================= */ +.auth-plate { gap: 4px; } +.auth-tabs { display: flex; gap: 8px; margin: 22px 0 6px; } +.auth-tabs button { background: #0e2a24; border: 1px solid #3c5a52; color: #9bb0a9; font: 10px IBM Plex Mono, monospace; letter-spacing: .12em; padding: 7px 14px; cursor: pointer; } +.auth-tabs button.on { border-color: #d58a46; color: #f6ead9; } +.auth-avatar { width: 64px; height: 64px; margin: 12px 0 6px; display: grid; place-items: center; border: 2px dashed #6f8f85; border-radius: 50%; color: #cfe8df; font: 700 26px Special Elite, serif; background: #0a211d; } +.auth-fields { display: flex; flex-direction: column; gap: 12px; width: min(320px, 84vw); margin: 8px 0 4px; } +.auth-fields label { display: flex; flex-direction: column; gap: 5px; font: 9px IBM Plex Mono, monospace; letter-spacing: .18em; color: #7f9a92; text-transform: uppercase; } +.auth-fields input { background: #08201b; border: 1px solid #3c5a52; color: #e4e9e4; font: 14px IBM Plex Mono, monospace; padding: 9px 11px; outline: none; } +.auth-fields input:focus { border-color: #6f8f85; } +.auth-error { margin: 4px 0 0; color: #e88a4a; font: 11px IBM Plex Mono, monospace; } +.auth-plate .splash-button { margin-top: 14px; } +/* investigator identity on the case-file splash */ +.splash-investigator { display: flex; align-items: center; gap: 10px; margin: 10px 0 0; color: #cfe8df; font: 13px IBM Plex Mono, monospace; letter-spacing: .1em; } +.splash-avatar { width: 26px; height: 26px; display: grid; place-items: center; border: 1px solid #6f8f85; border-radius: 50%; font: 700 13px Special Elite, serif; color: #e7b57e; } +.splash-signout { margin-left: 6px; background: none; border: none; color: #6f8f85; font: 10px IBM Plex Mono, monospace; letter-spacing: .1em; cursor: pointer; text-decoration: underline; } +.splash-signout:hover { color: #d58a46; }