import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent, } from "react"; import { allCandidates, classicRegions, constraintLabel, createEmptyPuzzle, findConflicts, isSolved, normalizePuzzle, validatePuzzle, type NormalizedPuzzle, type PuzzleDefinition, } from "../domain"; import { decodePuzzleHash, extractPreservedDocumentExtras, fromDomainPuzzle, normalizeSafeVisualPrimitives, toDomainPuzzle, type PreservedSudokuDocumentExtras, type SudokuDocument, } from "../formats"; import type { CandidateOverlay } from "../helpers"; import { CLASSIC_SAMPLE, SAMPLE_CATALOG } from "../data/samples"; import type { DifficultyAssessment, ExactSolveResult, GeneratedVariantBatch, GeneratedVariantPuzzle, GenerateVariantBatchOptions, GenerateVariantOptions, LogicalStep, LogicalSolveResult, PuzzleQualityAnalysis, PuzzleQualityOptions, QualityItemReference, } from "../solver"; import { qualityItemCells } from "../solver"; import { createProjectLibrary, createProjectRecord, type ProjectLibraryMode, type SudokuProgress, type SudokuProjectRecord, type SudokuProjectSummary, } from "../storage"; import { createSession, enterSelection, eraseSelection, maskValues, restoreSnapshot, snapshotSession, valueForKey, type EntryMode, type PlaySession, type PlaySnapshot, } from "../state/session"; import { applyLogicalStepToSession, autoRemoveNotesAfterPlacements, fillLegalCenterCandidates, pruneInvalidNotes, } from "../state/candidateMaintenance"; import { digitCompletions, matchingDigitCells, toggledDigitHighlight, } from "../state/gameplayHelpers"; import { aidMemoireFromPortable, aidMemoireToPortable, cloneAidMemoire, createAidMemoire, enterAidMemoireCell, eraseAidMemoireCell, setAidMemoireEnabled, type AidMemoireState, } from "../state/aidMemoire"; import { navigateGridCell } from "../state/gridNavigation"; import { parseCandidateVerbosity, type CandidateVerbosity, } from "../state/uiPreferences"; import { activeHypothesis, aidMemoireFromGameplayState, beginHypothesis, createGameplayHistory, createSavepoint, deleteSavepoint, describeGameplayChange, finishHypothesis, gameplayMoment, parseGameplayHistory, recordGameplayMoment, restoreSavepoint, serializeGameplayHistory, sessionFromGameplayState, type GameplayHistory, } from "../state/playHistory"; import { createSolverWorkerClient, type SolverWorkerClient } from "../workers"; import { ConstraintEditor } from "./ConstraintEditor"; import { AidMemoire } from "./AidMemoire"; import { BoardViewport } from "./BoardViewport"; import { DigitCompletionBar } from "./DigitCompletionBar"; import { GameplayHistoryDialog } from "./GameplayHistoryDialog"; import { GuidedHint } from "./GuidedHint"; import { foggedCellsForPuzzle } from "./fogVisibility"; import { guidedHintOverlay, guidedHintStepIsVisible, nextGuidedHintStage, type GuidedHintStage, } from "./guidedHint"; import { GeneratorWorkspace } from "./GeneratorWorkspace"; import { HelpersWorkspace } from "./HelpersWorkspace"; import { ImportExportDialog } from "./ImportExportDialog"; import { LibraryDialog } from "./LibraryDialog"; import { NumberPad } from "./NumberPad"; import { SolveWorkspace } from "./SolveWorkspace"; import { SetterQualityLab, type SetterQualityRunKind, } from "./SetterQualityLab"; import { SudokuBoard } from "./SudokuBoard"; type Workspace = "play" | "set" | "generate" | "solve" | "helpers"; type FeedbackKind = "info" | "success" | "error"; interface Feedback { readonly kind: FeedbackKind; readonly message: string; } type AutosaveStatus = "checking" | "idle" | "saving" | "saved" | "error"; interface HistoryEntry { readonly puzzle: PuzzleDefinition; readonly session: PlaySnapshot; readonly aidMemoire: AidMemoireState; readonly guidedCandidateTracking: boolean; } const library = createProjectLibrary(); const MAX_HISTORY = 100; const AUTOSAVE_DRAFT_ID = "autosave-current"; const AUTOSAVE_DELAY_MS = 750; function valuesToMask(values: readonly number[] | undefined): number { return (values ?? []).reduce((mask, value) => mask | (1 << (value - 1)), 0); } function sessionFromDocument( puzzle: PuzzleDefinition, progress?: Pick< SudokuDocument, | "values" | "cornerMarks" | "centerMarks" | "candidates" | "colors" | "elapsedMs" >, ): PlaySession { const session = createSession(puzzle.givens); if (progress?.values?.length === puzzle.size * puzzle.size) { session.values = [...progress.values]; } if (progress?.cornerMarks?.length === puzzle.size * puzzle.size) { session.cornerMarks = progress.cornerMarks.map(valuesToMask); } const centerMarks = progress?.centerMarks ?? progress?.candidates; if (centerMarks?.length === puzzle.size * puzzle.size) { session.centerMarks = centerMarks.map(valuesToMask); } if (progress?.colors?.length === puzzle.size * puzzle.size) { session.colors = [...progress.colors]; } session.elapsedSeconds = Math.floor((progress?.elapsedMs ?? 0) / 1_000); return session; } function sessionFromProgress( puzzle: PuzzleDefinition, progress: SudokuProgress | undefined, ): PlaySession { const session = createSession(puzzle.givens); if (progress === undefined) return session; session.values = [...progress.values]; session.cornerMarks = (progress.cornerMarks ?? []).length ? progress.cornerMarks!.map(valuesToMask) : puzzle.givens.map(() => 0); session.centerMarks = (progress.centerMarks ?? progress.candidates ?? []) .length ? (progress.centerMarks ?? progress.candidates)!.map(valuesToMask) : puzzle.givens.map(() => 0); session.colors = progress.colors ? [...progress.colors] : puzzle.givens.map(() => 0); session.elapsedSeconds = Math.floor((progress.elapsedMs ?? 0) / 1_000); return session; } function progressFromSession( puzzle: PuzzleDefinition, session: PlaySession, completed: boolean, aidMemoire: AidMemoireState, gameplayHistory?: GameplayHistory, ): SudokuProgress { return { version: 1, values: [...session.values], cornerMarks: session.cornerMarks.map((mask) => maskValues(mask, puzzle.size), ), centerMarks: session.centerMarks.map((mask) => maskValues(mask, puzzle.size), ), colors: [...session.colors], elapsedMs: session.elapsedSeconds * 1_000, completed, aidMemoire: aidMemoireToPortable(aidMemoire, puzzle.size), ...(gameplayHistory === undefined ? {} : { gameplayHistory: serializeGameplayHistory( gameplayHistory, puzzle.size, ), }), }; } function safeNormalize(puzzle: PuzzleDefinition): { readonly puzzle: NormalizedPuzzle; readonly error?: string; } { try { return { puzzle: normalizePuzzle(puzzle) }; } catch (error) { const size = Number.isInteger(puzzle.size) ? puzzle.size : 9; const cellCount = size * size; const givens = puzzle.givens.length === cellCount ? [...puzzle.givens] : new Array(cellCount).fill(0); const regions = puzzle.regions?.length === cellCount ? [...puzzle.regions] : classicRegions(size); return { puzzle: { ...puzzle, size, givens, regions, constraints: puzzle.constraints ?? [], }, error: error instanceof Error ? error.message : "The puzzle definition is invalid.", }; } } function initialProject(): { readonly puzzle: PuzzleDefinition; readonly session: PlaySession; readonly aidMemoire: AidMemoireState; readonly preservedExtras?: PreservedSudokuDocumentExtras; } { if (typeof location !== "undefined" && location.hash.includes("sudoku=")) { try { const document = decodePuzzleHash(location.href); const puzzle = normalizePuzzle(toDomainPuzzle(document)); return { puzzle, session: sessionFromDocument(puzzle, document), aidMemoire: aidMemoireFromPortable(document.aidMemoire, puzzle.size), preservedExtras: extractPreservedDocumentExtras(document), }; } catch { // An invalid hash is non-fatal; the import dialog can report details. } } const puzzle = normalizePuzzle(CLASSIC_SAMPLE); return { puzzle, session: createSession(puzzle.givens), aidMemoire: createAidMemoire(puzzle.size), }; } function formatTime(seconds: number): string { const hours = Math.floor(seconds / 3_600); const minutes = Math.floor((seconds % 3_600) / 60); const rest = seconds % 60; return [hours, minutes, rest] .map((part) => String(part).padStart(2, "0")) .join(":"); } function cellLabel(cell: number, size: number): string { return `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`; } function cloneSample(sample: PuzzleDefinition): PuzzleDefinition { return normalizePuzzle(sample); } function downloadJson(filename: string, value: unknown): void { const url = URL.createObjectURL( new Blob([`${JSON.stringify(value, null, 2)}\n`], { type: "application/json", }), ); const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; anchor.click(); URL.revokeObjectURL(url); } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : "The operation failed."; } function useMediaQuery(query: string): boolean { const [matches, setMatches] = useState( () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(query).matches, ); useEffect(() => { if (typeof window.matchMedia !== "function") return undefined; const media = window.matchMedia(query); const update = (event: MediaQueryListEvent) => setMatches(event.matches); media.addEventListener("change", update); return () => media.removeEventListener("change", update); }, [query]); return matches; } function sameProjectContent( left: SudokuProjectRecord, right: SudokuProjectRecord, ): boolean { return ( JSON.stringify({ title: left.title, puzzle: left.puzzle, progress: left.progress, tags: left.tags ?? [], }) === JSON.stringify({ title: right.title, puzzle: right.puzzle, progress: right.progress, tags: right.tags ?? [], }) ); } export function Workbench() { const boot = useMemo(() => initialProject(), []); const [workspace, setWorkspace] = useState("play"); const [puzzle, setPuzzle] = useState(boot.puzzle); const [session, setSession] = useState(boot.session); const [preservedDocumentExtras, setPreservedDocumentExtras] = useState< PreservedSudokuDocumentExtras | undefined >(boot.preservedExtras); const [selection, setSelection] = useState([0]); const [activeCell, setActiveCell] = useState(0); const [entryMode, setEntryMode] = useState("value"); const [showCandidates, setShowCandidates] = useState(false); const [candidateVerbosity, setCandidateVerbosity] = useState("detailed"); const [showConflicts, setShowConflicts] = useState(true); const [showDigitCompletion, setShowDigitCompletion] = useState(true); const [enableDigitHighlight, setEnableDigitHighlight] = useState(true); const [tapMultiSelect, setTapMultiSelect] = useState(false); const [highlightedDigit, setHighlightedDigit] = useState(null); const [guidedHint, setGuidedHint] = useState(); const [guidedHintStage, setGuidedHintStage] = useState("focus"); const [guidedHintError, setGuidedHintError] = useState(); const [guidedCandidateTracking, setGuidedCandidateTracking] = useState(false); const [autoMaintainPeerNotes, setAutoMaintainPeerNotes] = useState(false); const [candidateOverlay, setCandidateOverlay] = useState(); const [aidMemoire, setAidMemoire] = useState( boot.aidMemoire, ); const [aidMemoireCell, setAidMemoireCell] = useState(0); const [aidMemoireActive, setAidMemoireActive] = useState(false); const [past, setPast] = useState([]); const [future, setFuture] = useState([]); const [gameplayHistory, setGameplayHistory] = useState(() => createGameplayHistory(boot.session, boot.aidMemoire), ); const [historyOpen, setHistoryOpen] = useState(false); const [replayMomentId, setReplayMomentId] = useState(); const [busy, setBusy] = useState(false); const [feedback, setFeedback] = useState(); const [logical, setLogical] = useState(); const [exact, setExact] = useState(); const [difficulty, setDifficulty] = useState(); const [quality, setQuality] = useState(); const [qualityRunning, setQualityRunning] = useState(); const [qualityError, setQualityError] = useState(); const [generation, setGeneration] = useState(); const [generationBatch, setGenerationBatch] = useState(); const [generationRunning, setGenerationRunning] = useState(false); const [solveError, setSolveError] = useState(); const [importOpen, setImportOpen] = useState(false); const [libraryOpen, setLibraryOpen] = useState(false); const [summaries, setSummaries] = useState( [], ); const [libraryMode, setLibraryMode] = useState("indexeddb"); const [libraryBusy, setLibraryBusy] = useState(false); const [libraryFeedback, setLibraryFeedback] = useState(); const [currentProjectId, setCurrentProjectId] = useState(); const [recoverableAutosave, setRecoverableAutosave] = useState(); const [autosaveChecked, setAutosaveChecked] = useState(false); const [autosaveStatus, setAutosaveStatus] = useState("checking"); const [autosaveMessage, setAutosaveMessage] = useState( "Checking for recoverable local work…", ); const workerRef = useRef(null); const qualityAbortRef = useRef(null); const generationAbortRef = useRef(null); const draggingRef = useRef(false); const workbenchRef = useRef(null); const replayReturnSessionRef = useRef(null); const replayReturnAidMemoireRef = useRef(null); const latestAutosaveStateRef = useRef({ puzzle, session, aidMemoire, gameplayHistory, preservedDocumentExtras, solved: false, currentProjectId, }); const mobileLayout = useMediaQuery("(max-width: 48rem)"); const normalized = useMemo(() => safeNormalize(puzzle), [puzzle]); const normalizedSourceVisuals = useMemo( () => normalizeSafeVisualPrimitives( preservedDocumentExtras?.visuals, puzzle.size, ) ?? [], [preservedDocumentExtras?.visuals, puzzle.size], ); const replayMoment = useMemo( () => replayMomentId === undefined ? undefined : gameplayMoment(gameplayHistory, replayMomentId), [gameplayHistory, replayMomentId], ); const currentHypothesis = useMemo( () => activeHypothesis(gameplayHistory), [gameplayHistory], ); const foggedCells = useMemo( () => foggedCellsForPuzzle(normalized.puzzle, session.values), [normalized.puzzle, session.values], ); const effectiveSelection = useMemo(() => { const visible = selection.filter((cell) => !foggedCells.has(cell)); if (visible.length > 0) return visible; const firstVisible = session.values.findIndex((_, cell) => foggedCells.has(cell) ? false : true, ); return firstVisible < 0 ? [] : [firstVisible]; }, [foggedCells, selection, session.values]); const effectiveActiveCell = foggedCells.has(activeCell) ? (effectiveSelection[0] ?? activeCell) : activeCell; const selectedSet = useMemo( () => new Set(effectiveSelection), [effectiveSelection], ); const boardSelectedSet = useMemo( () => (aidMemoireActive ? new Set() : selectedSet), [aidMemoireActive, selectedSet], ); const handleCandidateOverlayChange = useCallback( (overlay: CandidateOverlay | undefined) => setCandidateOverlay(overlay), [], ); const completionData = useMemo( () => digitCompletions(puzzle.size, session.values), [puzzle.size, session.values], ); const highlightedCells = useMemo( () => highlightedDigit === null ? new Set() : new Set( matchingDigitCells(puzzle.size, session.values, highlightedDigit), ), [highlightedDigit, puzzle.size, session.values], ); const candidateMasks = useMemo(() => { if (normalized.error) return session.values.map(() => 0); try { return allCandidates(normalized.puzzle, session.values).map((values) => valuesToMask(values), ); } catch { return session.values.map(() => 0); } }, [normalized, session.values]); const visibleGuidedHint = guidedHint !== undefined && guidedHintStepIsVisible(guidedHint, foggedCells) ? guidedHint : undefined; const guidedBoardOverlay = useMemo(() => { if (visibleGuidedHint === undefined) return undefined; const overlay = guidedHintOverlay(visibleGuidedHint, guidedHintStage); return { focusCells: overlay.focusCells, placements: guidedHintStage === "preview" ? visibleGuidedHint.placements : undefined, eliminations: guidedHintStage === "preview" ? visibleGuidedHint.eliminations : undefined, }; }, [guidedHintStage, visibleGuidedHint]); const conflictData = useMemo(() => { if (normalized.error) return []; try { return findConflicts(normalized.puzzle, session.values); } catch { return []; } }, [normalized, session.values]); const conflictCells = useMemo( () => showConflicts ? new Set(conflictData.flatMap((conflict) => conflict.cells)) : new Set(), [conflictData, showConflicts], ); const solved = useMemo(() => { if (normalized.error) return false; try { return isSolved(normalized.puzzle, session.values); } catch { return false; } }, [normalized, session.values]); useEffect(() => { latestAutosaveStateRef.current = { puzzle, session, aidMemoire, gameplayHistory, preservedDocumentExtras, solved, currentProjectId, }; }, [ aidMemoire, currentProjectId, gameplayHistory, preservedDocumentExtras, puzzle, session, solved, ]); const clearAnalysis = useCallback(() => { setLogical(undefined); setExact(undefined); setDifficulty(undefined); setGeneration(undefined); setGenerationBatch(undefined); setSolveError(undefined); setGuidedHint(undefined); setGuidedHintStage("focus"); setGuidedHintError(undefined); qualityAbortRef.current?.abort(); qualityAbortRef.current = null; setQuality(undefined); setQualityRunning(undefined); setQualityError(undefined); }, []); const currentHistory = useCallback( (): HistoryEntry => ({ puzzle, session: snapshotSession(session), aidMemoire: cloneAidMemoire(aidMemoire), guidedCandidateTracking, }), [aidMemoire, guidedCandidateTracking, puzzle, session], ); const commit = useCallback( (nextPuzzle: PuzzleDefinition, nextSession: PlaySession): void => { setPast((entries) => [...entries, currentHistory()].slice(-MAX_HISTORY)); setFuture([]); setGameplayHistory((current) => workspace === "play" && nextPuzzle === puzzle ? recordGameplayMoment( current, nextSession, describeGameplayChange(session, nextSession, puzzle.size), aidMemoire, ) : createGameplayHistory(nextSession, aidMemoire), ); replayReturnSessionRef.current = null; replayReturnAidMemoireRef.current = null; setReplayMomentId(undefined); setPuzzle(nextPuzzle); setSession(nextSession); if (nextPuzzle !== puzzle) setGuidedCandidateTracking(false); clearAnalysis(); setFeedback(undefined); }, [aidMemoire, clearAnalysis, currentHistory, puzzle, session, workspace], ); const loadPuzzle = useCallback( ( nextPuzzle: PuzzleDefinition, progress?: Pick< SudokuDocument, | "values" | "cornerMarks" | "centerMarks" | "candidates" | "colors" | "elapsedMs" | "aidMemoire" >, projectId?: string, preservedExtras?: PreservedSudokuDocumentExtras, ): void => { const valid = normalizePuzzle(nextPuzzle); const nextSession = sessionFromDocument(valid, progress); const nextAidMemoire = aidMemoireFromPortable( progress?.aidMemoire, valid.size, ); setPuzzle(valid); setSession(nextSession); setPreservedDocumentExtras(preservedExtras); setAidMemoire(nextAidMemoire); setAidMemoireCell(0); setAidMemoireActive(false); setGameplayHistory(createGameplayHistory(nextSession, nextAidMemoire)); replayReturnSessionRef.current = null; replayReturnAidMemoireRef.current = null; setReplayMomentId(undefined); setHistoryOpen(false); setSelection([0]); setActiveCell(0); setHighlightedDigit(null); setGuidedCandidateTracking(false); setPast([]); setFuture([]); setCurrentProjectId(projectId); clearAnalysis(); setFeedback({ kind: "success", message: "Puzzle loaded locally." }); }, [clearAnalysis], ); const restoreProject = useCallback( (record: SudokuProjectRecord): void => { const valid = normalizePuzzle(toDomainPuzzle(record.puzzle)); const nextSession = sessionFromProgress(valid, record.progress); const nextAidMemoire = aidMemoireFromPortable( record.progress?.aidMemoire ?? record.puzzle.aidMemoire, valid.size, ); setPuzzle(valid); setSession(nextSession); setPreservedDocumentExtras(extractPreservedDocumentExtras(record.puzzle)); setAidMemoire(nextAidMemoire); setAidMemoireCell(0); setAidMemoireActive(false); setGameplayHistory( record.progress?.gameplayHistory === undefined ? createGameplayHistory(nextSession, nextAidMemoire) : parseGameplayHistory(record.progress.gameplayHistory, valid.size), ); replayReturnSessionRef.current = null; replayReturnAidMemoireRef.current = null; setReplayMomentId(undefined); setHistoryOpen(false); setSelection([0]); setActiveCell(0); setHighlightedDigit(null); setGuidedCandidateTracking(false); setPast([]); setFuture([]); setCurrentProjectId( record.id === AUTOSAVE_DRAFT_ID ? undefined : record.id, ); clearAnalysis(); setFeedback({ kind: "success", message: `Opened “${record.title || "Untitled puzzle"}”.`, }); }, [clearAnalysis], ); const refreshLibrary = useCallback(async (): Promise => { setLibraryMode(await library.ready()); setSummaries(await library.list()); }, []); useEffect(() => { let cancelled = false; void (async () => { try { setLibraryMode(await library.ready()); const autosave = await library.getAutosave(); const explicit = autosave !== undefined && autosave.id !== AUTOSAVE_DRAFT_ID ? await library.get(autosave.id) : undefined; const recovered = autosave !== undefined && explicit !== undefined && sameProjectContent(autosave, explicit) ? undefined : autosave; if (cancelled) return; setRecoverableAutosave(recovered); setAutosaveStatus(recovered === undefined ? "idle" : "saved"); setAutosaveMessage( recovered === undefined ? "Autosave is ready." : "Recoverable local work is available.", ); } catch (error) { if (cancelled) return; setAutosaveStatus("error"); setAutosaveMessage(`Autosave unavailable: ${errorMessage(error)}`); } finally { if (!cancelled) setAutosaveChecked(true); } })(); return () => { cancelled = true; }; }, []); const autosaveElapsedBucket = Math.floor(session.elapsedSeconds / 30); useEffect(() => { if ( !autosaveChecked || recoverableAutosave !== undefined || replayMomentId !== undefined ) { return; } let cancelled = false; const timer = window.setTimeout(() => { setAutosaveStatus("saving"); setAutosaveMessage("Saving work locally…"); void (async () => { try { const latest = latestAutosaveStateRef.current; const now = Date.now(); const document = fromDomainPuzzle( latest.puzzle, latest.preservedDocumentExtras, ); const progress = progressFromSession( latest.puzzle, latest.session, latest.solved, latest.aidMemoire, latest.gameplayHistory, ); const existing = latest.currentProjectId ? await library.get(latest.currentProjectId) : undefined; const record = existing === undefined ? createProjectRecord(document, { id: latest.currentProjectId ?? AUTOSAVE_DRAFT_ID, title: latest.puzzle.title?.trim() || "Untitled puzzle", progress, now, }) : { ...existing, title: latest.puzzle.title?.trim() || "Untitled puzzle", updatedAt: now, puzzle: document, progress, thumbnail: undefined, }; await library.putAutosave(record); if (cancelled) return; setAutosaveStatus("saved"); setAutosaveMessage( `Saved locally at ${new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit", }).format(now)}.`, ); } catch (error) { if (cancelled) return; setAutosaveStatus("error"); setAutosaveMessage(`Autosave failed: ${errorMessage(error)}`); } })(); }, AUTOSAVE_DELAY_MS); return () => { cancelled = true; window.clearTimeout(timer); }; }, [ aidMemoire, autosaveChecked, autosaveElapsedBucket, currentProjectId, gameplayHistory, preservedDocumentExtras, puzzle, recoverableAutosave, replayMomentId, session.centerMarks, session.colors, session.cornerMarks, session.paused, session.values, solved, ]); useEffect(() => { workerRef.current = createSolverWorkerClient(); return () => { qualityAbortRef.current?.abort(); qualityAbortRef.current = null; workerRef.current?.terminate(); workerRef.current = null; }; }, []); useEffect(() => { const stopDragging = () => { draggingRef.current = false; }; window.addEventListener("pointerup", stopDragging); window.addEventListener("pointercancel", stopDragging); return () => { window.removeEventListener("pointerup", stopDragging); window.removeEventListener("pointercancel", stopDragging); }; }, []); useEffect(() => { if (workspace !== "play" || session.paused || solved) return; const timer = window.setInterval( () => setSession((current) => ({ ...current, elapsedSeconds: current.elapsedSeconds + 1, })), 1_000, ); return () => window.clearInterval(timer); }, [session.paused, solved, workspace]); const undo = useCallback(() => { const target = past.at(-1); if (target === undefined) return; const restored = restoreSnapshot(session, target.session); setFuture((entries) => [currentHistory(), ...entries].slice(0, MAX_HISTORY), ); setPast(past.slice(0, -1)); setPuzzle(target.puzzle); setSession(restored); setAidMemoire(cloneAidMemoire(target.aidMemoire)); setGuidedCandidateTracking(target.guidedCandidateTracking); setAidMemoireActive(false); setGameplayHistory((current) => workspace === "play" && target.puzzle === puzzle ? recordGameplayMoment(current, restored, "Undo", target.aidMemoire) : createGameplayHistory(restored, target.aidMemoire), ); replayReturnSessionRef.current = null; replayReturnAidMemoireRef.current = null; setReplayMomentId(undefined); clearAnalysis(); }, [clearAnalysis, currentHistory, past, puzzle, session, workspace]); const redo = useCallback(() => { const target = future[0]; if (target === undefined) return; const restored = restoreSnapshot(session, target.session); setPast((entries) => [...entries, currentHistory()].slice(-MAX_HISTORY)); setFuture(future.slice(1)); setPuzzle(target.puzzle); setSession(restored); setAidMemoire(cloneAidMemoire(target.aidMemoire)); setGuidedCandidateTracking(target.guidedCandidateTracking); setAidMemoireActive(false); setGameplayHistory((current) => workspace === "play" && target.puzzle === puzzle ? recordGameplayMoment(current, restored, "Redo", target.aidMemoire) : createGameplayHistory(restored, target.aidMemoire), ); replayReturnSessionRef.current = null; replayReturnAidMemoireRef.current = null; setReplayMomentId(undefined); clearAnalysis(); }, [clearAnalysis, currentHistory, future, puzzle, session, workspace]); const changeSelection = useCallback( (cell: number, additive: boolean, toggle: boolean): void => { setActiveCell(cell); setSelection((current) => { if (!additive) return [cell]; if (toggle && current.includes(cell)) { const remaining = current.filter((item) => item !== cell); return remaining.length ? remaining : [cell]; } return current.includes(cell) ? current : [...current, cell]; }); }, [], ); const handlePointerDown = useCallback( (cell: number, event: PointerEvent) => { if (session.paused) return; setAidMemoireActive(false); event.preventDefault(); event.currentTarget.focus(); const command = event.ctrlKey || event.metaKey; if ( workspace === "play" && enableDigitHighlight && command && session.values[cell] ) { draggingRef.current = false; changeSelection(cell, false, false); setHighlightedDigit((current) => toggledDigitHighlight(puzzle.size, session.values, cell, current), ); return; } if (!command) setHighlightedDigit(null); if (tapMultiSelect) { draggingRef.current = false; changeSelection(cell, true, true); return; } draggingRef.current = true; const additive = event.shiftKey || command; changeSelection(cell, additive, command); }, [ changeSelection, enableDigitHighlight, puzzle.size, session.paused, session.values, tapMultiSelect, workspace, ], ); const handlePointerEnter = useCallback( (cell: number, event: PointerEvent) => { if (session.paused || !draggingRef.current || (event.buttons & 1) === 0) return; changeSelection(cell, true, false); }, [changeSelection, session.paused], ); const updateAidMemoire = useCallback( (next: AidMemoireState, label = "Aid-mémoire updated") => { if (next === aidMemoire) return; setPast((entries) => [...entries, currentHistory()].slice(-MAX_HISTORY)); setFuture([]); setAidMemoire(next); setGameplayHistory((current) => recordGameplayMoment(current, session, label, next), ); if (!next.enabled) setAidMemoireActive(false); setFeedback(undefined); }, [aidMemoire, currentHistory, session], ); const enterValue = useCallback( (value: number, target: "auto" | "board" = "auto"): void => { if (session.paused) return; if ( target === "auto" && workspace === "play" && aidMemoire.enabled && aidMemoireActive ) { updateAidMemoire( enterAidMemoireCell( aidMemoire, aidMemoireCell, entryMode, value, puzzle.size, ), `Updated aid-mémoire cell ${String(aidMemoireCell + 1)}`, ); return; } if (workspace === "set" && entryMode === "value") { const givens = [...puzzle.givens]; const values = [...session.values]; const cornerMarks = [...session.cornerMarks]; const centerMarks = [...session.centerMarks]; for (const cell of selectedSet) { const next = givens[cell] === value ? 0 : value; givens[cell] = next; values[cell] = next; cornerMarks[cell] = 0; centerMarks[cell] = 0; } commit( { ...puzzle, givens }, { ...session, values, cornerMarks, centerMarks }, ); setGuidedCandidateTracking(false); return; } let nextSession = enterSelection( session, selectedSet, entryMode, value, puzzle.givens, ); if (entryMode === "value" && autoMaintainPeerNotes) { nextSession = autoRemoveNotesAfterPlacements( session, nextSession, normalized.puzzle, ); } commit(puzzle, nextSession); if ( entryMode === "center" || (entryMode === "value" && nextSession.values.some( (nextValue, cell) => (session.values[cell] ?? 0) !== 0 && nextValue === 0, )) ) { setGuidedCandidateTracking(false); } }, [ aidMemoire, aidMemoireActive, aidMemoireCell, autoMaintainPeerNotes, commit, entryMode, normalized.puzzle, puzzle, selectedSet, session, updateAidMemoire, workspace, ], ); const erase = useCallback( (target: "auto" | "board" = "auto"): void => { if (session.paused) return; if ( target === "auto" && workspace === "play" && aidMemoire.enabled && aidMemoireActive ) { updateAidMemoire( eraseAidMemoireCell(aidMemoire, aidMemoireCell, entryMode), `Erased aid-mémoire cell ${String(aidMemoireCell + 1)}`, ); return; } if (workspace === "set" && entryMode === "value") { const givens = [...puzzle.givens]; const values = [...session.values]; for (const cell of selectedSet) { givens[cell] = 0; values[cell] = 0; } commit({ ...puzzle, givens }, { ...session, values }); setGuidedCandidateTracking(false); return; } commit( puzzle, eraseSelection(session, selectedSet, entryMode, puzzle.givens), ); if (entryMode === "value" || entryMode === "center") { setGuidedCandidateTracking(false); } }, [ aidMemoire, aidMemoireActive, aidMemoireCell, commit, entryMode, puzzle, selectedSet, session, updateAidMemoire, workspace, ], ); const moveActive = useCallback( (key: string, extend: boolean, command: boolean): void => { const next = navigateGridCell( effectiveActiveCell, puzzle.size, key, command, ); if (next === null) return; changeSelection(next, extend, false); requestAnimationFrame(() => { workbenchRef.current ?.querySelector(`[data-cell="${String(next)}"]`) ?.focus(); }); }, [changeSelection, effectiveActiveCell, puzzle.size], ); const handleKeyDown = useCallback( (event: KeyboardEvent): void => { setAidMemoireActive(false); const command = event.ctrlKey || event.metaKey; if (command && event.key.toLowerCase() === "z") { event.preventDefault(); if (event.shiftKey) redo(); else undo(); return; } if (command && event.key.toLowerCase() === "y") { event.preventDefault(); redo(); return; } if (command && event.key.toLowerCase() === "a") { event.preventDefault(); const all = Array.from( { length: puzzle.size * puzzle.size }, (_, cell) => cell, ); setSelection( event.shiftKey ? all.filter((cell) => !selectedSet.has(cell)) : all, ); return; } if (!command && event.key.toLowerCase() === "m") { event.preventDefault(); setTapMultiSelect((current) => !current); return; } if ( [ "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown", ].includes(event.key) ) { event.preventDefault(); moveActive(event.key, event.shiftKey, command); return; } if ( event.key === "Backspace" || event.key === "Delete" || event.key === "0" ) { event.preventDefault(); erase("board"); return; } if (!command) { const modes: Record = { z: "value", x: "corner", c: "center", v: "color", }; const nextMode = modes[event.key.toLowerCase()]; if (nextMode !== undefined) { event.preventDefault(); setEntryMode(nextMode); return; } const value = valueForKey(event.key, puzzle.size); if (value !== null) { event.preventDefault(); enterValue(value, "board"); } } }, [enterValue, erase, moveActive, puzzle.size, redo, selectedSet, undo], ); const runLogical = useCallback(async () => { if (normalized.error || workerRef.current === null) { setSolveError(normalized.error ?? "Solver worker is not ready yet."); return; } setBusy(true); setSolveError(undefined); try { const result = (await workerRef.current.request( { kind: "logical", puzzle: normalized.puzzle, options: { values: session.values }, }, { timeoutMs: 60_000 }, )) as LogicalSolveResult; setLogical(result); } catch (error) { setSolveError(errorMessage(error)); } finally { setBusy(false); } }, [normalized, session.values]); const runExact = useCallback(async () => { if (normalized.error || workerRef.current === null) { setSolveError(normalized.error ?? "Solver worker is not ready yet."); return; } setBusy(true); setSolveError(undefined); try { const result = (await workerRef.current.request( { kind: "solve", puzzle: normalized.puzzle, options: { values: session.values, maxSolutions: 2, maxNodes: 5_000_000, timeoutMs: 30_000, }, }, { timeoutMs: 40_000 }, )) as ExactSolveResult; setExact(result); } catch (error) { setSolveError(errorMessage(error)); } finally { setBusy(false); } }, [normalized, session.values]); const requestHint = useCallback(async () => { if (normalized.error || workerRef.current === null) { setFeedback({ kind: "error", message: normalized.error ?? "Solver worker is not ready yet.", }); return; } setBusy(true); setGuidedHintError(undefined); try { const result = (await workerRef.current.request( { kind: "logical", puzzle: normalized.puzzle, options: { values: session.values, maxSteps: foggedCells.size > 0 ? 32 : 1, ...(guidedCandidateTracking ? { candidates: session.centerMarks.map((mask) => maskValues(mask, puzzle.size), ), } : {}), }, }, { timeoutMs: 15_000 }, )) as LogicalSolveResult; const step = result.steps.find((candidate) => guidedHintStepIsVisible(candidate, foggedCells), ); if (step === undefined) { setGuidedHint(undefined); setGuidedHintStage("focus"); setFeedback({ kind: result.status === "solved" ? "success" : "info", message: result.steps.length > 0 && foggedCells.size > 0 ? "No currently visible logical hint was found. Reveal more of the fog by solving a visible cell." : result.status === "solved" ? "The grid is already solved." : "No supported logical next step was found.", }); } else { setGuidedHint(step); setGuidedHintStage("focus"); setFeedback(undefined); } } catch (error) { setGuidedHintError(errorMessage(error)); } finally { setBusy(false); } }, [foggedCells, guidedCandidateTracking, normalized, puzzle.size, session]); const revealNextHintStage = useCallback(() => { setGuidedHintStage((current) => nextGuidedHintStage(current) ?? current); }, []); const applyGuidedHint = useCallback(() => { if (guidedHint === undefined || normalized.error) return; if (!guidedHintStepIsVisible(guidedHint, foggedCells)) { setGuidedHint(undefined); setGuidedHintStage("focus"); setFeedback({ kind: "info", message: "That hint is no longer visible under the fog. Request another hint.", }); return; } try { const startsTracking = !guidedCandidateTracking && guidedHint.eliminations.length > 0; const baseSession = startsTracking ? fillLegalCenterCandidates(session, normalized.puzzle) : session; const nextSession = applyLogicalStepToSession( baseSession, guidedHint, normalized.puzzle, ); commit(puzzle, nextSession); if (startsTracking) setGuidedCandidateTracking(true); setFeedback({ kind: "success", message: `Applied ${guidedHint.technique.replaceAll("-", " ")} as one undoable step${startsTracking ? " and started a complete candidate grid" : ""}.`, }); } catch (error) { setGuidedHintError(errorMessage(error)); } }, [ commit, foggedCells, guidedCandidateTracking, guidedHint, normalized, puzzle, session, ]); const fillGuidedCandidates = useCallback(() => { if (normalized.error) return; try { const nextSession = fillLegalCenterCandidates(session, normalized.puzzle); commit(puzzle, nextSession); setGuidedCandidateTracking(true); setFeedback({ kind: "success", message: "Filled every empty cell with its currently legal centre candidates. Undo restores your previous notes.", }); } catch (error) { setFeedback({ kind: "error", message: errorMessage(error) }); } }, [commit, normalized, puzzle, session]); const removeInvalidCandidateNotes = useCallback(() => { if (normalized.error) return; try { const nextSession = pruneInvalidNotes(session, normalized.puzzle); const changed = nextSession.cornerMarks.some( (mask, cell) => mask !== session.cornerMarks[cell], ) || nextSession.centerMarks.some( (mask, cell) => mask !== session.centerMarks[cell], ); if (!changed) { setFeedback({ kind: "info", message: "All notes are already legal." }); return; } commit(puzzle, nextSession); setFeedback({ kind: "success", message: "Removed currently impossible corner and centre notes.", }); } catch (error) { setFeedback({ kind: "error", message: errorMessage(error) }); } }, [commit, normalized, puzzle, session]); const checkDefinition = useCallback(async () => { const validation = validatePuzzle(puzzle); if (!validation.valid) { setFeedback({ kind: "error", message: validation.issues .slice(0, 4) .map((issue) => `${issue.path}: ${issue.message}`) .join(" · "), }); return; } if (workerRef.current === null) return; setBusy(true); try { const result = (await workerRef.current.request( { kind: "solve", puzzle, options: { maxSolutions: 2, maxNodes: 5_000_000, timeoutMs: 30_000, }, }, { timeoutMs: 40_000 }, )) as ExactSolveResult; setFeedback({ kind: result.count === 1 && !result.truncated ? "success" : result.count >= 2 || (result.count === 0 && !result.truncated) ? "error" : "info", message: result.count >= 2 ? "The definition has multiple solutions." : result.truncated ? result.count === 0 ? "Search reached a safety limit before finding a solution; solvability is still unknown." : "A solution was found, but uniqueness was not established before a safety limit." : result.count === 0 ? "The definition has no solution." : `The definition is valid and uniquely solvable (${result.nodes.toLocaleString()} search nodes).`, }); } catch (error) { setFeedback({ kind: "error", message: errorMessage(error) }); } finally { setBusy(false); } }, [puzzle]); const runQualityAudit = useCallback( async (kind: SetterQualityRunKind, options: PuzzleQualityOptions) => { if (workerRef.current === null) { setQualityError("Solver worker is not ready yet."); return; } qualityAbortRef.current?.abort(); const controller = new AbortController(); qualityAbortRef.current = controller; setBusy(true); setQualityRunning(kind); setQualityError(undefined); try { const result = (await workerRef.current.request( { kind: "quality", puzzle, options }, { signal: controller.signal, timeoutMs: Math.min( 300_000, (options.aggregateTimeoutMs ?? 30_000) + 15_000, ), }, )) as PuzzleQualityAnalysis; if (!controller.signal.aborted) setQuality(result); } catch (error) { if (!controller.signal.aborted) setQualityError(errorMessage(error)); } finally { if (qualityAbortRef.current === controller) { qualityAbortRef.current = null; setQualityRunning(undefined); setBusy(false); } } }, [puzzle], ); const cancelQualityAudit = useCallback(() => { qualityAbortRef.current?.abort(); qualityAbortRef.current = null; setQualityRunning(undefined); setBusy(false); setQualityError(undefined); setFeedback({ kind: "info", message: "Setter-quality analysis cancelled; the previous result remains visible.", }); }, []); const generateConfigured = useCallback( async (options: GenerateVariantOptions) => { if (workerRef.current === null) return; generationAbortRef.current?.abort(); const controller = new AbortController(); generationAbortRef.current = controller; setGenerationRunning(true); setBusy(true); setFeedback({ kind: "info", message: "Generating and rating a uniquely checked puzzle locally. The current board stays available until it is ready…", }); try { const generated = (await workerRef.current.request( { kind: "generate-variant", options }, { timeoutMs: 240_000, signal: controller.signal }, )) as GeneratedVariantPuzzle; if (controller.signal.aborted) return; loadPuzzle(generated.puzzle); setGeneration(generated); setGenerationBatch(undefined); setDifficulty(generated.difficulty); setWorkspace("generate"); setFeedback({ kind: "success", message: `Generated a unique ${generated.variant} puzzle${generated.requestedTechnique === undefined ? "" : ` featuring ${generated.requestedTechnique.replaceAll("-", " ")}`}. Rated ${generated.difficulty.label}${generated.difficulty.score === null ? "" : ` (${String(generated.difficulty.score)}/100)`}.`, }); } catch (error) { if (!controller.signal.aborted) { setFeedback({ kind: "error", message: errorMessage(error) }); } } finally { if (generationAbortRef.current === controller) { generationAbortRef.current = null; setGenerationRunning(false); setBusy(false); } } }, [loadPuzzle], ); const generateBatchConfigured = useCallback( async (options: GenerateVariantBatchOptions) => { if (workerRef.current === null) return; generationAbortRef.current?.abort(); const controller = new AbortController(); generationAbortRef.current = controller; setGenerationRunning(true); setBusy(true); setFeedback({ kind: "info", message: "Generating, independently checking and ranking a bounded local batch. The current board stays available until a result is ready…", }); try { const batch = (await workerRef.current.request( { kind: "generate-batch", options }, { timeoutMs: 300_000, signal: controller.signal }, )) as GeneratedVariantBatch; if (controller.signal.aborted) return; const best = batch.entries[0]; if (best !== undefined) { loadPuzzle(best.puzzle); setGeneration(best); setDifficulty(best.difficulty); } setGenerationBatch(batch); setWorkspace("generate"); setFeedback({ kind: batch.completed > 0 ? "success" : "error", message: batch.completed > 0 ? `Generated and ranked ${String(batch.completed)} of ${String(batch.requested)} bounded candidates; the top result is open.` : "No batch candidate completed every bounded verification.", }); } catch (error) { if (!controller.signal.aborted) { setFeedback({ kind: "error", message: errorMessage(error) }); } } finally { if (generationAbortRef.current === controller) { generationAbortRef.current = null; setGenerationRunning(false); setBusy(false); } } }, [loadPuzzle], ); const selectGeneratedCandidate = useCallback( (generated: GeneratedVariantPuzzle) => { const batch = generationBatch; loadPuzzle(generated.puzzle); setGeneration(generated); setDifficulty(generated.difficulty); setGenerationBatch(batch); setWorkspace("generate"); setFeedback({ kind: "success", message: `Opened ranked candidate ${String(generated.seed)}.`, }); }, [generationBatch, loadPuzzle], ); const cancelGeneration = useCallback(() => { generationAbortRef.current?.abort(); generationAbortRef.current = null; setGenerationRunning(false); setBusy(false); setFeedback({ kind: "info", message: "Generation cancelled; the current puzzle was left unchanged.", }); }, []); const rateCurrentPuzzle = useCallback(async () => { if (workerRef.current === null) return; if (normalized.error) { setFeedback({ kind: "error", message: normalized.error }); return; } setBusy(true); setFeedback({ kind: "info", message: "Checking uniqueness and rating the current puzzle locally…", }); try { const assessment = (await workerRef.current.request( { kind: "difficulty", puzzle: normalized.puzzle }, { timeoutMs: 60_000 }, )) as DifficultyAssessment; setGeneration(undefined); setGenerationBatch(undefined); setDifficulty(assessment); setFeedback({ kind: assessment.uniqueness === "unique" ? "success" : "info", message: assessment.summary, }); } catch (error) { setFeedback({ kind: "error", message: errorMessage(error) }); } finally { setBusy(false); } }, [normalized]); const checkBoard = useCallback(() => { if (normalized.error) { setFeedback({ kind: "error", message: normalized.error }); } else if (conflictData.length > 0) { setFeedback({ kind: "error", message: conflictData[0]!.message }); } else if (solved) { setFeedback({ kind: "success", message: `Solved in ${formatTime(session.elapsedSeconds)}.`, }); } else { const remaining = session.values.filter((value) => value === 0).length; setFeedback({ kind: "info", message: `No conflicts found. ${String(remaining)} cell${remaining === 1 ? " remains" : "s remain"}.`, }); } }, [ conflictData, normalized.error, session.elapsedSeconds, session.values, solved, ]); const saveCurrent = useCallback(async () => { setLibraryBusy(true); setLibraryFeedback(undefined); try { const now = Date.now(); const document = fromDomainPuzzle(puzzle, preservedDocumentExtras); const progress = progressFromSession( puzzle, session, solved, aidMemoire, gameplayHistory, ); let record: SudokuProjectRecord; const existing = currentProjectId ? await library.get(currentProjectId) : undefined; if (existing !== undefined) { record = { ...existing, title: puzzle.title?.trim() || "Untitled puzzle", updatedAt: now, puzzle: document, progress, thumbnail: undefined, }; } else { record = createProjectRecord(document, { title: puzzle.title?.trim() || "Untitled puzzle", progress, now, }); } const saved = await library.put(record); setCurrentProjectId(saved.id); await refreshLibrary(); setLibraryFeedback("Current puzzle and progress saved locally."); } catch (error) { setLibraryFeedback(errorMessage(error)); } finally { setLibraryBusy(false); } }, [ aidMemoire, currentProjectId, gameplayHistory, preservedDocumentExtras, puzzle, refreshLibrary, session, solved, ]); const openProject = useCallback( async (id: string) => { setLibraryBusy(true); try { const record = await library.get(id); if (record === undefined) throw new Error("The saved puzzle no longer exists."); restoreProject(record); setLibraryOpen(false); } catch (error) { setLibraryFeedback(errorMessage(error)); } finally { setLibraryBusy(false); } }, [restoreProject], ); const deleteProject = useCallback( async (id: string) => { if (!window.confirm("Delete this saved puzzle from this browser?")) return; setLibraryBusy(true); try { await library.delete(id); if (currentProjectId === id) setCurrentProjectId(undefined); await refreshLibrary(); setLibraryFeedback("Saved puzzle deleted."); } catch (error) { setLibraryFeedback(errorMessage(error)); } finally { setLibraryBusy(false); } }, [currentProjectId, refreshLibrary], ); const clearLibrary = useCallback(async () => { if ( !window.confirm( "Clear every saved Sudoku project from this browser? Export first if you need a backup.", ) ) return; setLibraryBusy(true); try { await library.clear(); setCurrentProjectId(undefined); await refreshLibrary(); setLibraryFeedback("Local library cleared."); } catch (error) { setLibraryFeedback(errorMessage(error)); } finally { setLibraryBusy(false); } }, [refreshLibrary]); const exportLibrary = useCallback(async () => { setLibraryBusy(true); try { downloadJson("sudoku-tools-library.json", await library.exportAll()); setLibraryFeedback("Library backup downloaded."); } catch (error) { setLibraryFeedback(errorMessage(error)); } finally { setLibraryBusy(false); } }, []); const exportSelectedProjects = useCallback(async (ids: readonly string[]) => { setLibraryBusy(true); try { const exported = await library.exportSelected(ids); if (exported.projects.length === 0) { throw new Error("Select at least one saved project to export."); } downloadJson("sudoku-tools-selection.json", exported); setLibraryFeedback( `${String(exported.projects.length)} selected project${exported.projects.length === 1 ? "" : "s"} exported.`, ); } catch (error) { setLibraryFeedback(errorMessage(error)); } finally { setLibraryBusy(false); } }, []); const duplicateSelectedProjects = useCallback( async (ids: readonly string[]) => { setLibraryBusy(true); try { const count = await library.duplicateSelected(ids); await refreshLibrary(); setLibraryFeedback( `${String(count)} project${count === 1 ? "" : "s"} copied locally.`, ); } catch (error) { setLibraryFeedback(errorMessage(error)); } finally { setLibraryBusy(false); } }, [refreshLibrary], ); const deleteSelectedProjects = useCallback( async (ids: readonly string[]) => { if ( ids.length === 0 || !window.confirm( `Delete ${String(ids.length)} selected saved project${ids.length === 1 ? "" : "s"} from this browser?`, ) ) { return; } setLibraryBusy(true); try { for (const id of ids) await library.delete(id); if (currentProjectId && ids.includes(currentProjectId)) { setCurrentProjectId(undefined); } await refreshLibrary(); setLibraryFeedback("Selected projects deleted."); } catch (error) { setLibraryFeedback(errorMessage(error)); } finally { setLibraryBusy(false); } }, [currentProjectId, refreshLibrary], ); const updateProjectTags = useCallback( async (id: string, tags: readonly string[]) => { setLibraryBusy(true); try { const record = await library.get(id); if (record === undefined) { throw new Error("The saved puzzle no longer exists."); } await library.put({ ...record, tags, updatedAt: Date.now() }); await refreshLibrary(); setLibraryFeedback("Project tags updated."); } catch (error) { setLibraryFeedback(errorMessage(error)); } finally { setLibraryBusy(false); } }, [refreshLibrary], ); const importLibrary = useCallback( async (file: File) => { setLibraryBusy(true); try { if (file.size > 64 * 1_048_576) throw new Error("The library file exceeds the 64 MiB import limit."); const count = await library.importAll( JSON.parse(await file.text()) as unknown, ); await refreshLibrary(); setLibraryFeedback( `${String(count)} project${count === 1 ? "" : "s"} imported.`, ); } catch (error) { setLibraryFeedback(errorMessage(error)); } finally { setLibraryBusy(false); } }, [refreshLibrary], ); const restoreAutosave = useCallback(() => { if (recoverableAutosave === undefined) return; try { restoreProject(recoverableAutosave); setRecoverableAutosave(undefined); setAutosaveStatus("saved"); setAutosaveMessage("Recovered local work; autosave is active."); } catch (error) { setAutosaveStatus("error"); setAutosaveMessage(`Recovery failed: ${errorMessage(error)}`); } }, [recoverableAutosave, restoreProject]); const discardAutosave = useCallback(async () => { try { await library.clearAutosave(); setRecoverableAutosave(undefined); setAutosaveStatus("idle"); setAutosaveMessage( "Previous recovery data discarded; autosave is active.", ); } catch (error) { setAutosaveStatus("error"); setAutosaveMessage( `Could not discard recovery data: ${errorMessage(error)}`, ); } }, []); const applyValues = useCallback( (values: readonly number[]) => { commit(puzzle, { ...session, values: [...values] }); setGuidedCandidateTracking(false); }, [commit, puzzle, session], ); const focusCells = useCallback((cells: readonly number[]) => { if (cells.length === 0) return; setSelection([...cells]); setActiveCell(cells[0]!); setAidMemoireActive(false); }, []); const focusQualityItem = useCallback( (item: QualityItemReference) => { focusCells(qualityItemCells(normalized.puzzle, item)); }, [focusCells, normalized.puzzle], ); const createNamedSavepoint = useCallback( (name: string) => { try { setGameplayHistory( createSavepoint(gameplayHistory, session, name, aidMemoire), ); setFeedback({ kind: "success", message: `Saved “${name.trim()}” locally for this solve.`, }); } catch (error) { setFeedback({ kind: "error", message: errorMessage(error) }); } }, [aidMemoire, gameplayHistory, session], ); const restoreNamedSavepoint = useCallback( (savepointId: string) => { try { const transition = restoreSavepoint(gameplayHistory, savepointId); const restored = sessionFromGameplayState(transition.state); setPast((entries) => [...entries, currentHistory()].slice(-MAX_HISTORY), ); setFuture([]); setGameplayHistory(transition.history); setSession(restored); setGuidedCandidateTracking(false); setAidMemoire( aidMemoireFromGameplayState(transition.state, puzzle.size), ); setAidMemoireActive(false); replayReturnSessionRef.current = null; replayReturnAidMemoireRef.current = null; setReplayMomentId(undefined); setHistoryOpen(false); clearAnalysis(); setFeedback({ kind: "success", message: "Savepoint restored." }); } catch (error) { setFeedback({ kind: "error", message: errorMessage(error) }); } }, [clearAnalysis, currentHistory, gameplayHistory, puzzle.size], ); const startHypothesis = useCallback( (name: string, fromMomentId?: string) => { try { const transition = beginHypothesis( gameplayHistory, session, name, fromMomentId, aidMemoire, ); setGameplayHistory(transition.history); setSession(sessionFromGameplayState(transition.state)); setGuidedCandidateTracking(false); setAidMemoire( aidMemoireFromGameplayState(transition.state, puzzle.size), ); setAidMemoireActive(false); setPast([]); setFuture([]); replayReturnSessionRef.current = null; replayReturnAidMemoireRef.current = null; setReplayMomentId(undefined); setHistoryOpen(false); clearAnalysis(); setFeedback({ kind: "info", message: `Hypothesis “${name.trim()}” started. Its moves are isolated until you keep or discard them.`, }); } catch (error) { setFeedback({ kind: "error", message: errorMessage(error) }); } }, [aidMemoire, clearAnalysis, gameplayHistory, puzzle.size, session], ); const completeHypothesis = useCallback( (decision: "keep" | "discard") => { try { const transition = finishHypothesis( gameplayHistory, session, decision, aidMemoire, ); setGameplayHistory(transition.history); setSession(sessionFromGameplayState(transition.state)); setGuidedCandidateTracking(false); setAidMemoire( aidMemoireFromGameplayState(transition.state, puzzle.size), ); setAidMemoireActive(false); setPast([]); setFuture([]); replayReturnSessionRef.current = null; replayReturnAidMemoireRef.current = null; setReplayMomentId(undefined); setHistoryOpen(false); clearAnalysis(); setFeedback({ kind: "success", message: decision === "keep" ? "Hypothesis changes kept in the main solve." : "Hypothesis discarded; its branch remains available in replay.", }); } catch (error) { setFeedback({ kind: "error", message: errorMessage(error) }); } }, [aidMemoire, clearAnalysis, gameplayHistory, puzzle.size, session], ); const replayHistoryMoment = useCallback( (momentId: string) => { const moment = gameplayMoment(gameplayHistory, momentId); if (moment === undefined) return; if (replayReturnSessionRef.current === null) { replayReturnSessionRef.current = session; replayReturnAidMemoireRef.current = aidMemoire; } setSession(sessionFromGameplayState(moment.state, true)); setAidMemoire(aidMemoireFromGameplayState(moment.state, puzzle.size)); setAidMemoireActive(false); setReplayMomentId(moment.id); setHistoryOpen(false); setHighlightedDigit(null); }, [aidMemoire, gameplayHistory, puzzle.size, session], ); const returnToLiveGrid = useCallback(() => { const live = replayReturnSessionRef.current; const liveAidMemoire = replayReturnAidMemoireRef.current; if (live !== null) setSession(live); if (liveAidMemoire !== null) setAidMemoire(liveAidMemoire); replayReturnSessionRef.current = null; replayReturnAidMemoireRef.current = null; setAidMemoireActive(false); setReplayMomentId(undefined); }, []); const activeConstraints = useMemo(() => { const types: string[] = [ ...new Set(normalized.puzzle.constraints.map((item) => item.type)), ]; if ( normalized.puzzle.constraints.some( (item) => "negated" in item && item.negated === true, ) ) { types.push("false-clues"); } return types; }, [normalized.puzzle.constraints]); return (

Local puzzle studio

{puzzle.title || "Untitled Sudoku"}

{puzzle.author ? `Set by ${puzzle.author}. ` : ""} Set, play, explain and explore without uploading a puzzle.

{solved ? "Solved" : conflictData.length ? `${String(conflictData.length)} conflict${conflictData.length === 1 ? "" : "s"}` : "In progress"} {puzzle.size} × {puzzle.size} {puzzle.givens.filter(Boolean).length} given {puzzle.givens.filter(Boolean).length === 1 ? "" : "s"}
{autosaveStatus === "checking" ? "Autosave checking" : autosaveStatus === "saving" ? "Autosaving…" : autosaveStatus === "saved" ? "Autosaved" : autosaveStatus === "error" ? "Autosave issue" : "Autosave ready"}
{recoverableAutosave && (
Recover unsaved local work?

“{recoverableAutosave.title || "Untitled puzzle"}” was autosaved{" "} {new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short", }).format(recoverableAutosave.updatedAt)} .

)} {feedback && (

{feedback.message}

)}
{workspace === "play" && ( )} {replayMoment && ( )}
{replayMoment ? `Replay · ${replayMoment.label}` : aidMemoireActive && aidMemoire.enabled ? `Aid-mémoire · ${aidMemoire.cells[aidMemoireCell]?.label || `cell ${String(aidMemoireCell + 1)}`}` : effectiveSelection.length === 1 ? cellLabel(effectiveSelection[0]!, puzzle.size) : `${String(effectiveSelection.length)} cells selected`}
{ draggingRef.current = false; }} > {session.paused && replayMoment === undefined && ( )}
{mobileLayout && (workspace === "play" || workspace === "set") && replayMoment === undefined && (
)} {workspace === "play" && showDigitCompletion && ( setHighlightedDigit((current) => current === digit ? null : digit, ) } /> )} {workspace === "play" && aidMemoire.enabled && (!session.paused || replayMoment !== undefined) && ( { setAidMemoireCell(cell); setAidMemoireActive(true); setHighlightedDigit(null); }} onMode={setEntryMode} /> )}

Rules

{puzzle.rules || `Place 1–${String(puzzle.size)} exactly once in every row, column and outlined region.`}

{activeConstraints.length > 0 && (
{activeConstraints.map((type) => ( {constraintLabel(type)} ))}
)}
setImportOpen(false)} onImport={(next, progress, preservedExtras) => { try { loadPuzzle(next, progress, undefined, preservedExtras); setWorkspace("play"); } catch (error) { setFeedback({ kind: "error", message: errorMessage(error) }); } }} /> setLibraryOpen(false)} onSave={() => void saveCurrent()} onOpen={(id) => void openProject(id)} onDelete={(id) => void deleteProject(id)} onClear={() => void clearLibrary()} onExport={() => void exportLibrary()} onExportSelected={(ids) => void exportSelectedProjects(ids)} onDuplicateSelected={(ids) => void duplicateSelectedProjects(ids)} onDeleteSelected={(ids) => void deleteSelectedProjects(ids)} onUpdateTags={(id, tags) => void updateProjectTags(id, tags)} onImport={(file) => void importLibrary(file)} /> setHistoryOpen(false)} onCreateSavepoint={createNamedSavepoint} onRestoreSavepoint={restoreNamedSavepoint} onDeleteSavepoint={(savepointId) => setGameplayHistory((current) => deleteSavepoint(current, savepointId)) } onStartHypothesis={startHypothesis} onFinishHypothesis={completeHypothesis} onReplayMoment={replayHistoryMoment} onReturnLive={returnToLiveGrid} />
); }