// Game store: reducer, context, responsive mode, and localStorage-backed tweaks. // Mirrors the prototype's app.jsx store, but the case comes from the server and // suspicion/verdict are server-authoritative (the client only displays them). import { createContext } from 'preact' import type { ComponentChildren } from 'preact' import { useCallback, useContext, useEffect, useReducer, useState } from 'preact/hooks' import type { PublicCase } from './types' export type Screen = | 'title' | 'story' | 'briefing' | 'board' | 'interro' | 'evidence' | 'flashback' | 'timeline' | 'notes' | 'accuse' | 'verdict' | 'share' | 'boot' export interface Line { role: 'det' | 'sus' text: string ev?: string } export interface AccuseState { suspect: string | null motive: string | null evidence: string[] } export interface GameState { screen: Screen payload: Record suspicion: Record interrogations: Record usedQ: Record usedEv: Record pinned: string[] accuse: AccuseState startedAt: number } type Action = | { type: 'NAV'; screen: Screen; payload?: Record } | { type: 'SUSP_SET'; sid: string; value: number } | { type: 'ADD_LINE'; sid: string; line: Line } | { type: 'USEQ'; sid: string; qid: string } | { type: 'USEEV'; sid: string; ev: string } | { type: 'PIN'; ev: string } | { type: 'ACCUSE'; field: keyof AccuseState; value: unknown } function reducer(s: GameState, a: Action): GameState { switch (a.type) { case 'NAV': return { ...s, screen: a.screen, payload: a.payload || {} } case 'SUSP_SET': return { ...s, suspicion: { ...s.suspicion, [a.sid]: Math.max(0, Math.min(100, Math.round(a.value))) } } case 'ADD_LINE': return { ...s, interrogations: { ...s.interrogations, [a.sid]: [...(s.interrogations[a.sid] || []), a.line] } } case 'USEQ': return { ...s, usedQ: { ...s.usedQ, [a.sid]: [...(s.usedQ[a.sid] || []), a.qid] } } case 'USEEV': return { ...s, usedEv: { ...s.usedEv, [a.sid]: [...(s.usedEv[a.sid] || []), a.ev] } } case 'PIN': return { ...s, pinned: s.pinned.includes(a.ev) ? s.pinned : [...s.pinned, a.ev] } case 'ACCUSE': return { ...s, accuse: { ...s.accuse, [a.field]: a.value } } default: return s } } function initialState(c: PublicCase, screen: Screen = 'title'): GameState { const suspicion: Record = {} c.suspects.forEach((s) => { suspicion[s.id] = s.baselineSuspicion }) return { screen, payload: {}, suspicion, interrogations: {}, usedQ: {}, usedEv: {}, pinned: [], accuse: { suspect: null, motive: null, evidence: [] }, startedAt: Date.now(), } } // ---- tweaks ---- export interface Tweaks { palette: 'sodium' | 'harbor' | 'violet' fonts: 'crisp' | 'terminal' | 'stamp' fx: 'low' | 'med' | 'high' mood: 'night' | 'day' pixelScale: number typeSpeed: number rain: boolean } export const TWEAK_DEFAULTS: Tweaks = { palette: 'sodium', fonts: 'crisp', fx: 'med', mood: 'night', pixelScale: 1, typeSpeed: 18, rain: true, } const TWEAK_KEY = 'cz-tweaks' export function useTweaks(): [Tweaks, (k: K, v: Tweaks[K]) => void] { const [t, setT] = useState(() => { try { const raw = localStorage.getItem(TWEAK_KEY) return raw ? { ...TWEAK_DEFAULTS, ...JSON.parse(raw) } : TWEAK_DEFAULTS } catch { return TWEAK_DEFAULTS } }) const setTweak = useCallback((k: K, v: Tweaks[K]) => { setT((prev) => { const next = { ...prev, [k]: v } try { localStorage.setItem(TWEAK_KEY, JSON.stringify(next)) } catch { /* ignore */ } return next }) }, []) return [t, setTweak] } // ---- responsive mode ---- export type Device = 'auto' | 'desktop' | 'mobile' export function useMode(device: Device): 'desktop' | 'mobile' { const [w, setW] = useState(typeof window !== 'undefined' ? window.innerWidth : 1200) useEffect(() => { const f = () => setW(window.innerWidth) window.addEventListener('resize', f) return () => window.removeEventListener('resize', f) }, []) if (device === 'desktop') return 'desktop' if (device === 'mobile') return 'mobile' return w < 820 ? 'mobile' : 'desktop' } // ---- context ---- export interface Game { state: GameState & { tweaks: Tweaks } dispatch: (a: Action) => void nav: (screen: Screen, payload?: Record) => void mode: 'desktop' | 'mobile' runStats: () => [string, string][] case: PublicCase runId: string setTweak: (k: K, v: Tweaks[K]) => void newCase: () => void // fetch a fresh case from the server and start playing it loadCase: (id: string) => void // load a specific case by ID and jump straight into it } const GameCtx = createContext(null) export const useGame = (): Game => useContext(GameCtx)! interface ProviderProps { case: PublicCase runId: string mode: 'desktop' | 'mobile' tweaks: Tweaks setTweak: (k: K, v: Tweaks[K]) => void initialScreen?: Screen newCase: () => void loadCase: (id: string) => void children: ComponentChildren } export function GameProvider({ case: c, runId, mode, tweaks, setTweak, initialScreen = 'title', newCase, loadCase, children }: ProviderProps) { const [state, dispatch] = useReducer(reducer, c, (cc) => initialState(cc, initialScreen)) const nav = useCallback((screen: Screen, payload?: Record) => { dispatch({ type: 'NAV', screen, payload }) const stage = document.querySelector('.app__view') if (stage) stage.scrollTop = 0 }, []) const runStats = useCallback((): [string, string][] => { const ms = Date.now() - state.startedAt const mm = String(Math.floor(ms / 60000)).padStart(2, '0') const ss = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0') const grilled = Object.values(state.interrogations).filter((x) => x.length > 1).length return [ ['TIME', `${mm}:${ss}`], ['GRILLED', `${grilled}/${c.suspects.length}`], ['EXHIBITS', `${state.pinned.length}/${c.evidence.length}`], ] }, [state, c]) const game: Game = { state: { ...state, tweaks }, dispatch, nav, mode, runStats, case: c, runId, setTweak, newCase, loadCase, } window.__game = game return {children} }