feat: expand sudoku analysis and interoperability
This commit is contained in:
+557
-73
@@ -24,6 +24,7 @@ import {
|
||||
toDomainPuzzle,
|
||||
type SudokuDocument,
|
||||
} from "../formats";
|
||||
import type { CandidateOverlay } from "../helpers";
|
||||
import { CLASSIC_SAMPLE, SAMPLE_CATALOG } from "../data/samples";
|
||||
import type {
|
||||
DifficultyAssessment,
|
||||
@@ -57,9 +58,37 @@ import {
|
||||
matchingDigitCells,
|
||||
toggledDigitHighlight,
|
||||
} from "../state/gameplayHelpers";
|
||||
import {
|
||||
aidMemoireFromPortable,
|
||||
aidMemoireToPortable,
|
||||
cloneAidMemoire,
|
||||
createAidMemoire,
|
||||
enterAidMemoireCell,
|
||||
eraseAidMemoireCell,
|
||||
setAidMemoireEnabled,
|
||||
type AidMemoireState,
|
||||
} from "../state/aidMemoire";
|
||||
import { navigateGridCell } from "../state/gridNavigation";
|
||||
import {
|
||||
activeHypothesis,
|
||||
aidMemoireFromGameplayState,
|
||||
beginHypothesis,
|
||||
createGameplayHistory,
|
||||
createSavepoint,
|
||||
deleteSavepoint,
|
||||
describeGameplayChange,
|
||||
finishHypothesis,
|
||||
gameplayMoment,
|
||||
recordGameplayMoment,
|
||||
restoreSavepoint,
|
||||
sessionFromGameplayState,
|
||||
type GameplayHistory,
|
||||
} from "../state/playHistory";
|
||||
import { createSolverWorkerClient, type SolverWorkerClient } from "../workers";
|
||||
import { ConstraintEditor } from "./ConstraintEditor";
|
||||
import { AidMemoire } from "./AidMemoire";
|
||||
import { DigitCompletionBar } from "./DigitCompletionBar";
|
||||
import { GameplayHistoryDialog } from "./GameplayHistoryDialog";
|
||||
import { GeneratorWorkspace } from "./GeneratorWorkspace";
|
||||
import { HelpersWorkspace } from "./HelpersWorkspace";
|
||||
import { ImportExportDialog } from "./ImportExportDialog";
|
||||
@@ -79,6 +108,7 @@ interface Feedback {
|
||||
interface HistoryEntry {
|
||||
readonly puzzle: PuzzleDefinition;
|
||||
readonly session: PlaySnapshot;
|
||||
readonly aidMemoire: AidMemoireState;
|
||||
}
|
||||
|
||||
const library = createProjectLibrary();
|
||||
@@ -143,6 +173,7 @@ function progressFromSession(
|
||||
puzzle: PuzzleDefinition,
|
||||
session: PlaySession,
|
||||
completed: boolean,
|
||||
aidMemoire: AidMemoireState,
|
||||
): SudokuProgress {
|
||||
return {
|
||||
version: 1,
|
||||
@@ -156,6 +187,7 @@ function progressFromSession(
|
||||
colors: [...session.colors],
|
||||
elapsedMs: session.elapsedSeconds * 1_000,
|
||||
completed,
|
||||
aidMemoire: aidMemoireToPortable(aidMemoire, puzzle.size),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,18 +227,27 @@ function safeNormalize(puzzle: PuzzleDefinition): {
|
||||
function initialProject(): {
|
||||
readonly puzzle: PuzzleDefinition;
|
||||
readonly session: PlaySession;
|
||||
readonly aidMemoire: AidMemoireState;
|
||||
} {
|
||||
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) };
|
||||
return {
|
||||
puzzle,
|
||||
session: sessionFromDocument(puzzle, document),
|
||||
aidMemoire: aidMemoireFromPortable(document.aidMemoire, puzzle.size),
|
||||
};
|
||||
} catch {
|
||||
// An invalid hash is non-fatal; the import dialog can report details.
|
||||
}
|
||||
}
|
||||
const puzzle = normalizePuzzle(CLASSIC_SAMPLE);
|
||||
return { puzzle, session: createSession(puzzle.givens) };
|
||||
return {
|
||||
puzzle,
|
||||
session: createSession(puzzle.givens),
|
||||
aidMemoire: createAidMemoire(puzzle.size),
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
@@ -245,6 +286,8 @@ function errorMessage(error: unknown): string {
|
||||
|
||||
function constraintLabel(type: string): string {
|
||||
if (type === "xv") return "Sum pairs (5/10)";
|
||||
if (type === "x-sum") return "X-sums";
|
||||
if (type === "false-clues") return "False clues";
|
||||
return type
|
||||
.split("-")
|
||||
.map((part) => part[0]?.toUpperCase() + part.slice(1))
|
||||
@@ -264,8 +307,19 @@ export function Workbench() {
|
||||
const [showDigitCompletion, setShowDigitCompletion] = useState(true);
|
||||
const [enableDigitHighlight, setEnableDigitHighlight] = useState(true);
|
||||
const [highlightedDigit, setHighlightedDigit] = useState<number | null>(null);
|
||||
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>();
|
||||
@@ -286,9 +340,30 @@ export function Workbench() {
|
||||
const workerRef = useRef<SolverWorkerClient | null>(null);
|
||||
const draggingRef = useRef(false);
|
||||
const workbenchRef = useRef<HTMLElement>(null);
|
||||
const replayReturnSessionRef = useRef<PlaySession | null>(null);
|
||||
const replayReturnAidMemoireRef = useRef<AidMemoireState | null>(null);
|
||||
|
||||
const normalized = useMemo(() => safeNormalize(puzzle), [puzzle]);
|
||||
const replayMoment = useMemo(
|
||||
() =>
|
||||
replayMomentId === undefined
|
||||
? undefined
|
||||
: gameplayMoment(gameplayHistory, replayMomentId),
|
||||
[gameplayHistory, replayMomentId],
|
||||
);
|
||||
const currentHypothesis = useMemo(
|
||||
() => activeHypothesis(gameplayHistory),
|
||||
[gameplayHistory],
|
||||
);
|
||||
const selectedSet = useMemo(() => new Set(selection), [selection]);
|
||||
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],
|
||||
@@ -345,20 +420,37 @@ export function Workbench() {
|
||||
}, []);
|
||||
|
||||
const currentHistory = useCallback(
|
||||
(): HistoryEntry => ({ puzzle, session: snapshotSession(session) }),
|
||||
[puzzle, session],
|
||||
(): HistoryEntry => ({
|
||||
puzzle,
|
||||
session: snapshotSession(session),
|
||||
aidMemoire: cloneAidMemoire(aidMemoire),
|
||||
}),
|
||||
[aidMemoire, 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);
|
||||
clearAnalysis();
|
||||
setFeedback(undefined);
|
||||
},
|
||||
[clearAnalysis, currentHistory],
|
||||
[aidMemoire, clearAnalysis, currentHistory, puzzle, session, workspace],
|
||||
);
|
||||
|
||||
const loadPuzzle = useCallback(
|
||||
@@ -372,12 +464,26 @@ export function Workbench() {
|
||||
| "candidates"
|
||||
| "colors"
|
||||
| "elapsedMs"
|
||||
| "aidMemoire"
|
||||
>,
|
||||
projectId?: string,
|
||||
): void => {
|
||||
const valid = normalizePuzzle(nextPuzzle);
|
||||
const nextSession = sessionFromDocument(valid, progress);
|
||||
const nextAidMemoire = aidMemoireFromPortable(
|
||||
progress?.aidMemoire,
|
||||
valid.size,
|
||||
);
|
||||
setPuzzle(valid);
|
||||
setSession(sessionFromDocument(valid, progress));
|
||||
setSession(nextSession);
|
||||
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);
|
||||
@@ -393,8 +499,21 @@ export function Workbench() {
|
||||
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(sessionFromProgress(valid, record.progress));
|
||||
setSession(nextSession);
|
||||
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);
|
||||
@@ -451,24 +570,46 @@ export function Workbench() {
|
||||
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((current) => restoreSnapshot(current, target.session));
|
||||
setSession(restored);
|
||||
setAidMemoire(cloneAidMemoire(target.aidMemoire));
|
||||
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]);
|
||||
}, [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((current) => restoreSnapshot(current, target.session));
|
||||
setSession(restored);
|
||||
setAidMemoire(cloneAidMemoire(target.aidMemoire));
|
||||
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]);
|
||||
}, [clearAnalysis, currentHistory, future, puzzle, session, workspace]);
|
||||
|
||||
const changeSelection = useCallback(
|
||||
(cell: number, additive: boolean, toggle: boolean): void => {
|
||||
@@ -488,6 +629,7 @@ export function Workbench() {
|
||||
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;
|
||||
@@ -528,9 +670,42 @@ export function Workbench() {
|
||||
[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): void => {
|
||||
(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];
|
||||
@@ -554,37 +729,68 @@ export function Workbench() {
|
||||
enterSelection(session, selectedSet, entryMode, value, puzzle.givens),
|
||||
);
|
||||
},
|
||||
[commit, entryMode, puzzle, selectedSet, session, workspace],
|
||||
[
|
||||
aidMemoire,
|
||||
aidMemoireActive,
|
||||
aidMemoireCell,
|
||||
commit,
|
||||
entryMode,
|
||||
puzzle,
|
||||
selectedSet,
|
||||
session,
|
||||
updateAidMemoire,
|
||||
workspace,
|
||||
],
|
||||
);
|
||||
|
||||
const erase = useCallback((): void => {
|
||||
if (session.paused) 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;
|
||||
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;
|
||||
}
|
||||
commit({ ...puzzle, givens }, { ...session, values });
|
||||
return;
|
||||
}
|
||||
commit(
|
||||
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 });
|
||||
return;
|
||||
}
|
||||
commit(
|
||||
puzzle,
|
||||
eraseSelection(session, selectedSet, entryMode, puzzle.givens),
|
||||
);
|
||||
},
|
||||
[
|
||||
aidMemoire,
|
||||
aidMemoireActive,
|
||||
aidMemoireCell,
|
||||
commit,
|
||||
entryMode,
|
||||
puzzle,
|
||||
eraseSelection(session, selectedSet, entryMode, puzzle.givens),
|
||||
);
|
||||
}, [commit, entryMode, puzzle, selectedSet, session, workspace]);
|
||||
selectedSet,
|
||||
session,
|
||||
updateAidMemoire,
|
||||
workspace,
|
||||
],
|
||||
);
|
||||
|
||||
const moveActive = useCallback(
|
||||
(deltaRow: number, deltaColumn: number, extend: boolean): void => {
|
||||
const row = Math.floor(activeCell / puzzle.size);
|
||||
const column = activeCell % puzzle.size;
|
||||
const nextRow = Math.max(0, Math.min(puzzle.size - 1, row + deltaRow));
|
||||
const nextColumn = Math.max(
|
||||
0,
|
||||
Math.min(puzzle.size - 1, column + deltaColumn),
|
||||
);
|
||||
const next = nextRow * puzzle.size + nextColumn;
|
||||
(key: string, extend: boolean, command: boolean): void => {
|
||||
const next = navigateGridCell(activeCell, puzzle.size, key, command);
|
||||
if (next === null) return;
|
||||
changeSelection(next, extend, false);
|
||||
requestAnimationFrame(() => {
|
||||
workbenchRef.current
|
||||
@@ -597,6 +803,7 @@ export function Workbench() {
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
setAidMemoireActive(false);
|
||||
const command = event.ctrlKey || event.metaKey;
|
||||
if (command && event.key.toLowerCase() === "z") {
|
||||
event.preventDefault();
|
||||
@@ -620,16 +827,20 @@ export function Workbench() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const movement: Record<string, readonly [number, number]> = {
|
||||
ArrowUp: [-1, 0],
|
||||
ArrowDown: [1, 0],
|
||||
ArrowLeft: [0, -1],
|
||||
ArrowRight: [0, 1],
|
||||
};
|
||||
const delta = movement[event.key];
|
||||
if (delta !== undefined) {
|
||||
if (
|
||||
[
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"Home",
|
||||
"End",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
].includes(event.key)
|
||||
) {
|
||||
event.preventDefault();
|
||||
moveActive(delta[0], delta[1], event.shiftKey);
|
||||
moveActive(event.key, event.shiftKey, command);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
@@ -638,7 +849,7 @@ export function Workbench() {
|
||||
event.key === "0"
|
||||
) {
|
||||
event.preventDefault();
|
||||
erase();
|
||||
erase("board");
|
||||
return;
|
||||
}
|
||||
if (!command) {
|
||||
@@ -657,7 +868,7 @@ export function Workbench() {
|
||||
const value = valueForKey(event.key, puzzle.size);
|
||||
if (value !== null) {
|
||||
event.preventDefault();
|
||||
enterValue(value);
|
||||
enterValue(value, "board");
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -789,16 +1000,18 @@ export function Workbench() {
|
||||
kind:
|
||||
result.count === 1 && !result.truncated
|
||||
? "success"
|
||||
: result.count === 0 || result.count >= 2
|
||||
: result.count >= 2 || (result.count === 0 && !result.truncated)
|
||||
? "error"
|
||||
: "info",
|
||||
message:
|
||||
result.count === 0
|
||||
? "The definition has no solution."
|
||||
: result.count >= 2
|
||||
? "The definition has multiple solutions."
|
||||
: result.truncated
|
||||
? "A solution was found, but uniqueness was not established before a safety limit."
|
||||
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) {
|
||||
@@ -828,7 +1041,7 @@ export function Workbench() {
|
||||
setWorkspace("generate");
|
||||
setFeedback({
|
||||
kind: "success",
|
||||
message: `Generated a unique ${generated.variant} puzzle. Rated ${generated.difficulty.label}${generated.difficulty.score === null ? "" : ` (${String(generated.difficulty.score)}/100)`}.`,
|
||||
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) {
|
||||
setFeedback({ kind: "error", message: errorMessage(error) });
|
||||
@@ -899,7 +1112,7 @@ export function Workbench() {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const document = fromDomainPuzzle(puzzle);
|
||||
const progress = progressFromSession(puzzle, session, solved);
|
||||
const progress = progressFromSession(puzzle, session, solved, aidMemoire);
|
||||
let record: SudokuProjectRecord;
|
||||
const existing = currentProjectId
|
||||
? await library.get(currentProjectId)
|
||||
@@ -928,7 +1141,7 @@ export function Workbench() {
|
||||
} finally {
|
||||
setLibraryBusy(false);
|
||||
}
|
||||
}, [currentProjectId, puzzle, refreshLibrary, session, solved]);
|
||||
}, [aidMemoire, currentProjectId, puzzle, refreshLibrary, session, solved]);
|
||||
|
||||
const openProject = useCallback(
|
||||
async (id: string) => {
|
||||
@@ -1032,13 +1245,167 @@ export function Workbench() {
|
||||
if (cells.length === 0) return;
|
||||
setSelection([...cells]);
|
||||
setActiveCell(cells[0]!);
|
||||
setAidMemoireActive(false);
|
||||
}, []);
|
||||
|
||||
const activeConstraints = useMemo(
|
||||
() => [...new Set(normalized.puzzle.constraints.map((item) => item.type))],
|
||||
[normalized.puzzle.constraints],
|
||||
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);
|
||||
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));
|
||||
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));
|
||||
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">
|
||||
@@ -1119,7 +1486,15 @@ export function Workbench() {
|
||||
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");
|
||||
}}
|
||||
@@ -1169,20 +1544,46 @@ export function Workbench() {
|
||||
<section className="board-column" aria-label="Puzzle board">
|
||||
<div className="board-toolbar">
|
||||
<div className="toolbar-group">
|
||||
<button type="button" disabled={!past.length} onClick={undo}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!past.length || replayMoment !== undefined}
|
||||
onClick={undo}
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
<button type="button" disabled={!future.length} onClick={redo}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!future.length || replayMoment !== undefined}
|
||||
onClick={redo}
|
||||
>
|
||||
Redo
|
||||
</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>
|
||||
{selection.length === 1
|
||||
? cellLabel(selection[0]!, puzzle.size)
|
||||
: `${String(selection.length)} cells selected`}
|
||||
{replayMoment
|
||||
? `Replay · ${replayMoment.label}`
|
||||
: aidMemoireActive && aidMemoire.enabled
|
||||
? `Aid-mémoire · ${aidMemoire.cells[aidMemoireCell]?.label || `cell ${String(aidMemoireCell + 1)}`}`
|
||||
: selection.length === 1
|
||||
? cellLabel(selection[0]!, puzzle.size)
|
||||
: `${String(selection.length)} cells selected`}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`board-surface${session.paused ? " is-paused" : ""}`}>
|
||||
<div
|
||||
className={`board-surface${session.paused && replayMoment === undefined ? " is-paused" : ""}${replayMoment ? " is-replay" : ""}`}
|
||||
>
|
||||
<SudokuBoard
|
||||
puzzle={normalized.puzzle}
|
||||
values={session.values}
|
||||
@@ -1190,16 +1591,19 @@ export function Workbench() {
|
||||
centerMarks={session.centerMarks}
|
||||
colors={session.colors}
|
||||
candidates={candidateMasks}
|
||||
selected={selectedSet}
|
||||
selected={boardSelectedSet}
|
||||
highlighted={highlightedCells}
|
||||
conflicts={conflictCells}
|
||||
activeCell={activeCell}
|
||||
showCandidates={showCandidates}
|
||||
candidateOverlay={
|
||||
workspace === "helpers" ? candidateOverlay : undefined
|
||||
}
|
||||
onCellPointerDown={handlePointerDown}
|
||||
onCellPointerEnter={handlePointerEnter}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{session.paused && (
|
||||
{session.paused && replayMoment === undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className="paused-cover"
|
||||
@@ -1225,6 +1629,25 @@ export function Workbench() {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
@@ -1247,13 +1670,37 @@ export function Workbench() {
|
||||
</section>
|
||||
|
||||
<aside className="side-panel">
|
||||
{workspace === "play" && (
|
||||
{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">
|
||||
Select one or several cells, then use the keypad or keyboard.
|
||||
{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>
|
||||
<NumberPad
|
||||
@@ -1323,6 +1770,22 @@ export function Workbench() {
|
||||
/>
|
||||
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
|
||||
@@ -1334,8 +1797,9 @@ export function Workbench() {
|
||||
</button>
|
||||
</section>
|
||||
<p className="muted shortcut-note">
|
||||
Z/X/C/V change mode · arrows move · Shift extends · Ctrl/⌘ Z
|
||||
undoes · Ctrl/⌘-click a placed digit highlights its matches.
|
||||
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>
|
||||
)}
|
||||
@@ -1401,9 +1865,13 @@ export function Workbench() {
|
||||
|
||||
{workspace === "helpers" && (
|
||||
<HelpersWorkspace
|
||||
key={puzzle.size}
|
||||
size={puzzle.size}
|
||||
puzzle={normalized.puzzle}
|
||||
values={session.values}
|
||||
selectedCells={selection}
|
||||
candidateMasks={candidateMasks}
|
||||
onCandidateOverlayChange={handleCandidateOverlayChange}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
@@ -1413,6 +1881,7 @@ export function Workbench() {
|
||||
open={importOpen}
|
||||
puzzle={puzzle}
|
||||
session={session}
|
||||
aidMemoire={aidMemoireToPortable(aidMemoire, puzzle.size)}
|
||||
onClose={() => setImportOpen(false)}
|
||||
onImport={(next, progress) => {
|
||||
try {
|
||||
@@ -1437,6 +1906,21 @@ export function Workbench() {
|
||||
onExport={() => void exportLibrary()}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user