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) }
|
} 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,
|
// Narrative authoring: create a mystery and its NPC cast. The flow (cutscenes,
|
||||||
// dialogue, levels) lives in the story graph, seeded separately.
|
// dialogue, levels) lives in the story graph, seeded separately.
|
||||||
app.post('/api/mysteries', requireAdmin, async (req, res, next) => {
|
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 }>
|
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 }>
|
gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
|
||||||
listMysteries(): Promise<MysterySummary[]>
|
listMysteries(): Promise<MysterySummary[]>
|
||||||
|
listPlayableMysteries(): Promise<{ slug: string; title: string }[]>
|
||||||
deleteMystery(id: string): Promise<boolean>
|
deleteMystery(id: string): Promise<boolean>
|
||||||
uploadAsset(file: UploadedFile): Promise<AssetDto>
|
uploadAsset(file: UploadedFile): Promise<AssetDto>
|
||||||
listAssets(): Promise<AssetDto[]>
|
listAssets(): Promise<AssetDto[]>
|
||||||
@@ -304,6 +305,14 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
return { ok: true, state: state ?? undefined }
|
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() {
|
async listMysteries() {
|
||||||
const result = await pool.query<{ id: string; slug: string; title: string; nodes: string }>(
|
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
|
`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 { 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
|
// 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
|
// active playthrough; otherwise walks the story graph — cutscene and dialogue
|
||||||
@@ -8,6 +10,7 @@ import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState } fro
|
|||||||
export function Play() {
|
export function Play() {
|
||||||
const [state, setState] = useState<PlaythroughState | null>(null)
|
const [state, setState] = useState<PlaythroughState | null>(null)
|
||||||
const [resumable, setResumable] = useState<PlaythroughState | null>(null)
|
const [resumable, setResumable] = useState<PlaythroughState | null>(null)
|
||||||
|
const [mysteries, setMysteries] = useState<MysterySummary[]>([])
|
||||||
const [splash, setSplash] = useState(false)
|
const [splash, setSplash] = useState(false)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [status, setStatus] = useState('')
|
const [status, setStatus] = useState('')
|
||||||
@@ -38,23 +41,24 @@ export function Play() {
|
|||||||
if (!cancelled) res.ok ? apply(await res.json()) : setStatus('NODE NOT FOUND')
|
if (!cancelled) res.ok ? apply(await res.json()) : setStatus('NODE NOT FOUND')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The bare root is always the front door: show the splash, and offer Resume
|
// The bare root is always the front door: list the playable cases, and offer
|
||||||
// when a playthrough is already in progress rather than auto-resuming into it.
|
// Resume on the one already in progress rather than auto-resuming into it.
|
||||||
const res = await fetch('/api/playthroughs/current')
|
const [cases, current] = await Promise.all([fetch('/api/mysteries'), fetch('/api/playthroughs/current')])
|
||||||
if (cancelled) return
|
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)
|
setSplash(true)
|
||||||
setStatus('AWAITING PRINCIPAL INVESTIGATOR')
|
setStatus('SELECT A CASE FILE')
|
||||||
})()
|
})()
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [apply])
|
}, [apply])
|
||||||
|
|
||||||
const newGame = async () => {
|
const newGame = async (slug: string) => {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
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())
|
if (res.ok) apply(await res.json())
|
||||||
else setStatus('NO MYSTERY AVAILABLE')
|
else setStatus('COULD NOT OPEN CASE')
|
||||||
} finally { setBusy(false) }
|
} finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +70,25 @@ export function Play() {
|
|||||||
if (res.ok) apply(await res.json())
|
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
|
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) 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 { 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:disabled { color: #5f7b73; border-style: solid; cursor: default; }
|
||||||
.phone-dev-btn:not(:disabled):hover { border-color: #cdea6a; color: #cdea6a; }
|
.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