Adding better routing
This commit is contained in:
@@ -423,6 +423,15 @@ app.post('/api/playthroughs/:id/achievements', async (req, res, next) => {
|
|||||||
result.ok ? res.json({ earned: result.earned }) : res.status(result.error === 'Playthrough not found' ? 404 : 400).json({ error: result.error })
|
result.ok ? res.json({ earned: result.earned }) : res.status(result.error === 'Playthrough not found' ? 404 : 400).json({ error: result.error })
|
||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
|
// Dev teleport: jump the playthrough to an explicit story node. Powers /node/:id.
|
||||||
|
app.post('/api/playthroughs/:id/goto', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (process.env.NODE_ENV === 'production') return res.status(403).json({ error: 'Node teleport is disabled' })
|
||||||
|
if (!req.body?.nodeId) return res.status(400).json({ error: 'A nodeId is required' })
|
||||||
|
const result = await narrative.gotoNode(resolveUserId(req), String(req.params.id), String(req.body.nodeId))
|
||||||
|
result.ok ? res.json(result.state ?? null) : res.status(result.error === 'Playthrough not found' || result.error === 'Node not found' ? 404 : 400).json({ error: result.error })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
|
||||||
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
if (error instanceof multer.MulterError) {
|
if (error instanceof multer.MulterError) {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export interface NarrativeRepository {
|
|||||||
advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
|
advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
|
||||||
listAchievements(playthroughId: string): Promise<string[] | null>
|
listAchievements(playthroughId: string): Promise<string[] | null>
|
||||||
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 }>
|
||||||
listMysteries(): Promise<MysterySummary[]>
|
listMysteries(): Promise<MysterySummary[]>
|
||||||
deleteMystery(id: string): Promise<boolean>
|
deleteMystery(id: string): Promise<boolean>
|
||||||
uploadAsset(file: UploadedFile): Promise<AssetDto>
|
uploadAsset(file: UploadedFile): Promise<AssetDto>
|
||||||
@@ -251,6 +252,28 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
return { ok: true, earned: (result.rowCount || 0) > 0 }
|
return { ok: true, earned: (result.rowCount || 0) > 0 }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Dev teleport: jump the playthrough straight to an explicit node (no gate
|
||||||
|
// resolution). Instantiates a fresh level clone for level nodes. Powers /node/:id.
|
||||||
|
async gotoNode(userId, playthroughId, nodeId) {
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN')
|
||||||
|
const playthrough = (await client.query<{ mystery_id: string; mystery_slug: string }>(
|
||||||
|
`SELECT p.mystery_id, m.slug AS mystery_slug FROM osint.playthroughs p JOIN osint.mysteries m ON m.id=p.mystery_id
|
||||||
|
WHERE p.id=$1 AND p.user_id=$2 FOR UPDATE OF p`, [playthroughId, userId])).rows[0]
|
||||||
|
if (!playthrough) { await client.query('ROLLBACK'); return { ok: false, error: 'Playthrough not found' } }
|
||||||
|
const node = (await client.query<{ id: string; node_type: string; level_template_version_id: string | null }>(
|
||||||
|
'SELECT id,node_type,level_template_version_id FROM osint.story_nodes WHERE id=$1 AND mystery_id=$2', [nodeId, playthrough.mystery_id])).rows[0]
|
||||||
|
if (!node) { await client.query('ROLLBACK'); return { ok: false, error: 'Node not found' } }
|
||||||
|
const levelId = node.node_type === 'level' && node.level_template_version_id
|
||||||
|
? await instantiateLevel(client, node.level_template_version_id, playthrough.mystery_slug) : null
|
||||||
|
await client.query(`UPDATE osint.playthroughs SET status='active',current_node_id=$2,current_level_id=$3,updated_at=NOW() WHERE id=$1`, [playthroughId, node.id, levelId])
|
||||||
|
await client.query('COMMIT')
|
||||||
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
|
const state = await stateForPlaythrough(playthroughId)
|
||||||
|
return { ok: true, state: state ?? undefined }
|
||||||
|
},
|
||||||
|
|
||||||
async advancePlaythrough(userId, playthroughId, terminalKey) {
|
async advancePlaythrough(userId, playthroughId, terminalKey) {
|
||||||
const client = await pool.connect()
|
const client = await pool.connect()
|
||||||
try {
|
try {
|
||||||
|
|||||||
+2
-1
@@ -96,7 +96,8 @@ export function App() {
|
|||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => setIsAdmin(Boolean(session?.isAdmin))).catch(() => setIsAdmin(false))
|
fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => setIsAdmin(Boolean(session?.isAdmin))).catch(() => setIsAdmin(false))
|
||||||
|
|
||||||
const deepLinkLevel = params.get('level')
|
const pathLevel = window.location.pathname.match(/^\/level\/(.+)$/)
|
||||||
|
const deepLinkLevel = params.get('level') || (pathLevel ? decodeURIComponent(pathLevel[1]) : null)
|
||||||
const editQuery = params.get('edit') === '1' ? '?edit=1' : ''
|
const editQuery = params.get('edit') === '1' ? '?edit=1' : ''
|
||||||
const openLevel = async (slug: string) => {
|
const openLevel = async (slug: string) => {
|
||||||
const data = await loadLevelBySlug(slug, editQuery)
|
const data = await loadLevelBySlug(slug, editQuery)
|
||||||
|
|||||||
+8
-3
@@ -1,13 +1,18 @@
|
|||||||
import { StrictMode, Suspense, lazy } from 'react'
|
import { StrictMode, Suspense, lazy } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { App } from './App'
|
import { App } from './App'
|
||||||
|
import { Play } from './play'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
// three.js is lazy-loaded so the board never pays for it up front.
|
// three.js is lazy-loaded so the board never pays for it up front.
|
||||||
const PhonePreview = lazy(() => import('./phone').then(m => ({ default: m.PhonePreview })))
|
const PhonePreview = lazy(() => import('./phone').then(m => ({ default: m.PhonePreview })))
|
||||||
|
|
||||||
// Visual spike: /?phone=1 renders the handset standalone, isolated from the board.
|
// Routing: the bare root is the game's front door (splash + campaign); /level/:id,
|
||||||
const root = new URLSearchParams(window.location.search).has('phone')
|
// /admin, and the legacy ?level= deep link open the board; ?phone=1 is the spike.
|
||||||
|
const path = window.location.pathname
|
||||||
|
const search = new URLSearchParams(window.location.search)
|
||||||
|
const isBoard = path.startsWith('/level/') || path === '/admin' || search.has('level')
|
||||||
|
const root = search.has('phone')
|
||||||
? <Suspense fallback={null}><PhonePreview /></Suspense>
|
? <Suspense fallback={null}><PhonePreview /></Suspense>
|
||||||
: <App />
|
: isBoard ? <App /> : <Play />
|
||||||
createRoot(document.getElementById('root')!).render(<StrictMode>{root}</StrictMode>)
|
createRoot(document.getElementById('root')!).render(<StrictMode>{root}</StrictMode>)
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState } from './narrative'
|
||||||
|
|
||||||
|
// 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 [splash, setSplash] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [status, setStatus] = useState('')
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
const res = await fetch('/api/playthroughs/current')
|
||||||
|
if (cancelled) return
|
||||||
|
if (res.status === 204) { setSplash(true); setStatus('AWAITING PRINCIPAL INVESTIGATOR'); return }
|
||||||
|
res.ok ? apply(await res.json()) : setSplash(true)
|
||||||
|
})()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [apply])
|
||||||
|
|
||||||
|
const newGame = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mystery: 'glass-harbor' }) })
|
||||||
|
if (res.ok) apply(await res.json())
|
||||||
|
else setStatus('NO MYSTERY AVAILABLE')
|
||||||
|
} 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (splash) return <SplashScreen hasResume={false} busy={busy} status={status} onNewGame={newGame} onResume={() => {}} />
|
||||||
|
|
||||||
|
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 === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }} onExit={terminalKey => { void advance(terminalKey) }} />
|
||||||
|
return <div className="boot"><div className="seal">GU</div><small>{node.label}</small></div>
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user