case0 / web /src /store.tsx
aliabdelwahab's picture
Per-case visuals, incident vignettes, guided play, no-repeat dealing, four new crime kinds
aabd7c7 verified
Raw
History Blame
7.36 kB
// 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' | 'suspects' | '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[]
/** Suspects whose lie cracked (cornered / contradiction exposed) at least once. */
cracked: Record<string, boolean>
/** evidenceId -> suspect ids it rattled or cracked when presented. */
evHits: Record<string, string[]>
accuse: AccuseState
startedAt: number
}
/** A suspect at or above this suspicion is "cornered" — ready to accuse. */
export const HOT_SUSPICION = 75
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: 'FLAG'; sid: string; evId?: string; kind: 'rattled' | 'cornered' | 'contradiction' }
| { 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 'FLAG': {
const cracked = a.kind !== 'rattled' ? { ...s.cracked, [a.sid]: true } : s.cracked
let { evHits } = s
if (a.evId) {
const cur = s.evHits[a.evId] || []
if (!cur.includes(a.sid)) evHits = { ...s.evHits, [a.evId]: [...cur, a.sid] }
}
return { ...s, cracked, evHits }
}
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: [],
cracked: {},
evHits: {},
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,
}
// In-memory only: the tweaks sheet UI is gone, so persisted values would be
// unrevertable. Defaults are deterministic; setTweak stays as a debug hook.
export function useTweaks(): [Tweaks, <K extends keyof Tweaks>(k: K, v: Tweaks[K]) => void] {
const [t, setT] = useState<Tweaks>(TWEAK_DEFAULTS)
const setTweak = useCallback(<K extends keyof Tweaks>(k: K, v: Tweaks[K]) => {
setT((prev) => ({ ...prev, [k]: v }))
}, [])
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>
}