Files
cruciverb/frontend/src/store.ts
T

1582 lines
55 KiB
TypeScript

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<string, number>
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<string, boolean>
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<NarratorSettings>) => 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<void>
checkPendingReceipts: () => void
mpCreateRoom: (mode: string, partySub?: string) => Promise<void>
mpJoinRoom: (code: string) => Promise<void>
mpLeaveRoom: () => void
mpStartGame: () => void
mpPassTurn: () => void
generatePuzzle: (difficulty?: string, size?: number, density?: number, clueDifficulty?: number, theme?: string, cryptic?: boolean, pack?: string) => Promise<void>
loadDaily: (date: string, preset: string) => Promise<void>
loadByShortId: (shortId: string) => Promise<void>
fetchSession: () => Promise<void>
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<void>
checkCell: () => Promise<void>
revealLetter: () => Promise<void>
revealWord: () => Promise<void>
tick: () => void
togglePause: () => void
setInputMode: (mode: InputMode) => void
setStylusSettings: (settings: Partial<StylusSettings>) => 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<void>
importPuzzle: (file: File) => Promise<void>
}
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<CellStatus>(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<string, number>
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<PuzzleStore> | ((s: PuzzleStore) => Partial<PuzzleStore>)) => 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<PuzzleStore>((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<NarratorSettings>) => {
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<PuzzleStore> = { 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<PuzzleStore> = {
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<PuzzleStore> = { 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 }),
}))