// Root shell: loads the case from the server (or ?case=ID), then mounts the game.
import { useEffect, useState } from 'preact/hooks'
import { getCase, newCase } from './api'
import { RainFX, prefersReducedMotion } from './engine/pixel'
import { GameProvider, type Screen, useGame, useMode, useTweaks } from './store'
import type { PublicCase } from './types'
import { SCREENS } from './screens'
import { TitleScreen } from './screens/cold'
import { Assistant } from './ui/assistant'
import { unlockAudioOnce } from './ui/audio'
import { Btn } from './ui/components'
const RAIN_SCREENS = new Set(['title', 'interro', 'briefing', 'verdict', 'flashback', 'story'])
function ScreenHost() {
const g = useGame()
const Screen = SCREENS[g.state.screen] || TitleScreen
const t = g.state.tweaks
const showRain = t.rain && t.fx !== 'low' && !prefersReducedMotion() && RAIN_SCREENS.has(g.state.screen)
return (
)
}
function Loading() {
const showRain = !prefersReducedMotion()
return (
{showRain && (
)}
CASE ZERO
FORMING A CASE
The city, the body, the lies — coming together from the night wire.
)
}
function ErrorView({ msg, retry }: { msg: string; retry: () => void }) {
return (
THE WIRE WENT DEAD
{msg}
Try again
)
}
export function Root() {
const [data, setData] = useState<{ case: PublicCase; runId: string } | null>(null)
const [error, setError] = useState(null)
const [startScreen, setStartScreen] = useState('title')
const mode = useMode('auto') // always responsive to the real viewport
const [tweaks, setTweak] = useTweaks()
useEffect(() => {
const r = document.documentElement
r.setAttribute('data-palette', tweaks.palette)
r.setAttribute('data-fonts', tweaks.fonts)
r.setAttribute('data-fx', tweaks.fx)
r.setAttribute('data-mood', tweaks.mood)
window.__pxScale = tweaks.pixelScale
}, [tweaks])
useEffect(unlockAudioOnce, []) // grant audio playback on first tap (mobile autoplay policy)
const load = () => {
setError(null)
setData(null)
setStartScreen('title')
const params = new URLSearchParams(window.location.search)
const cid = params.get('case')
const req = cid ? getCase(cid) : newCase()
req.then((r) => setData({ case: r.case, runId: r.runId })).catch((e) => setError(e instanceof Error ? e.message : String(e)))
}
useEffect(load, [])
// "Begin New Case" - always fetch a FRESH case from the server (never replay the loaded one)
// and start playing it. A shared ?case= is cleared so a refresh won't snap back to it.
const beginNewCase = () => {
setError(null)
setData(null)
setStartScreen('story')
const u = new URL(window.location.href)
if (u.searchParams.has('case')) {
u.searchParams.delete('case')
window.history.replaceState({}, '', u.pathname + u.search)
}
newCase()
.then((r) => setData({ case: r.case, runId: r.runId }))
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
}
// "Enter Case ID" - load that exact case (fresh run) and jump straight into playing it.
const loadCaseById = (id: string) => {
const cid = id.trim()
if (!cid) return
setError(null)
setData(null)
setStartScreen('story')
const u = new URL(window.location.href)
u.searchParams.set('case', cid)
window.history.replaceState({}, '', u.pathname + u.search)
getCase(cid)
.then((r) => setData({ case: r.case, runId: r.runId }))
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
}
if (error) {
return (
)
}
if (!data) {
return (
)
}
return (
)
}