diff --git a/server/index.ts b/server/index.ts index 8b82ad5..e7d0814 100644 --- a/server/index.ts +++ b/server/index.ts @@ -373,6 +373,12 @@ app.delete('/api/admin/utterances/:id', requireAdmin, async (req, res, next) => } catch (error) { next(error) } }) +// Play mode: the launchable case list for the splash picker (entrypoint required). +app.get('/api/mysteries', async (_req, res, next) => { + try { res.json(await narrative.listPlayableMysteries()) } + catch (error) { next(error) } +}) + // Narrative authoring: create a mystery and its NPC cast. The flow (cutscenes, // dialogue, levels) lives in the story graph, seeded separately. app.post('/api/mysteries', requireAdmin, async (req, res, next) => { diff --git a/server/narrativeRepository.ts b/server/narrativeRepository.ts index 6d278cb..16c13e6 100644 --- a/server/narrativeRepository.ts +++ b/server/narrativeRepository.ts @@ -51,6 +51,7 @@ export interface NarrativeRepository { awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }> gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }> listMysteries(): Promise + listPlayableMysteries(): Promise<{ slug: string; title: string }[]> deleteMystery(id: string): Promise uploadAsset(file: UploadedFile): Promise listAssets(): Promise @@ -304,6 +305,14 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora return { ok: true, state: state ?? undefined } }, + // Play mode: only mysteries with an entrypoint are launchable (this filters out + // half-authored, empty ones). Returns the minimum the case picker needs. + async listPlayableMysteries() { + const result = await pool.query<{ slug: string; title: string }>( + 'SELECT slug,title FROM osint.mysteries WHERE entry_node_id IS NOT NULL ORDER BY title') + return result.rows.map(row => ({ slug: row.slug, title: row.title })) + }, + async listMysteries() { const result = await pool.query<{ id: string; slug: string; title: string; nodes: string }>( `SELECT m.id,m.slug,m.title,COUNT(n.id)::text AS nodes diff --git a/src/play.tsx b/src/play.tsx index ac628a5..bbede76 100644 --- a/src/play.tsx +++ b/src/play.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useState } from 'react' -import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState } from './narrative' +import { CutsceneHost, DialoguePlayer, type PlaythroughState } from './narrative' + +type MysterySummary = { slug: string; title: string } // 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 @@ -8,6 +10,7 @@ import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState } fro 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('') @@ -38,23 +41,24 @@ export function Play() { if (!cancelled) res.ok ? apply(await res.json()) : setStatus('NODE NOT FOUND') return } - // The bare root is always the front door: show the splash, and offer Resume - // when a playthrough is already in progress rather than auto-resuming into it. - const res = await fetch('/api/playthroughs/current') + // 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')]) if (cancelled) return - if (res.ok && res.status !== 204) setResumable(await res.json()) + if (cases.ok) setMysteries(await cases.json()) + if (current.ok && current.status !== 204) setResumable(await current.json()) setSplash(true) - setStatus('AWAITING PRINCIPAL INVESTIGATOR') + setStatus('SELECT A CASE FILE') })() return () => { cancelled = true } }, [apply]) - const newGame = async () => { + 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: 'glass-harbor' }) }) + 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('NO MYSTERY AVAILABLE') + else setStatus('COULD NOT OPEN CASE') } finally { setBusy(false) } } @@ -66,7 +70,25 @@ export function Play() { if (res.ok) apply(await res.json()) } - if (splash) return { if (resumable) apply(resumable) }} /> + if (splash) return
+
+
GU
+

PRINCIPAL INVESTIGATOR

+

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…'}
diff --git a/src/styles.css b/src/styles.css index bdc46d1..d5e8fb9 100644 --- a/src/styles.css +++ b/src/styles.css @@ -648,3 +648,12 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; } .phone-dev-btn { border: 1px dashed #6f8f85; background: #0a1a16cc; color: #9fd020; font-family: ui-monospace, monospace; font-size: 11px; letter-spacing: 1px; padding: 5px 12px; cursor: pointer; } .phone-dev-btn:disabled { color: #5f7b73; border-style: solid; cursor: default; } .phone-dev-btn:not(:disabled):hover { border-color: #cdea6a; color: #cdea6a; } + +/* Splash case picker */ +.splash-cases { display: flex; flex-direction: column; gap: 10px; width: 100%; margin: 20px 0 8px; } +.splash-case { display: flex; align-items: center; justify-content: space-between; gap: 14px; width: 100%; padding: 12px 16px; + border: 1px solid #3c5a52; background: #0a211d; color: #cfe8df; cursor: pointer; text-align: left; font: inherit; } +.splash-case:hover:not(:disabled) { border-color: #6f8f85; background: #0d2a24; } +.splash-case:disabled { opacity: .5; cursor: default; } +.splash-case-title { font-weight: 600; letter-spacing: .5px; } +.splash-case-action { color: #d58a46; font-size: 12px; letter-spacing: 1px; white-space: nowrap; }