Splash case picker + playable-mystery list
GET /api/mysteries returns only mysteries with an entrypoint (empties are filtered out), and the splash now lists them as case files with a per-case BEGIN / RESUME action instead of a single hardcoded New Game. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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<MysterySummary[]>
|
||||
listPlayableMysteries(): Promise<{ slug: string; title: string }[]>
|
||||
deleteMystery(id: string): Promise<boolean>
|
||||
uploadAsset(file: UploadedFile): Promise<AssetDto>
|
||||
listAssets(): Promise<AssetDto[]>
|
||||
@@ -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
|
||||
|
||||
+32
-10
@@ -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<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('')
|
||||
@@ -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 <SplashScreen hasResume={!!resumable} busy={busy} status={status} onNewGame={newGame} onResume={() => { if (resumable) apply(resumable) }} />
|
||||
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>
|
||||
|
||||
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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user