diff --git a/frontend/src/store.ts b/frontend/src/store.ts new file mode 100644 index 0000000..cf9ef4f --- /dev/null +++ b/frontend/src/store.ts @@ -0,0 +1,1568 @@ +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 + + narratorEnabled: boolean + narratorSettings: NarratorSettings + setNarratorEnabled: (enabled: boolean) => void + 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 + 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: [], + + narratorEnabled: false, + narratorSettings: { ...defaultNarratorSettings }, + + setNarratorEnabled: (enabled: boolean) => { + set({ narratorEnabled: enabled, narratorSettings: { ...get().narratorSettings, enabled } }) + }, + + setNarratorSettings: (partial: Partial) => { + const next = { ...get().narratorSettings, ...partial } + set({ narratorSettings: next, narratorEnabled: next.enabled }) + }, + + 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, + }) + } + }, + + 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 }), +}))