2823 lines
90 KiB
TypeScript
2823 lines
90 KiB
TypeScript
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<number>(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<Workspace>("play");
|
||
const [puzzle, setPuzzle] = useState<PuzzleDefinition>(boot.puzzle);
|
||
const [session, setSession] = useState<PlaySession>(boot.session);
|
||
const [preservedDocumentExtras, setPreservedDocumentExtras] = useState<
|
||
PreservedSudokuDocumentExtras | undefined
|
||
>(boot.preservedExtras);
|
||
const [selection, setSelection] = useState<number[]>([0]);
|
||
const [activeCell, setActiveCell] = useState(0);
|
||
const [entryMode, setEntryMode] = useState<EntryMode>("value");
|
||
const [showCandidates, setShowCandidates] = useState(false);
|
||
const [candidateVerbosity, setCandidateVerbosity] =
|
||
useState<CandidateVerbosity>("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<number | null>(null);
|
||
const [guidedHint, setGuidedHint] = useState<LogicalStep>();
|
||
const [guidedHintStage, setGuidedHintStage] =
|
||
useState<GuidedHintStage>("focus");
|
||
const [guidedHintError, setGuidedHintError] = useState<string>();
|
||
const [guidedCandidateTracking, setGuidedCandidateTracking] = useState(false);
|
||
const [autoMaintainPeerNotes, setAutoMaintainPeerNotes] = useState(false);
|
||
const [candidateOverlay, setCandidateOverlay] = useState<CandidateOverlay>();
|
||
const [aidMemoire, setAidMemoire] = useState<AidMemoireState>(
|
||
boot.aidMemoire,
|
||
);
|
||
const [aidMemoireCell, setAidMemoireCell] = useState(0);
|
||
const [aidMemoireActive, setAidMemoireActive] = useState(false);
|
||
const [past, setPast] = useState<HistoryEntry[]>([]);
|
||
const [future, setFuture] = useState<HistoryEntry[]>([]);
|
||
const [gameplayHistory, setGameplayHistory] = useState<GameplayHistory>(() =>
|
||
createGameplayHistory(boot.session, boot.aidMemoire),
|
||
);
|
||
const [historyOpen, setHistoryOpen] = useState(false);
|
||
const [replayMomentId, setReplayMomentId] = useState<string>();
|
||
const [busy, setBusy] = useState(false);
|
||
const [feedback, setFeedback] = useState<Feedback>();
|
||
const [logical, setLogical] = useState<LogicalSolveResult>();
|
||
const [exact, setExact] = useState<ExactSolveResult>();
|
||
const [difficulty, setDifficulty] = useState<DifficultyAssessment>();
|
||
const [quality, setQuality] = useState<PuzzleQualityAnalysis>();
|
||
const [qualityRunning, setQualityRunning] = useState<SetterQualityRunKind>();
|
||
const [qualityError, setQualityError] = useState<string>();
|
||
const [generation, setGeneration] = useState<GeneratedVariantPuzzle>();
|
||
const [generationBatch, setGenerationBatch] =
|
||
useState<GeneratedVariantBatch>();
|
||
const [generationRunning, setGenerationRunning] = useState(false);
|
||
const [solveError, setSolveError] = useState<string>();
|
||
const [importOpen, setImportOpen] = useState(false);
|
||
const [libraryOpen, setLibraryOpen] = useState(false);
|
||
const [summaries, setSummaries] = useState<readonly SudokuProjectSummary[]>(
|
||
[],
|
||
);
|
||
const [libraryMode, setLibraryMode] =
|
||
useState<ProjectLibraryMode>("indexeddb");
|
||
const [libraryBusy, setLibraryBusy] = useState(false);
|
||
const [libraryFeedback, setLibraryFeedback] = useState<string>();
|
||
const [currentProjectId, setCurrentProjectId] = useState<string>();
|
||
const [recoverableAutosave, setRecoverableAutosave] =
|
||
useState<SudokuProjectRecord>();
|
||
const [autosaveChecked, setAutosaveChecked] = useState(false);
|
||
const [autosaveStatus, setAutosaveStatus] =
|
||
useState<AutosaveStatus>("checking");
|
||
const [autosaveMessage, setAutosaveMessage] = useState(
|
||
"Checking for recoverable local work…",
|
||
);
|
||
const workerRef = useRef<SolverWorkerClient | null>(null);
|
||
const qualityAbortRef = useRef<AbortController | null>(null);
|
||
const generationAbortRef = useRef<AbortController | null>(null);
|
||
const draggingRef = useRef(false);
|
||
const workbenchRef = useRef<HTMLElement>(null);
|
||
const replayReturnSessionRef = useRef<PlaySession | null>(null);
|
||
const replayReturnAidMemoireRef = useRef<AidMemoireState | null>(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<number>() : 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<number>()
|
||
: 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<number>(),
|
||
[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<void> => {
|
||
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<HTMLButtonElement>) => {
|
||
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<HTMLButtonElement>) => {
|
||
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<HTMLButtonElement>(`[data-cell="${String(next)}"]`)
|
||
?.focus();
|
||
});
|
||
},
|
||
[changeSelection, effectiveActiveCell, puzzle.size],
|
||
);
|
||
|
||
const handleKeyDown = useCallback(
|
||
(event: KeyboardEvent<HTMLDivElement>): 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<string, EntryMode> = {
|
||
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 (
|
||
<main className="sudoku-workbench" ref={workbenchRef}>
|
||
<header className="workbench-hero">
|
||
<div className="hero-copy">
|
||
<p className="eyebrow">Local puzzle studio</p>
|
||
<h1>{puzzle.title || "Untitled Sudoku"}</h1>
|
||
<p>
|
||
{puzzle.author ? `Set by ${puzzle.author}. ` : ""}
|
||
Set, play, explain and explore without uploading a puzzle.
|
||
</p>
|
||
</div>
|
||
<div className="hero-actions" aria-label="Puzzle actions">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
loadPuzzle(createEmptyPuzzle(9, { title: "Untitled Sudoku" }));
|
||
setWorkspace("set");
|
||
setEntryMode("value");
|
||
}}
|
||
>
|
||
New blank
|
||
</button>
|
||
<label className="select-wrap">
|
||
<span className="sr-only">Open built-in puzzle</span>
|
||
<select
|
||
defaultValue=""
|
||
onChange={(event) => {
|
||
const sample = SAMPLE_CATALOG.find(
|
||
({ id }) => id === event.target.value,
|
||
);
|
||
if (sample !== undefined) {
|
||
loadPuzzle(cloneSample(sample.puzzle));
|
||
setWorkspace("play");
|
||
}
|
||
event.target.value = "";
|
||
}}
|
||
>
|
||
<option value="" disabled>
|
||
Built-in examples…
|
||
</option>
|
||
{SAMPLE_CATALOG.map((sample) => (
|
||
<option key={sample.id} value={sample.id}>
|
||
{sample.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<button type="button" onClick={() => setImportOpen(true)}>
|
||
Import / export
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setLibraryOpen(true);
|
||
void refreshLibrary();
|
||
}}
|
||
>
|
||
Library
|
||
</button>
|
||
<button type="button" onClick={() => window.print()}>
|
||
Print
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<nav className="workspace-tabs" aria-label="Sudoku workspace">
|
||
{(
|
||
[
|
||
["play", "Play"],
|
||
["set", "Set"],
|
||
["generate", "Generate"],
|
||
["solve", "Solve"],
|
||
["helpers", "Helpers"],
|
||
] as const
|
||
).map(([id, label]) => (
|
||
<button
|
||
key={id}
|
||
type="button"
|
||
className={workspace === id ? "is-active" : ""}
|
||
aria-current={workspace === id ? "page" : undefined}
|
||
disabled={currentHypothesis !== undefined && id !== "play"}
|
||
title={
|
||
currentHypothesis !== undefined && id !== "play"
|
||
? "Keep or discard the active hypothesis first"
|
||
: undefined
|
||
}
|
||
onClick={() => {
|
||
if (id !== "play") returnToLiveGrid();
|
||
if (id !== "play") setAidMemoireActive(false);
|
||
setWorkspace(id);
|
||
if (id === "set") setEntryMode("value");
|
||
}}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
|
||
<div className="workbench-status">
|
||
<div className="toolbar-group">
|
||
<span className={`status-pill${solved ? " status-solved" : ""}`}>
|
||
{solved
|
||
? "Solved"
|
||
: conflictData.length
|
||
? `${String(conflictData.length)} conflict${conflictData.length === 1 ? "" : "s"}`
|
||
: "In progress"}
|
||
</span>
|
||
<span>
|
||
{puzzle.size} × {puzzle.size}
|
||
</span>
|
||
<span>
|
||
{puzzle.givens.filter(Boolean).length} given
|
||
{puzzle.givens.filter(Boolean).length === 1 ? "" : "s"}
|
||
</span>
|
||
</div>
|
||
<time
|
||
className="timer-display"
|
||
dateTime={`PT${String(session.elapsedSeconds)}S`}
|
||
>
|
||
{formatTime(session.elapsedSeconds)}
|
||
</time>
|
||
<span
|
||
className={`autosave-status autosave-status--${autosaveStatus}`}
|
||
title={autosaveMessage}
|
||
>
|
||
{autosaveStatus === "checking"
|
||
? "Autosave checking"
|
||
: autosaveStatus === "saving"
|
||
? "Autosaving…"
|
||
: autosaveStatus === "saved"
|
||
? "Autosaved"
|
||
: autosaveStatus === "error"
|
||
? "Autosave issue"
|
||
: "Autosave ready"}
|
||
</span>
|
||
</div>
|
||
|
||
{recoverableAutosave && (
|
||
<section className="recovery-callout" aria-labelledby="recovery-title">
|
||
<div>
|
||
<strong id="recovery-title">Recover unsaved local work?</strong>
|
||
<p>
|
||
“{recoverableAutosave.title || "Untitled puzzle"}” was autosaved{" "}
|
||
{new Intl.DateTimeFormat(undefined, {
|
||
dateStyle: "medium",
|
||
timeStyle: "short",
|
||
}).format(recoverableAutosave.updatedAt)}
|
||
.
|
||
</p>
|
||
</div>
|
||
<div className="action-row">
|
||
<button type="button" onClick={restoreAutosave}>
|
||
Restore
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="text-button"
|
||
onClick={() => void discardAutosave()}
|
||
>
|
||
Discard
|
||
</button>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{feedback && (
|
||
<p
|
||
className={`feedback-callout feedback-${feedback.kind}`}
|
||
role={feedback.kind === "error" ? "alert" : "status"}
|
||
>
|
||
{feedback.message}
|
||
</p>
|
||
)}
|
||
|
||
<div
|
||
className={`workbench-grid${workspace === "helpers" ? " workbench-grid--helpers" : ""}`}
|
||
>
|
||
<section className="board-column" aria-label="Puzzle board">
|
||
<div className="board-toolbar">
|
||
<div className="toolbar-group">
|
||
<button
|
||
type="button"
|
||
disabled={!past.length || replayMoment !== undefined}
|
||
onClick={undo}
|
||
>
|
||
Undo
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!future.length || replayMoment !== undefined}
|
||
onClick={redo}
|
||
>
|
||
Redo
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={tapMultiSelect ? "is-active" : ""}
|
||
aria-pressed={tapMultiSelect}
|
||
title="Toggle cells with individual taps; dragging is disabled (M)"
|
||
onClick={() => setTapMultiSelect((current) => !current)}
|
||
>
|
||
Tap multi-select
|
||
</button>
|
||
{workspace === "play" && (
|
||
<button type="button" onClick={() => setHistoryOpen(true)}>
|
||
{currentHypothesis
|
||
? `Hypothesis: ${currentHypothesis.name}`
|
||
: "History & branches"}
|
||
</button>
|
||
)}
|
||
{replayMoment && (
|
||
<button type="button" onClick={returnToLiveGrid}>
|
||
Return live
|
||
</button>
|
||
)}
|
||
</div>
|
||
<span>
|
||
{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`}
|
||
</span>
|
||
</div>
|
||
<div
|
||
className={`board-surface${session.paused && replayMoment === undefined ? " is-paused" : ""}${replayMoment ? " is-replay" : ""}`}
|
||
>
|
||
<BoardViewport
|
||
onPanModeChange={() => {
|
||
draggingRef.current = false;
|
||
}}
|
||
>
|
||
<SudokuBoard
|
||
puzzle={normalized.puzzle}
|
||
values={session.values}
|
||
cornerMarks={session.cornerMarks}
|
||
centerMarks={session.centerMarks}
|
||
colors={session.colors}
|
||
candidates={candidateMasks}
|
||
selected={boardSelectedSet}
|
||
highlighted={highlightedCells}
|
||
conflicts={conflictCells}
|
||
activeCell={effectiveActiveCell}
|
||
showCandidates={showCandidates}
|
||
candidateVerbosity={candidateVerbosity}
|
||
candidateOverlay={
|
||
workspace === "helpers" ? candidateOverlay : undefined
|
||
}
|
||
guidedHintOverlay={
|
||
workspace === "play" ? guidedBoardOverlay : undefined
|
||
}
|
||
qualityHeatmap={
|
||
workspace === "set" ? quality?.criticalityHeatmap : undefined
|
||
}
|
||
visuals={normalizedSourceVisuals}
|
||
onCellPointerDown={handlePointerDown}
|
||
onCellPointerEnter={handlePointerEnter}
|
||
onKeyDown={handleKeyDown}
|
||
/>
|
||
</BoardViewport>
|
||
{session.paused && replayMoment === undefined && (
|
||
<button
|
||
type="button"
|
||
className="paused-cover"
|
||
onClick={() =>
|
||
setSession((current) => ({ ...current, paused: false }))
|
||
}
|
||
>
|
||
<strong>Paused</strong>
|
||
<span>Resume puzzle</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
{mobileLayout &&
|
||
(workspace === "play" || workspace === "set") &&
|
||
replayMoment === undefined && (
|
||
<div className="mobile-number-pad" aria-label="Sticky entry pad">
|
||
<NumberPad
|
||
size={puzzle.size}
|
||
mode={entryMode}
|
||
onMode={setEntryMode}
|
||
onValue={enterValue}
|
||
onErase={erase}
|
||
/>
|
||
</div>
|
||
)}
|
||
{workspace === "play" && showDigitCompletion && (
|
||
<DigitCompletionBar
|
||
size={puzzle.size}
|
||
completions={completionData}
|
||
highlightedDigit={highlightedDigit}
|
||
highlightingEnabled={enableDigitHighlight}
|
||
onHighlight={(digit) =>
|
||
setHighlightedDigit((current) =>
|
||
current === digit ? null : digit,
|
||
)
|
||
}
|
||
/>
|
||
)}
|
||
{workspace === "play" &&
|
||
aidMemoire.enabled &&
|
||
(!session.paused || replayMoment !== undefined) && (
|
||
<AidMemoire
|
||
size={puzzle.size}
|
||
state={aidMemoire}
|
||
selectedCell={aidMemoireCell}
|
||
active={aidMemoireActive && replayMoment === undefined}
|
||
readOnly={replayMoment !== undefined}
|
||
mode={entryMode}
|
||
onStateChange={updateAidMemoire}
|
||
onSelect={(cell) => {
|
||
setAidMemoireCell(cell);
|
||
setAidMemoireActive(true);
|
||
setHighlightedDigit(null);
|
||
}}
|
||
onMode={setEntryMode}
|
||
/>
|
||
)}
|
||
<div className="board-meta">
|
||
<section className="rule-card">
|
||
<p className="eyebrow">Rules</p>
|
||
<p>
|
||
{puzzle.rules ||
|
||
`Place 1–${String(puzzle.size)} exactly once in every row, column and outlined region.`}
|
||
</p>
|
||
{activeConstraints.length > 0 && (
|
||
<div
|
||
className="constraint-chips"
|
||
aria-label="Active constraints"
|
||
>
|
||
{activeConstraints.map((type) => (
|
||
<span key={type}>{constraintLabel(type)}</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</section>
|
||
|
||
<aside className="side-panel">
|
||
{workspace === "play" && replayMoment && (
|
||
<div className="play-panel stack">
|
||
<div>
|
||
<p className="eyebrow">Read-only replay</p>
|
||
<h2>{replayMoment.label}</h2>
|
||
<p className="muted">
|
||
This is the complete grid at{" "}
|
||
{formatTime(replayMoment.state.elapsedSeconds)}. Return to the
|
||
live grid or start a hypothesis from here.
|
||
</p>
|
||
</div>
|
||
<div className="action-row">
|
||
<button type="button" onClick={returnToLiveGrid}>
|
||
Return to live grid
|
||
</button>
|
||
<button type="button" onClick={() => setHistoryOpen(true)}>
|
||
Open history
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{workspace === "play" && replayMoment === undefined && (
|
||
<div className="play-panel stack">
|
||
<div>
|
||
<p className="eyebrow">Play locally</p>
|
||
<h2>Enter your solve</h2>
|
||
<p className="muted">
|
||
{aidMemoireActive && aidMemoire.enabled
|
||
? "The keypad is editing the selected aid-mémoire cell. Select the Sudoku grid to return to normal entry."
|
||
: "Select one or several cells, then use the keypad or keyboard."}
|
||
</p>
|
||
</div>
|
||
{!mobileLayout && (
|
||
<NumberPad
|
||
size={puzzle.size}
|
||
mode={entryMode}
|
||
onMode={setEntryMode}
|
||
onValue={enterValue}
|
||
onErase={erase}
|
||
/>
|
||
)}
|
||
<GuidedHint
|
||
size={puzzle.size}
|
||
step={visibleGuidedHint}
|
||
stage={guidedHintStage}
|
||
busy={busy || session.paused}
|
||
error={guidedHintError}
|
||
candidateTrackingActive={guidedCandidateTracking}
|
||
autoMaintainPeerNotes={autoMaintainPeerNotes}
|
||
onRequestHint={() => void requestHint()}
|
||
onRevealNext={revealNextHintStage}
|
||
onApply={applyGuidedHint}
|
||
onDismiss={() => {
|
||
setGuidedHint(undefined);
|
||
setGuidedHintStage("focus");
|
||
setGuidedHintError(undefined);
|
||
}}
|
||
onFillLegalCandidates={fillGuidedCandidates}
|
||
onRemoveInvalidNotes={removeInvalidCandidateNotes}
|
||
onAutoMaintainPeerNotesChange={setAutoMaintainPeerNotes}
|
||
/>
|
||
<section className="panel-section">
|
||
<div className="action-row">
|
||
<button type="button" onClick={checkBoard}>
|
||
Check
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
setSession((current) => ({
|
||
...current,
|
||
paused: !current.paused,
|
||
}))
|
||
}
|
||
>
|
||
{session.paused ? "Resume" : "Pause"}
|
||
</button>
|
||
</div>
|
||
<label className="option-row">
|
||
<input
|
||
type="checkbox"
|
||
checked={showCandidates}
|
||
onChange={(event) =>
|
||
setShowCandidates(event.target.checked)
|
||
}
|
||
/>
|
||
Show automatically calculated candidates
|
||
</label>
|
||
<label className="accessibility-select">
|
||
<span>Screen-reader candidate detail</span>
|
||
<select
|
||
value={candidateVerbosity}
|
||
onChange={(event) =>
|
||
setCandidateVerbosity(
|
||
parseCandidateVerbosity(event.target.value),
|
||
)
|
||
}
|
||
>
|
||
<option value="off">Off</option>
|
||
<option value="concise">Concise counts</option>
|
||
<option value="detailed">Detailed digits</option>
|
||
</select>
|
||
</label>
|
||
<label className="option-row">
|
||
<input
|
||
type="checkbox"
|
||
checked={showConflicts}
|
||
onChange={(event) => setShowConflicts(event.target.checked)}
|
||
/>
|
||
Highlight conflicts immediately
|
||
</label>
|
||
<label className="option-row">
|
||
<input
|
||
type="checkbox"
|
||
checked={showDigitCompletion}
|
||
onChange={(event) =>
|
||
setShowDigitCompletion(event.target.checked)
|
||
}
|
||
/>
|
||
Show digit completion bar
|
||
</label>
|
||
<label className="option-row">
|
||
<input
|
||
type="checkbox"
|
||
checked={enableDigitHighlight}
|
||
onChange={(event) => {
|
||
const enabled = event.target.checked;
|
||
setEnableDigitHighlight(enabled);
|
||
if (!enabled) setHighlightedDigit(null);
|
||
}}
|
||
/>
|
||
Enable matching-digit highlighting (Ctrl/⌘-click or bar)
|
||
</label>
|
||
<label className="option-row">
|
||
<input
|
||
type="checkbox"
|
||
checked={aidMemoire.enabled}
|
||
onChange={(event) => {
|
||
const enabled = event.target.checked;
|
||
updateAidMemoire(
|
||
setAidMemoireEnabled(aidMemoire, enabled),
|
||
enabled ? "Aid-mémoire shown" : "Aid-mémoire hidden",
|
||
);
|
||
setAidMemoireActive(enabled);
|
||
if (enabled) setAidMemoireCell(0);
|
||
}}
|
||
/>
|
||
Show aid-mémoire scratch cells
|
||
</label>
|
||
</section>
|
||
<section className="panel-section">
|
||
<button
|
||
type="button"
|
||
className="danger"
|
||
onClick={() => {
|
||
commit(puzzle, createSession(puzzle.givens));
|
||
setGuidedCandidateTracking(false);
|
||
}}
|
||
>
|
||
Restart puzzle
|
||
</button>
|
||
</section>
|
||
<p className="muted shortcut-note">
|
||
Z/X/C/V change mode · arrows move · Home/End move across a row ·
|
||
Shift extends · Ctrl/⌘ Z undoes · Ctrl/⌘-click a placed digit
|
||
highlights its matches.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{workspace === "set" && (
|
||
<div className="setter-workspace stack">
|
||
<section className="panel-section">
|
||
<p className="eyebrow">Grid clues</p>
|
||
<h2>Enter givens</h2>
|
||
<p className="muted">
|
||
In Value mode, digits become fixed clues. Notes and colours
|
||
remain play-test annotations.
|
||
</p>
|
||
{!mobileLayout && (
|
||
<NumberPad
|
||
size={puzzle.size}
|
||
mode={entryMode}
|
||
onMode={setEntryMode}
|
||
onValue={enterValue}
|
||
onErase={erase}
|
||
/>
|
||
)}
|
||
</section>
|
||
<ConstraintEditor
|
||
puzzle={puzzle}
|
||
selection={selection}
|
||
busy={busy}
|
||
onChange={(next) => commit(next, session)}
|
||
onNewGrid={(size) =>
|
||
loadPuzzle(
|
||
createEmptyPuzzle(size, {
|
||
title: puzzle.title ?? "Untitled Sudoku",
|
||
}),
|
||
)
|
||
}
|
||
onCheck={() => void checkDefinition()}
|
||
onGenerate={() => setWorkspace("generate")}
|
||
/>
|
||
<SetterQualityLab
|
||
size={puzzle.size}
|
||
result={quality}
|
||
running={qualityRunning}
|
||
error={qualityError}
|
||
onRunQuick={(options) => void runQualityAudit("quick", options)}
|
||
onRunMinimality={(options) =>
|
||
void runQualityAudit("minimality", options)
|
||
}
|
||
onCancel={cancelQualityAudit}
|
||
onFocusCells={focusCells}
|
||
onFocusItem={focusQualityItem}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{workspace === "generate" && (
|
||
<GeneratorWorkspace
|
||
busy={busy}
|
||
assessment={difficulty}
|
||
generation={generation}
|
||
batch={generationBatch}
|
||
onGenerate={(options) => void generateConfigured(options)}
|
||
onGenerateBatch={(options) =>
|
||
void generateBatchConfigured(options)
|
||
}
|
||
onSelectGenerated={selectGeneratedCandidate}
|
||
onCancel={generationRunning ? cancelGeneration : undefined}
|
||
onRate={() => void rateCurrentPuzzle()}
|
||
/>
|
||
)}
|
||
|
||
{workspace === "solve" && (
|
||
<SolveWorkspace
|
||
size={puzzle.size}
|
||
busy={busy}
|
||
logical={logical}
|
||
exact={exact}
|
||
error={solveError}
|
||
onLogical={() => void runLogical()}
|
||
onExact={() => void runExact()}
|
||
onApplyValues={applyValues}
|
||
onFocusCells={focusCells}
|
||
/>
|
||
)}
|
||
|
||
{workspace === "helpers" && (
|
||
<HelpersWorkspace
|
||
key={puzzle.size}
|
||
size={puzzle.size}
|
||
puzzle={normalized.puzzle}
|
||
values={session.values}
|
||
selectedCells={effectiveSelection}
|
||
candidateMasks={candidateMasks}
|
||
onCandidateOverlayChange={handleCandidateOverlayChange}
|
||
/>
|
||
)}
|
||
</aside>
|
||
</div>
|
||
|
||
<ImportExportDialog
|
||
open={importOpen}
|
||
puzzle={puzzle}
|
||
session={session}
|
||
aidMemoire={aidMemoireToPortable(aidMemoire, puzzle.size)}
|
||
preservedExtras={preservedDocumentExtras}
|
||
onClose={() => setImportOpen(false)}
|
||
onImport={(next, progress, preservedExtras) => {
|
||
try {
|
||
loadPuzzle(next, progress, undefined, preservedExtras);
|
||
setWorkspace("play");
|
||
} catch (error) {
|
||
setFeedback({ kind: "error", message: errorMessage(error) });
|
||
}
|
||
}}
|
||
/>
|
||
<LibraryDialog
|
||
open={libraryOpen}
|
||
summaries={summaries}
|
||
mode={libraryMode}
|
||
busy={libraryBusy}
|
||
feedback={libraryFeedback}
|
||
onClose={() => 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)}
|
||
/>
|
||
<GameplayHistoryDialog
|
||
open={historyOpen}
|
||
history={gameplayHistory}
|
||
replayMomentId={replayMomentId}
|
||
onClose={() => setHistoryOpen(false)}
|
||
onCreateSavepoint={createNamedSavepoint}
|
||
onRestoreSavepoint={restoreNamedSavepoint}
|
||
onDeleteSavepoint={(savepointId) =>
|
||
setGameplayHistory((current) => deleteSavepoint(current, savepointId))
|
||
}
|
||
onStartHypothesis={startHypothesis}
|
||
onFinishHypothesis={completeHypothesis}
|
||
onReplayMoment={replayHistoryMoment}
|
||
onReturnLive={returnToLiveGrid}
|
||
/>
|
||
</main>
|
||
);
|
||
}
|