case0 / web /src /store.tsx
HusseinEid's picture
Case Zero - initial public release (fully local: Qwen2.5-1.5B via llama.cpp + Supertonic, custom pixel-noir SPA via gradio.Server)
414dc55
// 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<string, unknown>
suspicion: Record<string, number>
interrogations: Record<string, Line[]>
usedQ: Record<string, string[]>
usedEv: Record<string, string[]>
pinned: string[]
accuse: AccuseState
startedAt: number
}
type Action =
| { type: 'NAV'; screen: Screen; payload?: Record<string, unknown> }
| { 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<string, number> = {}
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 extends keyof Tweaks>(k: K, v: Tweaks[K]) => void] {
const [t, setT] = useState<Tweaks>(() => {
try {
const raw = localStorage.getItem(TWEAK_KEY)
return raw ? { ...TWEAK_DEFAULTS, ...JSON.parse(raw) } : TWEAK_DEFAULTS
} catch {
return TWEAK_DEFAULTS
}
})
const setTweak = useCallback(<K extends keyof Tweaks>(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<string, unknown>) => void
mode: 'desktop' | 'mobile'
runStats: () => [string, string][]
case: PublicCase
runId: string
setTweak: <K extends keyof Tweaks>(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<Game | null>(null)
export const useGame = (): Game => useContext(GameCtx)!
interface ProviderProps {
case: PublicCase
runId: string
mode: 'desktop' | 'mobile'
tweaks: Tweaks
setTweak: <K extends keyof Tweaks>(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<string, unknown>) => {
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 <GameCtx.Provider value={game}>{children}</GameCtx.Provider>
}