diff --git a/server/index.ts b/server/index.ts index b326d4d..8b82ad5 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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 }) } 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) => { if (error instanceof multer.MulterError) { diff --git a/server/narrativeRepository.ts b/server/narrativeRepository.ts index b8d8bdc..6d278cb 100644 --- a/server/narrativeRepository.ts +++ b/server/narrativeRepository.ts @@ -49,6 +49,7 @@ export interface NarrativeRepository { advancePlaythrough(userId: string, playthroughId: string, terminalKey?: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }> listAchievements(playthroughId: string): Promise 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 deleteMystery(id: string): Promise uploadAsset(file: UploadedFile): Promise @@ -251,6 +252,28 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora 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) { const client = await pool.connect() try { diff --git a/src/App.tsx b/src/App.tsx index eb3f1bb..84e65ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -96,7 +96,8 @@ export function App() { 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)) - 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 openLevel = async (slug: string) => { const data = await loadLevelBySlug(slug, editQuery) diff --git a/src/main.tsx b/src/main.tsx index c37ecec..6644b9d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,13 +1,18 @@ import { StrictMode, Suspense, lazy } from 'react' import { createRoot } from 'react-dom/client' import { App } from './App' +import { Play } from './play' import './styles.css' // three.js is lazy-loaded so the board never pays for it up front. const PhonePreview = lazy(() => import('./phone').then(m => ({ default: m.PhonePreview }))) -// Visual spike: /?phone=1 renders the handset standalone, isolated from the board. -const root = new URLSearchParams(window.location.search).has('phone') +// Routing: the bare root is the game's front door (splash + campaign); /level/:id, +// /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') ? - : + : isBoard ? : createRoot(document.getElementById('root')!).render({root}) diff --git a/src/play.tsx b/src/play.tsx new file mode 100644 index 0000000..f824354 --- /dev/null +++ b/src/play.tsx @@ -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/), which App renders. +export function Play() { + const [state, setState] = useState(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 => { + 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 {}} /> + + const node = state?.node + if (!node) return
GU

GLITCH UNIVERSITY NETWORK TERMINAL

{status || 'OPENING CASE FILE…'}
+ if (node.kind === 'cutscene') return { void advance() }} /> + if (node.kind === 'dialogue' && node.utterances) return { void advance(terminalKey) }} /> + return
GU
{node.label}
+}