Spaces:
Running
Running
File size: 7,355 Bytes
2a0e91b 16ff49b 2a0e91b 16ff49b 2a0e91b 16ff49b 2a0e91b 16ff49b 2a0e91b 16ff49b 2a0e91b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 | // 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>
}
|