import { create } from 'zustand' import type { ClientPuzzle, CellStatus, Direction, WordSpan, InputMode, GameMode, StylusSettings, RecognitionResult } from './types' import type { NarratorSettings } from './narrator' import { defaultNarratorSettings } from './narrator' import * as api from './api' import type { ReceiptBody } from './api' import * as offline from './offline' import { SPEEDRUN_LIMITS } from './presets' export interface PendingReceipt { receipt: ReceiptBody signature: string key_id: string receipt_url: string dismissed: boolean } export function getPendingReceipts(): PendingReceipt[] { try { return JSON.parse(localStorage.getItem('pending-receipts') || '[]') } catch { return [] } } function savePendingReceipt(r: PendingReceipt) { const all = getPendingReceipts().filter(x => x.receipt.receipt_id !== r.receipt.receipt_id) all.push(r) localStorage.setItem('pending-receipts', JSON.stringify(all)) } function removePendingReceipt(receiptId: string) { const all = getPendingReceipts().filter(x => x.receipt.receipt_id !== receiptId) localStorage.setItem('pending-receipts', JSON.stringify(all)) } interface PuzzleStore { puzzleId: string | null shortId: string | null dailyDate: string | null dailyPreset: string | null puzzle: ClientPuzzle | null loading: boolean error: string | null entries: string[][] cellStatus: CellStatus[][] selectedCell: { row: number; col: number } | null direction: Direction activeClue: number | null solved: boolean wordSpans: WordSpan[] startTime: number | null elapsed: number paused: boolean inputMode: InputMode stylusSettings: StylusSettings recognitionResult: RecognitionResult | null showDisambiguation: boolean gameMode: GameMode timeLimit: number marathonCount: number marathonFailed: boolean streak: { current: number; best: number } | null streakFreezes: number hintsUsed: number showOnboarding: boolean hasPasskey: boolean preferredPreset: string | null cellEverWrong: boolean[][] dailyChallenge: string | null challengeCompleted: boolean challengeDeleteCount: number challengePauseUsed: boolean challengeAutoCheckUsed: boolean challengePencilUsed: boolean challengeFirstCell: [number, number] | null challengeFirstLetterTime: number | null challengeCellFocusStart: number | null challengeLastLetterTimestamps: number[] challengeCellTimes: Record challengeConsecutiveLetters: string[] challengeHadVowelStreak: boolean challengeHadConsonantStreak: boolean challengeHadTriple: boolean challengeFirstLetter: string | null challengeLastLetter: string | null challengeMaxGap: number challengeMaxCellTime: number challengeFirstRowFilledTime: number | null challengeCellsIn30s: number challengeEmptyCellsBefore: number completedChallenges: Record showHeatmap: boolean solveRank: number | null solvePercentile: number | null solveTotalSolvers: number | null showEditor: boolean showAdmin: boolean pendingReceipt: PendingReceipt | null mpConnected: boolean mpRoomCode: string | null mpMode: string | null mpPlayers: { id: string; name: string; color: string; cursor: [number, number] | null; direction: string }[] mpWs: WebSocket | null mpStarted: boolean mpCurrentTurn: string | null mpMyId: string | null mpIsCreator: boolean partySubMode: string | null partyPhase: string | null partyRound: number partyTotalRounds: number partyClue: string | null partyDeadline: number | null partyAnswers: { id: string; text: string }[] partyResults: { id: string; text: string; author: string; authorColor: string; votes: number; isReal: boolean }[] | null partyRealAnswer: string | null partyScores: { id: string; name: string; color: string; score: number }[] partyMySubmission: string | null partyMyVote: string | null partyChoices: { id: string; label: string; text: string }[] partyMyPick: string | null partyCorrectPlayers: { id: string; name: string; color: string; rank: number; points: number }[] undoStack: { row: number; col: number; prev: string; next: string; pencil?: boolean }[] redoStack: { row: number; col: number; prev: string; next: string; pencil?: boolean }[] undo: () => void redo: () => void autoCheck: boolean toggleAutoCheck: () => void pencilMode: boolean pencilEntries: string[][] togglePencilMode: () => void narratorSettings: NarratorSettings setNarratorSettings: (settings: Partial) => void partySubmitAnswer: (answer: string) => void partyVoteAnswer: (answerId: string) => void partySpeedAnswer: (answer: string) => void partyPickChoice: (choiceId: string) => void dismissReceipt: (receiptId: string) => void claimPendingReceipt: (receiptId: string) => Promise checkPendingReceipts: () => void mpCreateRoom: (mode: string, partySub?: string) => Promise mpJoinRoom: (code: string) => Promise mpLeaveRoom: () => void mpStartGame: () => void mpPassTurn: () => void generatePuzzle: (difficulty?: string, size?: number, density?: number, clueDifficulty?: number, theme?: string, cryptic?: boolean, pack?: string) => Promise loadDaily: (date: string, preset: string) => Promise loadByShortId: (shortId: string) => Promise fetchSession: () => Promise setShowOnboarding: (show: boolean) => void selectCell: (row: number, col: number) => void toggleDirection: () => void typeLetter: (letter: string) => void deleteLetter: () => void moveSelection: (dr: number, dc: number) => void moveRowEdge: (home: boolean) => void tabClue: (forward: boolean) => void deselectCell: () => void validateSolution: () => Promise checkCell: () => Promise revealLetter: () => Promise revealWord: () => Promise tick: () => void togglePause: () => void setInputMode: (mode: InputMode) => void setStylusSettings: (settings: Partial) => void setRecognitionResult: (result: RecognitionResult | null) => void acceptCandidate: (letter: string) => void dismissDisambiguation: () => void setGameMode: (mode: GameMode) => void setShowEditor: (show: boolean) => void setShowAdmin: (show: boolean) => void loadCommunityPuzzle: (id: string) => Promise importPuzzle: (file: File) => Promise } function buildWordSpans(puzzle: ClientPuzzle): WordSpan[] { const spans: WordSpan[] = [] for (const clue of puzzle.clues_across) { const cells: [number, number][] = [] // find the starting cell for this clue number for (const [key, num] of Object.entries(puzzle.cell_numbers)) { if (num === clue.number) { const [r, c] = key.split(',').map(Number) // walk right from this cell let col = c while (col < puzzle.width && puzzle.cells[r][col] !== 'black') { cells.push([r, col]) col++ } break } } if (cells.length > 0) { spans.push({ number: clue.number, direction: 'across', cells }) } } for (const clue of puzzle.clues_down) { const cells: [number, number][] = [] for (const [key, num] of Object.entries(puzzle.cell_numbers)) { if (num === clue.number) { const [r, c] = key.split(',').map(Number) let row = r while (row < puzzle.height && puzzle.cells[row][c] !== 'black') { cells.push([row, c]) row++ } break } } if (cells.length > 0) { spans.push({ number: clue.number, direction: 'down', cells }) } } return spans } function findClueForCell( spans: WordSpan[], row: number, col: number, direction: Direction, ): WordSpan | undefined { return spans.find( (s) => s.direction === direction && s.cells.some(([r, c]) => r === row && c === col), ) } function makeEmptyGrid(w: number, h: number): string[][] { return Array.from({ length: h }, () => Array(w).fill('')) } function makeStatusGrid(w: number, h: number): CellStatus[][] { return Array.from({ length: h }, () => Array(w).fill('default'), ) } function makeBoolGrid(w: number, h: number): boolean[][] { return Array.from({ length: h }, () => Array(w).fill(false)) } type PartyAnswerInfo = { id: string; text: string } type PartyResultInfo = { id: string; text: string; author: string; authorColor: string; votes: number; isReal: boolean } type PartyScoreInfo = { id: string; name: string; color: string; score: number } type PartyCorrectInfo = { id: string; name: string; color: string; rank: number; points: number } type PartyChoiceInfo = { id: string; label: string; text: string } interface PartyDefaultsState { partySubMode: string | null partyPhase: string | null partyRound: number partyTotalRounds: number partyClue: string | null partyDeadline: number | null partyAnswers: PartyAnswerInfo[] partyResults: PartyResultInfo[] | null partyRealAnswer: string | null partyScores: PartyScoreInfo[] partyMySubmission: string | null partyMyVote: string | null partyChoices: PartyChoiceInfo[] partyMyPick: string | null partyCorrectPlayers: PartyCorrectInfo[] } function partyDefaults(): PartyDefaultsState { return { partySubMode: null, partyPhase: null, partyRound: 0, partyTotalRounds: 0, partyClue: null, partyDeadline: null, partyAnswers: [], partyResults: null, partyRealAnswer: null, partyScores: [], partyMySubmission: null, partyMyVote: null, partyChoices: [], partyMyPick: null, partyCorrectPlayers: [], } } interface ChallengeDefaultsState { challengeDeleteCount: number challengePauseUsed: boolean challengeAutoCheckUsed: boolean challengePencilUsed: boolean challengeFirstCell: [number, number] | null challengeFirstLetterTime: number | null challengeCellFocusStart: number | null challengeLastLetterTimestamps: number[] challengeCellTimes: Record challengeConsecutiveLetters: string[] challengeHadVowelStreak: boolean challengeHadConsonantStreak: boolean challengeHadTriple: boolean challengeFirstLetter: string | null challengeLastLetter: string | null challengeMaxGap: number challengeMaxCellTime: number challengeFirstRowFilledTime: number | null challengeCellsIn30s: number challengeEmptyCellsBefore: number } function challengeDefaults(): ChallengeDefaultsState { return { challengeDeleteCount: 0, challengePauseUsed: false, challengeAutoCheckUsed: false, challengePencilUsed: false, challengeFirstCell: null, challengeFirstLetterTime: null, challengeCellFocusStart: null, challengeLastLetterTimestamps: [], challengeCellTimes: {}, challengeConsecutiveLetters: [], challengeHadVowelStreak: false, challengeHadConsonantStreak: false, challengeHadTriple: false, challengeFirstLetter: null, challengeLastLetter: null, challengeMaxGap: 0, challengeMaxCellTime: 0, challengeFirstRowFilledTime: null, challengeCellsIn30s: 0, challengeEmptyCellsBefore: 0, } } const VOWELS = 'AEIOU' function updateLetterStreaks(letters: string[], letter: string) { const buf = [...letters, letter].slice(-6) let hadVowel = false let hadConsonant = false let hadTriple = false if (buf.length >= 5) { const last5 = buf.slice(-5) if (last5.every(l => VOWELS.includes(l))) hadVowel = true } if (buf.length >= 6) { if (buf.every(l => !VOWELS.includes(l))) hadConsonant = true } if (buf.length >= 3) { const last3 = buf.slice(-3) if (last3[0] === last3[1] && last3[1] === last3[2]) hadTriple = true } return { buf, hadVowel, hadConsonant, hadTriple } } function isChallengeComplete(challenge: string, state: PuzzleStore): boolean { const hour = new Date().getHours() switch (challenge) { case 'purist': return state.hintsUsed === 0 case 'speed_demon': { const limit = SPEEDRUN_LIMITS[state.dailyPreset || ''] return limit ? state.elapsed < limit : false } case 'surgeon': return !state.cellEverWrong.some(r => r.some(v => v)) case 'no_regrets': return state.challengeDeleteCount === 0 case 'iron_will': return !state.challengePauseUsed case 'trust_fall': return !state.challengeAutoCheckUsed case 'inkwell': return !state.challengePencilUsed case 'night_owl': return hour >= 0 && hour < 5 case 'early_bird': return hour >= 5 && hour < 8 case 'rush_hour': return hour >= 12 && hour < 13 case 'night_shift': return hour >= 20 && hour <= 23 case 'afternoon_tea': return hour >= 15 && hour < 17 case 'contrarian': if (!state.challengeFirstCell || !state.puzzle) return false return state.challengeFirstCell[0] >= Math.floor(state.puzzle.height / 2) && state.challengeFirstCell[1] >= Math.floor(state.puzzle.width / 2) case 'sunday_stroll': return state.elapsed >= 180 case 'blitz': return state.elapsed < 60 case 'opera_singer': return state.challengeHadVowelStreak case 'tongue_twister': return state.challengeHadConsonantStreak case 'mic_drop': return state.challengeEmptyCellsBefore === 1 case 'jackrabbit': return state.challengeCellsIn30s >= 10 case 'metronome': return state.challengeMaxGap <= 15 case 'quickdraw': return state.challengeMaxCellTime <= 10 case 'bookends': return state.challengeFirstLetter != null && state.challengeLastLetter != null case 'long_haul': return state.challengeFirstLetterTime != null && state.startTime != null && (state.challengeFirstLetterTime - state.startTime) >= 30000 case 'fast_fingers': return state.challengeFirstRowFilledTime != null && state.startTime != null && (state.challengeFirstRowFilledTime - state.startTime) <= 20000 case 'three_in_a_row': return state.challengeHadTriple case 'alphabetical': return 'ABCDE'.includes(state.challengeFirstLetter || '') case 'lucky_seven': return Math.abs(state.elapsed - 420) <= 10 case 'even_steven': return state.elapsed % 2 === 0 case 'minimalist': return state.hintsUsed > 0 && state.hintsUsed < 3 case 'overtime': return state.elapsed >= 600 default: return false } } function errMsg(e: Error | string): string { return e instanceof Error ? e.message : e } interface SolvedStateUpdates { solved: boolean; elapsed: number; hintsUsed: number; cellStatus?: CellStatus[][]; cellEverWrong?: boolean[][] } interface MpPlayer { id: string; name: string; color: string; cursor: [number, number] | null; direction?: string; score?: number; connected?: boolean } interface MpAnswer { id: string; text: string; author: string; author_color: string; votes: number; is_real: boolean } interface MpOption { id: string; label: string; text: string } interface MpScore { id: string; name: string; color: string; score: number } interface MpMessage { type: string id: string name: string color: string row: number col: number direction: string letter: string grid?: string[][] players?: MpPlayer[] your_id?: string started?: boolean current_turn?: string | null phase: string round: number total: number clue: string deadline_secs: number answers: MpAnswer[] real_answer?: string | null scores: MpScore[] options: MpOption[] rank: number points: number } function handleMpMessage(msg: MpMessage, get: () => PuzzleStore, set: (partial: Partial | ((s: PuzzleStore) => Partial)) => void) { switch (msg.type) { case 'player_join': set(s => ({ mpPlayers: [...s.mpPlayers, { id: msg.id, name: msg.name, color: msg.color, cursor: null, direction: 'across' }] })) break case 'player_leave': set(s => ({ mpPlayers: s.mpPlayers.filter(p => p.id !== msg.id) })) break case 'player_cursor': set(s => ({ mpPlayers: s.mpPlayers.map(p => p.id === msg.id ? { ...p, cursor: [msg.row, msg.col], direction: msg.direction } : p) })) break case 'cell_update': { const entries = get().entries.map(r => [...r]) entries[msg.row][msg.col] = msg.letter set({ entries }) break } case 'cell_clear': { const entries = get().entries.map(r => [...r]) entries[msg.row][msg.col] = '' set({ entries }) break } case 'state': if (msg.grid) set({ entries: msg.grid }) if (msg.players) { set({ mpPlayers: msg.players.map(p => ({ ...p, cursor: p.cursor ? [p.cursor[0], p.cursor[1]] : null, direction: p.direction || 'across' })) }) } if (msg.your_id) set({ mpMyId: msg.your_id }) set({ mpStarted: !!msg.started, mpCurrentTurn: msg.current_turn || null }) break case 'turn_change': set({ mpCurrentTurn: msg.id }) break case 'game_started': set({ mpStarted: true }) break case 'party_round': set({ partyPhase: msg.phase, partyRound: msg.round, partyTotalRounds: msg.total, partyClue: msg.clue, partyDeadline: Date.now() + msg.deadline_secs * 1000, partyMySubmission: null, partyMyVote: null, partyMyPick: null, partyAnswers: [], partyResults: null, partyRealAnswer: null, partyChoices: [], partyCorrectPlayers: [], }) break case 'party_answers': set({ partyAnswers: msg.answers, partyPhase: 'vote' }) break case 'party_results': set({ partyResults: msg.answers.map(a => ({ id: a.id, text: a.text, author: a.author, authorColor: a.author_color, votes: a.votes, isReal: a.is_real, })), partyRealAnswer: msg.real_answer || null, partyScores: msg.scores, partyPhase: 'results', }) break case 'party_final': set({ partyScores: msg.scores, partyPhase: 'finished' }) break case 'party_correct': set(s => ({ partyCorrectPlayers: [...s.partyCorrectPlayers, { id: msg.id, name: msg.name, color: msg.color, rank: msg.rank, points: msg.points, }], })) break case 'party_choices': set({ partyChoices: msg.options.map(o => ({ id: o.id, label: o.label, text: o.text })), partyPhase: 'choose', }) break } } export const useStore = create((set, get) => ({ puzzleId: null, shortId: null, dailyDate: null, dailyPreset: null, puzzle: null, loading: false, error: null, entries: [], cellStatus: [], selectedCell: null, direction: 'across', activeClue: null, solved: false, wordSpans: [], startTime: null, elapsed: 0, paused: false, inputMode: 'auto', stylusSettings: { strokeWidth: 3, debounceMs: 800, autoFillThreshold: 0.5, ambiguityThreshold: 0.5, enableOnnx: true, enableTesseract: true, enableStrokeMatcher: true, }, recognitionResult: null, showDisambiguation: false, gameMode: 'standard', timeLimit: 0, marathonCount: 0, marathonFailed: false, streak: null, streakFreezes: 0, hintsUsed: 0, showOnboarding: false, hasPasskey: false, preferredPreset: null, cellEverWrong: [], dailyChallenge: null, challengeCompleted: false, challengeDeleteCount: 0, challengePauseUsed: false, challengeAutoCheckUsed: false, challengePencilUsed: false, challengeFirstCell: null, challengeFirstLetterTime: null, challengeCellFocusStart: null, challengeLastLetterTimestamps: [], challengeCellTimes: {}, challengeConsecutiveLetters: [], challengeHadVowelStreak: false, challengeHadConsonantStreak: false, challengeHadTriple: false, challengeFirstLetter: null, challengeLastLetter: null, challengeMaxGap: 0, challengeMaxCellTime: 0, challengeFirstRowFilledTime: null, challengeCellsIn30s: 0, challengeEmptyCellsBefore: 0, completedChallenges: {}, showHeatmap: false, solveRank: null, solvePercentile: null, solveTotalSolvers: null, showEditor: false, showAdmin: false, pendingReceipt: null, mpConnected: false, mpRoomCode: null, mpMode: null, mpPlayers: [], mpWs: null, mpStarted: false, mpCurrentTurn: null, mpMyId: null, mpIsCreator: false, undoStack: [], redoStack: [], autoCheck: localStorage.getItem('cruciverb-autocheck') === 'true', pencilMode: false, pencilEntries: [], narratorSettings: { ...defaultNarratorSettings }, setNarratorSettings: (partial: Partial) => { set({ narratorSettings: { ...get().narratorSettings, ...partial } }) }, undo: () => { const { undoStack, redoStack, entries, pencilEntries, mpWs } = get() if (undoStack.length === 0) return const action = undoStack[undoStack.length - 1] const newUndo = undoStack.slice(0, -1) const newRedo = [...redoStack, action] if (action.pencil) { const newPencil = pencilEntries.map(r => [...r]) newPencil[action.row][action.col] = action.prev set({ pencilEntries: newPencil, undoStack: newUndo, redoStack: newRedo, challengeDeleteCount: get().challengeDeleteCount + 1 }) } else { const newEntries = entries.map(r => [...r]) newEntries[action.row][action.col] = action.prev set({ entries: newEntries, undoStack: newUndo, redoStack: newRedo, challengeDeleteCount: get().challengeDeleteCount + 1 }) if (mpWs && mpWs.readyState === WebSocket.OPEN) { if (action.prev) { mpWs.send(JSON.stringify({ type: 'cell', row: action.row, col: action.col, letter: action.prev })) } else { mpWs.send(JSON.stringify({ type: 'clear', row: action.row, col: action.col })) } } } }, redo: () => { const { undoStack, redoStack, entries, pencilEntries, mpWs } = get() if (redoStack.length === 0) return const action = redoStack[redoStack.length - 1] const newRedo = redoStack.slice(0, -1) const newUndo = [...undoStack, action] if (action.pencil) { const newPencil = pencilEntries.map(r => [...r]) newPencil[action.row][action.col] = action.next set({ pencilEntries: newPencil, undoStack: newUndo, redoStack: newRedo }) } else { const newEntries = entries.map(r => [...r]) newEntries[action.row][action.col] = action.next set({ entries: newEntries, undoStack: newUndo, redoStack: newRedo }) if (mpWs && mpWs.readyState === WebSocket.OPEN) { if (action.next) { mpWs.send(JSON.stringify({ type: 'cell', row: action.row, col: action.col, letter: action.next })) } else { mpWs.send(JSON.stringify({ type: 'clear', row: action.row, col: action.col })) } } } }, toggleAutoCheck: () => { const next = !get().autoCheck localStorage.setItem('cruciverb-autocheck', String(next)) set({ autoCheck: next }) }, togglePencilMode: () => { const next = !get().pencilMode const updates: Partial = { pencilMode: next } if (next) updates.challengePencilUsed = true set(updates) }, partySubMode: null, partyPhase: null, partyRound: 0, partyTotalRounds: 0, partyClue: null, partyDeadline: null, partyAnswers: [], partyResults: null, partyRealAnswer: null, partyScores: [], partyMySubmission: null, partyMyVote: null, partyChoices: [], partyMyPick: null, partyCorrectPlayers: [], partySubmitAnswer: (answer: string) => { const ws = get().mpWs if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'party_submit', answer })) } set({ partyMySubmission: answer }) }, partyVoteAnswer: (answerId: string) => { const ws = get().mpWs if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'party_vote', answer_id: answerId })) } set({ partyMyVote: answerId }) }, partySpeedAnswer: (answer: string) => { const ws = get().mpWs if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'party_answer', answer })) } }, partyPickChoice: (choiceId: string) => { const ws = get().mpWs if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'party_pick', choice_id: choiceId })) } set({ partyMyPick: choiceId }) }, dismissReceipt: (receiptId: string) => { removePendingReceipt(receiptId) set({ pendingReceipt: null }) }, claimPendingReceipt: async (receiptId: string) => { const all = getPendingReceipts() const found = all.find(r => r.receipt.receipt_id === receiptId) if (!found) return try { await api.claimReceipt({ receipt: found.receipt, signature: found.signature, key_id: found.key_id }) removePendingReceipt(receiptId) set({ pendingReceipt: null }) } catch { /* ignored */ } }, checkPendingReceipts: () => { const all = getPendingReceipts() const pending = all.find(r => !r.dismissed) if (pending) set({ pendingReceipt: pending }) }, mpCreateRoom: async (mode: string, partySub?: string) => { const { puzzleId, mpWs: oldWs } = get() if (mode !== 'party' && !puzzleId) throw new Error('No puzzle loaded') if (oldWs) oldWs.close() const room = await api.createRoom(puzzleId || '', mode, partySub) const code = room.code set({ mpRoomCode: code, mpMode: mode, mpIsCreator: true, partySubMode: partySub || (mode === 'party' ? 'wrong' : null) }) const ws = api.connectRoom(code) ws.onopen = () => set({ mpConnected: true }) ws.onclose = () => set({ mpConnected: false, mpRoomCode: null, mpMode: null, mpPlayers: [], mpWs: null, mpStarted: false, mpCurrentTurn: null, mpMyId: null, mpIsCreator: false, ...partyDefaults() }) ws.onmessage = (e) => handleMpMessage(JSON.parse(e.data), get, set) set({ mpWs: ws }) }, mpJoinRoom: async (code: string) => { const { mpWs: oldWs } = get() if (oldWs) oldWs.close() const info = await api.getRoomInfo(code) set({ mpRoomCode: code, mpMode: info.mode, mpIsCreator: false, partySubMode: info.party?.sub_mode || null }) if (info.puzzle_id && info.puzzle_id !== get().puzzleId) { const res = await api.fetchPuzzleById(info.puzzle_id).catch(() => null) if (res) { const spans = buildWordSpans(res.puzzle) set({ puzzleId: res.id, shortId: res.short_id, puzzle: res.puzzle, entries: makeEmptyGrid(res.puzzle.width, res.puzzle.height), pencilEntries: makeEmptyGrid(res.puzzle.width, res.puzzle.height), cellStatus: makeStatusGrid(res.puzzle.width, res.puzzle.height), selectedCell: null, direction: 'across', activeClue: null, wordSpans: spans, startTime: null, elapsed: 0, paused: false, solved: false, hintsUsed: 0, undoStack: [], redoStack: [], }) } } const ws = api.connectRoom(code) ws.onopen = () => set({ mpConnected: true }) ws.onclose = () => set({ mpConnected: false, mpRoomCode: null, mpMode: null, mpPlayers: [], mpWs: null, mpStarted: false, mpCurrentTurn: null, mpMyId: null, mpIsCreator: false, ...partyDefaults() }) ws.onmessage = (e) => { // SAFETY: payloads are the multiplayer room server's own protocol frames, parsed at the boundary handleMpMessage(JSON.parse(e.data) as MpMessage, get, set) } set({ mpWs: ws }) }, mpLeaveRoom: () => { const { mpWs } = get() if (mpWs) mpWs.close() set({ mpConnected: false, mpRoomCode: null, mpMode: null, mpPlayers: [], mpWs: null, mpStarted: false, mpCurrentTurn: null, mpMyId: null, mpIsCreator: false, ...partyDefaults() }) }, mpStartGame: () => { const ws = get().mpWs if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'start' })) } }, mpPassTurn: () => { const ws = get().mpWs if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'pass' })) } }, fetchSession: async () => { try { const me = await api.fetchMe() set({ streak: me.streak, streakFreezes: me.streak_freezes || 0, showOnboarding: me.is_new, hasPasskey: me.has_passkey, preferredPreset: me.preferred_preset || null, }) } catch { /* ignored */ } }, setShowOnboarding: (show: boolean) => set({ showOnboarding: show }), loadDaily: async (date, preset) => { if (get().loading) return set({ loading: true, error: null, solved: false }) try { let res try { res = await api.fetchDaily(date, preset) // cache for offline offline.cachePuzzle(`daily-${date}-${preset}`, res).catch(() => {}) } catch (_e) { // try offline cache const cached = await offline.getCachedPuzzle(`daily-${date}-${preset}`) if (cached) { res = cached } else { throw _e } } const spans = buildWordSpans(res.puzzle) const savedProgress = await offline.getProgress(res.id).catch(() => null) set({ puzzleId: res.id, shortId: res.short_id, dailyDate: date, dailyPreset: preset, puzzle: res.puzzle, entries: savedProgress || makeEmptyGrid(res.puzzle.width, res.puzzle.height), pencilEntries: makeEmptyGrid(res.puzzle.width, res.puzzle.height), cellStatus: makeStatusGrid(res.puzzle.width, res.puzzle.height), cellEverWrong: makeBoolGrid(res.puzzle.width, res.puzzle.height), selectedCell: null, direction: 'across', activeClue: null, wordSpans: spans, loading: false, startTime: null, elapsed: 0, paused: false, solved: false, hintsUsed: 0, undoStack: [], redoStack: [], solveRank: null, solvePercentile: null, solveTotalSolvers: null, dailyChallenge: null, challengeCompleted: false, showHeatmap: false, ...challengeDefaults(), challengeAutoCheckUsed: false, }) // if already solved, show the completed state with original data if (res.already_solved) { const updates: SolvedStateUpdates = { solved: true, elapsed: res.solve_time || 0, hintsUsed: res.solve_hints || 0, } // restore per-cell results if (res.cell_results) { try { const cr = JSON.parse(res.cell_results) const newStatus: CellStatus[][] = cr.map((row: string[]) => row.map((s: string): CellStatus => { if (s === 'r') return 'revealed' return 'default' }) ) const newEverWrong = cr.map((row: string[]) => row.map((s: string) => s === 'w') ) updates.cellStatus = newStatus updates.cellEverWrong = newEverWrong } catch { /* ignored */ } } set(updates) } api.fetchDailyChallenge(date, preset).then(ch => { set({ dailyChallenge: ch.challenge || null, challengeCompleted: ch.completed }) }).catch(() => {}) } catch (e) { set({ loading: false, error: offline.isOnline() ? errMsg(e instanceof Error ? e : String(e)) : 'No cached puzzles available offline' }) } }, loadByShortId: async (shortId) => { set({ loading: true, error: null, solved: false }) try { const res = await api.fetchPuzzleByShortId(shortId) const spans = buildWordSpans(res.puzzle) set({ puzzleId: res.id, shortId: res.short_id, puzzle: res.puzzle, entries: makeEmptyGrid(res.puzzle.width, res.puzzle.height), pencilEntries: makeEmptyGrid(res.puzzle.width, res.puzzle.height), cellStatus: makeStatusGrid(res.puzzle.width, res.puzzle.height), cellEverWrong: makeBoolGrid(res.puzzle.width, res.puzzle.height), selectedCell: null, direction: 'across', activeClue: null, wordSpans: spans, loading: false, startTime: null, elapsed: 0, paused: false, solved: false, hintsUsed: 0, undoStack: [], redoStack: [], solveRank: null, solvePercentile: null, solveTotalSolvers: null, dailyDate: null, dailyPreset: null, dailyChallenge: null, challengeCompleted: false, showHeatmap: false, ...challengeDefaults(), challengeAutoCheckUsed: false, }) } catch (e) { set({ loading: false, error: errMsg(e instanceof Error ? e : String(e)) }) } }, setGameMode: (mode: GameMode) => set({ gameMode: mode }), setShowEditor: (show: boolean) => set({ showEditor: show }), setShowAdmin: (show: boolean) => set({ showAdmin: show }), loadCommunityPuzzle: async (id: string) => { if (get().loading) return set({ loading: true, error: null, solved: false }) try { const res = await api.loadCommunityPuzzle(id) const spans = buildWordSpans(res.puzzle) set({ puzzleId: res.id, shortId: res.short_id, puzzle: res.puzzle, entries: makeEmptyGrid(res.puzzle.width, res.puzzle.height), pencilEntries: makeEmptyGrid(res.puzzle.width, res.puzzle.height), cellStatus: makeStatusGrid(res.puzzle.width, res.puzzle.height), cellEverWrong: makeBoolGrid(res.puzzle.width, res.puzzle.height), selectedCell: null, direction: 'across', activeClue: null, wordSpans: spans, loading: false, startTime: null, elapsed: 0, paused: false, solved: false, hintsUsed: 0, undoStack: [], redoStack: [], solveRank: null, solvePercentile: null, solveTotalSolvers: null, dailyDate: null, dailyPreset: null, dailyChallenge: null, challengeCompleted: false, showHeatmap: false, ...challengeDefaults(), challengeAutoCheckUsed: false, }) } catch (e) { set({ loading: false, error: errMsg(e instanceof Error ? e : String(e)) }) } }, importPuzzle: async (file: File) => { if (get().loading) return set({ loading: true, error: null, solved: false, solveRank: null, solvePercentile: null, solveTotalSolvers: null }) try { const res = await api.importPuzzle(file) const spans = buildWordSpans(res.puzzle) set({ puzzleId: res.id, shortId: res.short_id, puzzle: res.puzzle, entries: makeEmptyGrid(res.puzzle.width, res.puzzle.height), pencilEntries: makeEmptyGrid(res.puzzle.width, res.puzzle.height), cellStatus: makeStatusGrid(res.puzzle.width, res.puzzle.height), cellEverWrong: makeBoolGrid(res.puzzle.width, res.puzzle.height), selectedCell: null, direction: 'across', activeClue: null, wordSpans: spans, loading: false, startTime: null, elapsed: 0, paused: false, solved: false, hintsUsed: 0, undoStack: [], redoStack: [], dailyDate: null, dailyPreset: null, dailyChallenge: null, challengeCompleted: false, showHeatmap: false, ...challengeDefaults(), challengeAutoCheckUsed: false, }) } catch (e) { set({ loading: false, error: errMsg(e instanceof Error ? e : String(e)) }) } }, generatePuzzle: async (difficulty, size, density, clueDifficulty, theme, cryptic, pack) => { const { gameMode } = get() const timeLimit = gameMode === 'speedrun' ? (SPEEDRUN_LIMITS[difficulty || ''] || 300) : 0 set({ loading: true, error: null, solved: false, timeLimit, marathonCount: 0, marathonFailed: false, solveRank: null, solvePercentile: null, solveTotalSolvers: null }) try { const res = await api.generatePuzzle(difficulty, size, density, clueDifficulty, theme, cryptic, pack) const spans = buildWordSpans(res.puzzle) set({ puzzleId: res.id, shortId: res.short_id, puzzle: res.puzzle, entries: makeEmptyGrid(res.puzzle.width, res.puzzle.height), pencilEntries: makeEmptyGrid(res.puzzle.width, res.puzzle.height), cellStatus: makeStatusGrid(res.puzzle.width, res.puzzle.height), cellEverWrong: makeBoolGrid(res.puzzle.width, res.puzzle.height), selectedCell: null, direction: 'across', activeClue: null, wordSpans: spans, loading: false, startTime: null, elapsed: 0, paused: false, solved: false, hintsUsed: 0, undoStack: [], redoStack: [], dailyDate: null, dailyPreset: null, dailyChallenge: null, challengeCompleted: false, showHeatmap: false, ...challengeDefaults(), challengeAutoCheckUsed: false, }) } catch (e) { set({ loading: false, error: errMsg(e instanceof Error ? e : String(e)) }) } }, selectCell: (row, col) => { const { puzzle, selectedCell, direction, wordSpans } = get() if (!puzzle || puzzle.cells[row][col] === 'black') return let newDir = direction if (selectedCell?.row === row && selectedCell?.col === col) { newDir = direction === 'across' ? 'down' : 'across' } const span = findClueForCell(wordSpans, row, col, newDir) const finalDir = span ? newDir : newDir === 'across' ? 'down' : 'across' const finalSpan = span || findClueForCell(wordSpans, row, col, finalDir) const updates: Partial = { selectedCell: { row, col }, direction: finalDir, activeClue: finalSpan?.number ?? null, startTime: get().startTime ?? Date.now(), } if (get().challengeFirstCell === null) { updates.challengeFirstCell = [row, col] } if (!selectedCell || selectedCell.row !== row || selectedCell.col !== col) { updates.challengeCellFocusStart = Date.now() } set(updates) const ws = get().mpWs if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'cursor', row, col, direction: finalDir })) } }, toggleDirection: () => { const { direction, selectedCell, wordSpans } = get() if (!selectedCell) return const newDir = direction === 'across' ? 'down' : 'across' const span = findClueForCell( wordSpans, selectedCell.row, selectedCell.col, newDir, ) if (span) { set({ direction: newDir, activeClue: span.number }) } }, typeLetter: (letter) => { const { selectedCell, entries, cellStatus, puzzle, direction, solved, marathonFailed, mpStarted, mpCurrentTurn, mpMyId, pencilMode, pencilEntries, undoStack } = get() if (!selectedCell || !puzzle || solved || marathonFailed) return if (mpStarted && mpCurrentTurn !== mpMyId) return const { row, col } = selectedCell if (puzzle.cells[row][col] === 'black') return if (cellStatus[row][col] === 'revealed') { get().moveSelection( direction === 'down' ? 1 : 0, direction === 'across' ? 1 : 0, ) return } const upper = letter.toUpperCase() const now = Date.now() // count empty cells before this letter (for mic_drop) let emptyCells = 0 for (let r = 0; r < puzzle.height; r++) { for (let c = 0; c < puzzle.width; c++) { if (puzzle.cells[r][c] !== 'black' && !entries[r][c]) emptyCells++ } } if (pencilMode) { const prev = pencilEntries[row]?.[col] || '' const newPencil = pencilEntries.map(r => [...r]) newPencil[row][col] = upper const newUndo = [...undoStack, { row, col, prev, next: upper, pencil: true }].slice(-200) set({ pencilEntries: newPencil, undoStack: newUndo, redoStack: [], startTime: get().startTime ?? now }) } else { const prev = entries[row][col] const newEntries = entries.map((r) => [...r]) newEntries[row][col] = upper const newPencil = pencilEntries.map(r => [...r]) newPencil[row][col] = '' const newUndo = [...undoStack, { row, col, prev, next: upper }].slice(-200) set({ entries: newEntries, pencilEntries: newPencil, undoStack: newUndo, redoStack: [], startTime: get().startTime ?? now }) const ws = get().mpWs if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'cell', row, col, letter: upper })) } if (get().autoCheck && get().puzzleId) { const sentLetter = upper api.requestHint(get().puzzleId!, 'check_cell', row, col, undefined, upper).then(res => { if (get().entries[row]?.[col] !== sentLetter) return const status = get().cellStatus.map(r => [...r]) status[row][col] = res.is_correct ? 'correct' : 'incorrect' if (!res.is_correct) { const cew = get().cellEverWrong.map(r => [...r]) cew[row][col] = true // auto-check flagged a wrong letter - this is what trust_fall tracks set({ cellStatus: status, cellEverWrong: cew, challengeAutoCheckUsed: true }) } else { set({ cellStatus: status }) } }).catch(() => {}) } } // challenge tracking const chalUpdates: Partial = { challengeLastLetter: upper, challengeEmptyCellsBefore: emptyCells } // quickdraw: dwell time on the current cell, up to now if (!pencilMode) { const focusStart = get().challengeCellFocusStart if (focusStart != null) { const dwell = Math.round((now - focusStart) / 1000) if (dwell > get().challengeMaxCellTime) chalUpdates.challengeMaxCellTime = dwell } } if (get().challengeFirstLetter === null) chalUpdates.challengeFirstLetter = upper if (get().challengeFirstLetterTime === null) chalUpdates.challengeFirstLetterTime = now // letter streak tracking const streaks = updateLetterStreaks(get().challengeConsecutiveLetters, upper) chalUpdates.challengeConsecutiveLetters = streaks.buf if (streaks.hadVowel) chalUpdates.challengeHadVowelStreak = true if (streaks.hadConsonant) chalUpdates.challengeHadConsonantStreak = true if (streaks.hadTriple) chalUpdates.challengeHadTriple = true // timing gaps const timestamps = get().challengeLastLetterTimestamps if (timestamps.length > 0) { const gap = (now - timestamps[timestamps.length - 1]) / 1000 if (gap > get().challengeMaxGap) chalUpdates.challengeMaxGap = gap } chalUpdates.challengeLastLetterTimestamps = [...timestamps, now].slice(-10) // per-cell time tracking (simplified - record first entry time per cell) const cellKey = `${row},${col}` const cellTimes = get().challengeCellTimes if (!(cellKey in cellTimes)) { const enterTime = (now - (get().startTime || now)) / 1000 chalUpdates.challengeCellTimes = { ...cellTimes, [cellKey]: enterTime } } // first row filled check if (get().challengeFirstRowFilledTime === null && !pencilMode) { const currentEntries = get().entries let firstRowFilled = true for (let c = 0; c < puzzle.width; c++) { if (puzzle.cells[0][c] !== 'black' && !currentEntries[0][c] && !(row === 0 && col === c)) { firstRowFilled = false break } } if (firstRowFilled) chalUpdates.challengeFirstRowFilledTime = now } // cells in first 30s const startT = get().startTime || now if (now - startT <= 30000 && !pencilMode) { chalUpdates.challengeCellsIn30s = get().challengeCellsIn30s + 1 } set(chalUpdates) // advance to next empty cell in direction, skipping filled ones const dr = direction === 'down' ? 1 : 0 const dc = direction === 'across' ? 1 : 0 let nr = row + dr let nc = col + dc while (nr >= 0 && nc >= 0 && nr < puzzle.height && nc < puzzle.width) { if (puzzle.cells[nr][nc] === 'black') break if (!get().entries[nr][nc]) { const span = findClueForCell(get().wordSpans, nr, nc, direction) set({ selectedCell: { row: nr, col: nc }, activeClue: span?.number ?? get().activeClue, }) return } nr += dr nc += dc } // if no empty cell found in this word, just move one step get().moveSelection(dr, dc) }, deleteLetter: () => { const { selectedCell, entries, puzzle, direction, cellStatus, solved, marathonFailed, mpStarted, mpCurrentTurn, mpMyId, pencilMode, pencilEntries, undoStack } = get() if (mpStarted && mpCurrentTurn !== mpMyId) return if (!selectedCell || !puzzle || solved || marathonFailed) return const { row, col } = selectedCell if (pencilMode && pencilEntries[row]?.[col]) { const prev = pencilEntries[row][col] const newPencil = pencilEntries.map(r => [...r]) newPencil[row][col] = '' const newUndo = [...undoStack, { row, col, prev, next: '', pencil: true }].slice(-200) set({ pencilEntries: newPencil, undoStack: newUndo, redoStack: [], challengeDeleteCount: get().challengeDeleteCount + 1 }) return } if (entries[row][col] && cellStatus[row][col] !== 'revealed') { const prev = entries[row][col] const newEntries = entries.map((r) => [...r]) newEntries[row][col] = '' const newUndo = [...undoStack, { row, col, prev, next: '' }].slice(-200) set({ entries: newEntries, undoStack: newUndo, redoStack: [], challengeDeleteCount: get().challengeDeleteCount + 1 }) const ws = get().mpWs if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'clear', row, col })) } } else { const dr = direction === 'down' ? -1 : 0 const dc = direction === 'across' ? -1 : 0 let nr = row + dr let nc = col + dc while ( nr >= 0 && nc >= 0 && nr < puzzle.height && nc < puzzle.width && puzzle.cells[nr][nc] === 'black' ) { nr += dr nc += dc } if (nr >= 0 && nc >= 0 && nr < puzzle.height && nc < puzzle.width) { const newEntries = entries.map((r) => [...r]) if (cellStatus[nr][nc] !== 'revealed') { const prev = newEntries[nr][nc] newEntries[nr][nc] = '' if (prev) { const newUndo = [...undoStack, { row: nr, col: nc, prev, next: '' }].slice(-200) set({ entries: newEntries, selectedCell: { row: nr, col: nc }, undoStack: newUndo, redoStack: [] }) } else { set({ entries: newEntries, selectedCell: { row: nr, col: nc } }) } } else { set({ selectedCell: { row: nr, col: nc } }) } const ws = get().mpWs if (ws && ws.readyState === WebSocket.OPEN && cellStatus[nr][nc] !== 'revealed') { ws.send(JSON.stringify({ type: 'clear', row: nr, col: nc })) } } } }, moveSelection: (dr, dc) => { const { selectedCell, puzzle, wordSpans } = get() if (!selectedCell || !puzzle) return let nr = selectedCell.row + dr let nc = selectedCell.col + dc // skip black cells while ( nr >= 0 && nc >= 0 && nr < puzzle.height && nc < puzzle.width && puzzle.cells[nr][nc] === 'black' ) { nr += dr nc += dc } if (nr >= 0 && nc >= 0 && nr < puzzle.height && nc < puzzle.width) { const dir = get().direction const span = findClueForCell(wordSpans, nr, nc, dir) set({ selectedCell: { row: nr, col: nc }, activeClue: span?.number ?? get().activeClue, }) } }, moveRowEdge: (home) => { const { selectedCell, puzzle, wordSpans, direction } = get() if (!selectedCell || !puzzle) return const row = selectedCell.row const width = puzzle.width let col: number | null = null if (home) { for (let c = 0; c < width; c++) { if (puzzle.cells[row][c] !== 'black') { col = c; break } } } else { for (let c = width - 1; c >= 0; c--) { if (puzzle.cells[row][c] !== 'black') { col = c; break } } } if (col === null) return const span = findClueForCell(wordSpans, row, col, direction) set({ selectedCell: { row, col }, activeClue: span?.number ?? get().activeClue }) }, tabClue: (forward) => { const { wordSpans, direction, activeClue, puzzle } = get() if (!puzzle) return const dirSpans = wordSpans.filter((s) => s.direction === direction) if (dirSpans.length === 0) return let idx = dirSpans.findIndex((s) => s.number === activeClue) if (forward) { idx = (idx + 1) % dirSpans.length } else { idx = (idx - 1 + dirSpans.length) % dirSpans.length } const span = dirSpans[idx] const [r, c] = span.cells[0] set({ selectedCell: { row: r, col: c }, activeClue: span.number, }) }, deselectCell: () => { set({ selectedCell: null, activeClue: null }) }, validateSolution: async () => { const { puzzleId, entries, elapsed, hintsUsed, mpRoomCode, solved } = get() if (!puzzleId || solved) return try { const cells = entries.map((row) => row.map((c) => (c ? c : null)), ) // build cell results for storage const cellResultsGrid = get().cellStatus.map((row, r) => row.map((s, c) => { if (get().puzzle?.cells[r][c] === 'black') return 'b' if (s === 'revealed') return 'r' if (get().cellEverWrong[r]?.[c]) return 'w' return 'c' }) ) const cellResultsStr = JSON.stringify(cellResultsGrid) const res = await api.validateSolution(puzzleId, cells, elapsed, hintsUsed, mpRoomCode, cellResultsStr) const oldStatus = get().cellStatus const newStatus: CellStatus[][] = res.cells.map((row: string[], r: number) => row.map((s: string, c: number): CellStatus => { if (oldStatus[r]?.[c] === 'revealed') return 'revealed' if (s === 'correct') return 'correct' if (s === 'incorrect') return 'incorrect' return 'default' }), ) // track cells that were wrong on validation const cew = get().cellEverWrong.map(r => [...r]) for (let r = 0; r < res.cells.length; r++) { for (let c = 0; c < res.cells[r].length; c++) { if (res.cells[r][c] === 'incorrect') cew[r][c] = true } } set({ cellStatus: newStatus, cellEverWrong: cew, solved: res.correct }) if (res.correct && res.already_solved) { // already solved before - just show the solved state, no stats/challenges return } if (res.correct) { set({ solveRank: res.rank || null, solvePercentile: res.percentile || null, solveTotalSolvers: res.total_solvers || null, }) get().fetchSession() // check daily challenge completion const { dailyChallenge, dailyDate, dailyPreset, challengeCompleted } = get() if (dailyChallenge && dailyDate && dailyPreset && !challengeCompleted) { if (isChallengeComplete(dailyChallenge, get())) { set({ challengeCompleted: true }) api.completeDailyChallenge(dailyDate, dailyPreset, dailyChallenge).catch(() => {}) } } if (res.receipt) { const pending: PendingReceipt = { receipt: res.receipt.receipt, signature: res.receipt.signature, key_id: res.receipt.key_id, receipt_url: res.receipt.receipt_url ?? '', dismissed: false, } savePendingReceipt(pending) set({ pendingReceipt: pending }) } // marathon mode: auto-load next puzzle after brief delay if (get().gameMode === 'marathon') { const count = get().marathonCount + 1 set({ marathonCount: count }) setTimeout(async () => { set({ loading: true, error: null, solved: false }) try { const next = await api.generatePuzzle('glyph') const spans = buildWordSpans(next.puzzle) set({ puzzleId: next.id, shortId: next.short_id, puzzle: next.puzzle, entries: makeEmptyGrid(next.puzzle.width, next.puzzle.height), pencilEntries: makeEmptyGrid(next.puzzle.width, next.puzzle.height), cellStatus: makeStatusGrid(next.puzzle.width, next.puzzle.height), selectedCell: null, direction: 'across', activeClue: null, wordSpans: spans, loading: false, startTime: Date.now(), elapsed: 0, paused: false, hintsUsed: 0, undoStack: [], redoStack: [], }) } catch (e) { set({ loading: false, error: errMsg(e instanceof Error ? e : String(e)) }) } }, 800) } } } catch { /* ignored */ } }, checkCell: async () => { const { puzzleId, selectedCell, entries, solved } = get() if (!puzzleId || !selectedCell || solved) return const { row, col } = selectedCell const letter = entries[row][col] if (!letter) return set({ hintsUsed: get().hintsUsed + 1 }) try { const res = await api.requestHint(puzzleId, 'check_cell', row, col, undefined, letter) if (get().entries[row]?.[col] !== letter) return const newStatus = get().cellStatus.map((r) => [...r]) newStatus[row][col] = res.is_correct ? 'correct' : 'incorrect' if (!res.is_correct) { const cew = get().cellEverWrong.map(r => [...r]) cew[row][col] = true set({ cellStatus: newStatus, cellEverWrong: cew }) } else { set({ cellStatus: newStatus }) } } catch (_e) { set({ hintsUsed: get().hintsUsed - 1 }) } }, revealLetter: async () => { const { puzzleId, selectedCell, solved } = get() if (!puzzleId || !selectedCell || solved) return const { row, col } = selectedCell set({ hintsUsed: get().hintsUsed + 1 }) try { const res = await api.requestHint(puzzleId, 'reveal_letter', row, col) const newEntries = get().entries.map((r) => [...r]) const newStatus = get().cellStatus.map((r) => [...r]) newEntries[row][col] = res.letter newStatus[row][col] = 'revealed' set({ entries: newEntries, cellStatus: newStatus }) } catch (_e) { set({ hintsUsed: get().hintsUsed - 1 }) } }, revealWord: async () => { const { puzzleId, selectedCell, direction, solved } = get() if (!puzzleId || !selectedCell || solved) return set({ hintsUsed: get().hintsUsed + 1 }) try { const res = await api.requestHint(puzzleId, 'reveal_word', selectedCell.row, selectedCell.col, direction) const newEntries = get().entries.map((r) => [...r]) const newStatus = get().cellStatus.map((r) => [...r]) for (const { row, col, letter } of res.letters) { newEntries[row][col] = letter newStatus[row][col] = 'revealed' } set({ entries: newEntries, cellStatus: newStatus }) } catch (_e) { set({ hintsUsed: get().hintsUsed - 1 }) } }, tick: () => { const { startTime, paused, solved, gameMode, timeLimit, marathonFailed } = get() if (!startTime || paused || solved || marathonFailed) return const elapsed = Math.floor((Date.now() - startTime) / 1000) set({ elapsed }) // speed run: auto-fail when time runs out if (gameMode === 'speedrun' && timeLimit > 0 && elapsed >= timeLimit) { set({ solved: true }) } // marathon: fail if 2 minutes per puzzle exceeded if (gameMode === 'marathon' && elapsed >= 120) { set({ marathonFailed: true }) } }, togglePause: () => { const { gameMode } = get() if (gameMode === 'speedrun') return set((s) => ({ paused: !s.paused, challengePauseUsed: true })) }, setInputMode: (mode) => set({ inputMode: mode }), setStylusSettings: (partial) => set((s) => ({ stylusSettings: { ...s.stylusSettings, ...partial } })), setRecognitionResult: (result) => { if (!result) { set({ recognitionResult: null, showDisambiguation: false }) return } set({ recognitionResult: result }) }, acceptCandidate: (letter) => { set({ recognitionResult: null, showDisambiguation: false }) get().typeLetter(letter) }, dismissDisambiguation: () => set({ showDisambiguation: false, recognitionResult: null }), }))