Spaces:
Running
Running
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> | |
| } | |