1582 lines
55 KiB
TypeScript
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 }),
|
|
}))
|