feat: expand sudoku analysis and interoperability

This commit is contained in:
2026-08-30 23:21:56 +02:00
parent 4a9869baa0
commit 8ca9300ab3
73 changed files with 12482 additions and 384 deletions
+378
View File
@@ -0,0 +1,378 @@
import { useRef, type CSSProperties, type KeyboardEvent } from "react";
import {
aidMemoireCellDescription,
clearAidMemoireEntries,
compactAidMemoireColumns,
configureAidMemoire,
enterAidMemoireCell,
eraseAidMemoireCell,
labelAidMemoireCell,
MAX_AID_MEMOIRE_CELLS,
MAX_AID_MEMOIRE_COLUMNS,
resetAidMemoire,
setAidMemoireEnabled,
type AidMemoireState,
} from "../state/aidMemoire";
import {
maskValues,
symbolFor,
valueForKey,
type EntryMode,
} from "../state/session";
interface AidMemoireProps {
readonly size: number;
readonly state: AidMemoireState;
readonly selectedCell: number;
readonly active: boolean;
readonly readOnly?: boolean;
readonly mode: EntryMode;
readonly onStateChange: (state: AidMemoireState) => void;
readonly onSelect: (cell: number) => void;
readonly onMode: (mode: EntryMode) => void;
}
const modes: ReadonlyArray<{ mode: EntryMode; label: string; key: string }> = [
{ mode: "value", label: "Value", key: "Z" },
{ mode: "corner", label: "Corner", key: "X" },
{ mode: "center", label: "Centre", key: "C" },
{ mode: "color", label: "Colour", key: "V" },
];
export function AidMemoire({
size,
state,
selectedCell,
active,
readOnly = false,
mode,
onStateChange,
onSelect,
onMode,
}: AidMemoireProps) {
const cellRefs = useRef<Array<HTMLButtonElement | null>>([]);
const selected = Math.max(0, Math.min(state.cells.length - 1, selectedCell));
const selectAndFocus = (cell: number) => {
const next = Math.max(0, Math.min(state.cells.length - 1, cell));
onSelect(next);
cellRefs.current[next]?.focus();
};
const handleKeyDown = (
cell: number,
event: KeyboardEvent<HTMLButtonElement>,
) => {
const row = Math.floor(cell / state.columns);
const column = cell % state.columns;
const rowStart = row * state.columns;
const rowEnd = Math.min(
state.cells.length - 1,
rowStart + state.columns - 1,
);
let destination: number | undefined;
if (event.key === "ArrowLeft") destination = Math.max(rowStart, cell - 1);
if (event.key === "ArrowRight") destination = Math.min(rowEnd, cell + 1);
if (event.key === "ArrowUp") {
destination = cell - state.columns >= 0 ? cell - state.columns : cell;
}
if (event.key === "ArrowDown") {
destination =
cell + state.columns < state.cells.length ? cell + state.columns : cell;
}
if (event.key === "Home") {
destination = event.ctrlKey || event.metaKey ? 0 : rowStart;
}
if (event.key === "End") {
destination =
event.ctrlKey || event.metaKey ? state.cells.length - 1 : rowEnd;
}
if (event.key === "PageUp") destination = column;
if (event.key === "PageDown") {
const lastRowStart =
Math.floor((state.cells.length - 1) / state.columns) * state.columns;
destination = Math.min(state.cells.length - 1, lastRowStart + column);
}
if (destination !== undefined) {
event.preventDefault();
selectAndFocus(destination);
return;
}
if (readOnly) return;
if (
event.key === "Backspace" ||
event.key === "Delete" ||
event.key === "0"
) {
event.preventDefault();
onStateChange(eraseAidMemoireCell(state, cell, mode));
return;
}
if (!event.ctrlKey && !event.metaKey && !event.altKey) {
const modeKey: Record<string, EntryMode> = {
z: "value",
x: "corner",
c: "center",
v: "color",
};
const nextMode = modeKey[event.key.toLowerCase()];
if (nextMode !== undefined) {
event.preventDefault();
onMode(nextMode);
return;
}
const value = valueForKey(event.key, mode === "color" ? 8 : size);
if (value !== null) {
event.preventDefault();
onStateChange(enterAidMemoireCell(state, cell, mode, value, size));
}
}
};
const configure = (cellCount: number, columns: number) => {
const next = configureAidMemoire(state, size, cellCount, columns);
onStateChange(next);
if (selected >= next.cells.length) onSelect(next.cells.length - 1);
};
return (
<section className="aid-memoire" aria-labelledby="aid-memoire-title">
<header className="section-heading aid-memoire__heading">
<div>
<p className="eyebrow">Optional scratch cells</p>
<h2 id="aid-memoire-title">Aid-mémoire</h2>
<p className="muted">
Track contextual sets, pseudo-digits or mappings. These cells do not
constrain the Sudoku.
</p>
</div>
<button
type="button"
className="text-button"
disabled={readOnly}
onClick={() => onStateChange(setAidMemoireEnabled(state, false))}
>
Hide
</button>
</header>
<div
className="aid-memoire__scroller"
aria-label="Scrollable aid-mémoire area"
>
<div
className="aid-memoire__grid"
role="grid"
aria-label={`${String(state.cells.length)} aid-mémoire scratch cells`}
aria-colcount={state.columns}
aria-rowcount={Math.ceil(state.cells.length / state.columns)}
aria-readonly={readOnly}
style={{ "--aid-columns": state.columns } as CSSProperties}
>
{Array.from(
{ length: Math.ceil(state.cells.length / state.columns) },
(_, rowIndex) => (
<div
key={rowIndex}
className="aid-memoire__row"
role="row"
aria-rowindex={rowIndex + 1}
>
{state.cells
.slice(
rowIndex * state.columns,
(rowIndex + 1) * state.columns,
)
.map((cell, columnIndex) => {
const index = rowIndex * state.columns + columnIndex;
const cornerMarks = maskValues(cell.cornerMarks, size);
const centerMarks = maskValues(cell.centerMarks, size);
return (
<button
key={index}
ref={(element) => {
cellRefs.current[index] = element;
}}
type="button"
role="gridcell"
className={`aid-memoire__cell${active && selected === index ? " is-selected" : ""}${cell.color ? ` has-color-${String(cell.color)}` : ""}`}
aria-label={aidMemoireCellDescription(
cell,
index,
size,
)}
aria-colindex={columnIndex + 1}
aria-selected={active && selected === index}
tabIndex={selected === index ? 0 : -1}
onClick={() => {
if (!readOnly) onSelect(index);
}}
onFocus={() => {
if (!readOnly) onSelect(index);
}}
onKeyDown={(event) => handleKeyDown(index, event)}
>
<span className="aid-memoire__label" aria-hidden="true">
{cell.label || `#${String(index + 1)}`}
</span>
{cell.value ? (
<span
className="aid-memoire__value"
aria-hidden="true"
>
{symbolFor(cell.value, size)}
</span>
) : (
<>
<span
className="aid-memoire__corner"
aria-hidden="true"
>
{cornerMarks
.map((mark) => symbolFor(mark, size))
.join(" ")}
</span>
<span
className="aid-memoire__centre"
aria-hidden="true"
>
{centerMarks
.map((mark) => symbolFor(mark, size))
.join("")}
</span>
</>
)}
</button>
);
})}
</div>
),
)}
</div>
</div>
<div
className="mode-switcher aid-memoire__modes"
role="group"
aria-label="Aid-mémoire entry mode"
>
{modes.map((item) => (
<button
key={item.mode}
type="button"
disabled={readOnly}
className={mode === item.mode ? "is-active" : ""}
aria-pressed={mode === item.mode}
title={`${item.label} mode (${item.key})`}
onClick={() => onMode(item.mode)}
>
{item.label}
</button>
))}
</div>
<div className="aid-memoire__selected">
<label>
Selected cell label
<input
value={state.cells[selected]?.label ?? ""}
maxLength={40}
disabled={readOnly}
onChange={(event) =>
onStateChange(
labelAidMemoireCell(state, selected, event.target.value),
)
}
/>
</label>
<p className="muted">
The regular keypad edits this cell in {mode} mode. Digits, Z/X/C/V,
Delete and arrow keys also work directly in the scratch grid.
</p>
</div>
<details className="aid-memoire__settings">
<summary>Configure scratch layout</summary>
<div className="aid-memoire__settings-body">
<div className="inline-fields">
<label>
Cells
<input
type="number"
min={1}
max={MAX_AID_MEMOIRE_CELLS}
value={state.cells.length}
disabled={readOnly}
onChange={(event) =>
configure(Number(event.target.value), state.columns)
}
/>
</label>
<label>
Columns
<input
type="number"
min={1}
max={Math.min(MAX_AID_MEMOIRE_COLUMNS, state.cells.length)}
value={state.columns}
disabled={readOnly}
onChange={(event) =>
configure(state.cells.length, Number(event.target.value))
}
/>
</label>
</div>
<div className="action-row">
<button
type="button"
disabled={readOnly}
onClick={() => configure(state.cells.length, state.cells.length)}
>
Single row
</button>
<button
type="button"
disabled={readOnly}
onClick={() =>
configure(
state.cells.length,
compactAidMemoireColumns(state.cells.length),
)
}
>
Compact grid
</button>
<button
type="button"
disabled={readOnly}
onClick={() => {
if (window.confirm("Clear every aid-mémoire entry?")) {
onStateChange(clearAidMemoireEntries(state));
}
}}
>
Clear entries
</button>
<button
type="button"
className="danger"
disabled={readOnly}
onClick={() => {
if (
window.confirm(
"Reset the aid-mémoire layout, labels and entries?",
)
) {
onStateChange(resetAidMemoire(state, size));
onSelect(0);
}
}}
>
Reset all
</button>
</div>
</div>
</details>
</section>
);
}
+408
View File
@@ -0,0 +1,408 @@
import { useEffect, useMemo, useState } from "react";
import { compilePuzzle, type NormalizedPuzzle } from "../domain";
import {
candidateCellsForValues,
candidateUnitIndicesForCells,
cellLabel,
deriveCandidateLinks,
houseLabel,
inspectCandidateCells,
inspectCandidateHouses,
type CandidateLink,
type CandidateOverlay,
} from "../helpers";
import { symbolFor } from "../state/session";
const LINK_LIST_LIMIT = 160;
const OVERLAY_LINK_LIMIT = 500;
interface CandidateLabProps {
readonly puzzle: NormalizedPuzzle;
readonly values: readonly number[];
readonly candidateMasks: readonly number[];
readonly selectedCells: readonly number[];
readonly onOverlayChange?: (overlay: CandidateOverlay | undefined) => void;
}
function nodeLabel(node: CandidateLink["a"], size: number): string {
return `${cellLabel(node.cell, size)}(${symbolFor(node.value, size)})`;
}
function linkLabel(link: CandidateLink, size: number): string {
return `${nodeLabel(link.a, size)} ${link.kind === "strong" ? "⇔" : "—"} ${nodeLabel(link.b, size)}`;
}
export function CandidateLab({
puzzle,
values,
candidateMasks,
selectedCells,
onOverlayChange,
}: CandidateLabProps) {
const [activeValues, setActiveValues] = useState<readonly number[]>([]);
const [scope, setScope] = useState("selection");
const [showStrong, setShowStrong] = useState(true);
const [showWeak, setShowWeak] = useState(false);
const [includeCellLinks, setIncludeCellLinks] = useState(true);
const [focusedLinkId, setFocusedLinkId] = useState<string>();
const compiled = useMemo(() => compilePuzzle(puzzle), [puzzle]);
const effectiveActiveValues = useMemo(
() => activeValues.filter((value) => value <= puzzle.size),
[activeValues, puzzle.size],
);
const relatedUnitIndices = useMemo(
() => candidateUnitIndicesForCells(puzzle, selectedCells),
[puzzle, selectedCells],
);
const selectedUnitIndex = scope.startsWith("unit:")
? Number(scope.slice(5))
: undefined;
const effectiveScope =
selectedUnitIndex !== undefined &&
!relatedUnitIndices.includes(selectedUnitIndex)
? "selection"
: scope;
const scopedUnitIndices = useMemo(() => {
if (effectiveScope === "all")
return compiled.units.map((_, index) => index);
if (
selectedUnitIndex !== undefined &&
Number.isInteger(selectedUnitIndex) &&
compiled.units[selectedUnitIndex] !== undefined
) {
return [selectedUnitIndex];
}
return relatedUnitIndices;
}, [compiled.units, effectiveScope, relatedUnitIndices, selectedUnitIndex]);
const scopedCells = useMemo(() => {
if (effectiveScope === "selection") return [...new Set(selectedCells)];
return [
...new Set(
scopedUnitIndices.flatMap(
(unitIndex) => compiled.units[unitIndex]?.cells ?? [],
),
),
];
}, [compiled.units, effectiveScope, scopedUnitIndices, selectedCells]);
const cellInspections = useMemo(
() => inspectCandidateCells(puzzle, candidateMasks, selectedCells),
[candidateMasks, puzzle, selectedCells],
);
const houseInspections = useMemo(
() =>
inspectCandidateHouses(puzzle, values, candidateMasks, scopedUnitIndices),
[candidateMasks, puzzle, scopedUnitIndices, values],
);
const links = useMemo(
() =>
deriveCandidateLinks(puzzle, candidateMasks, {
unitIndices: scopedUnitIndices,
cellIndices: scopedCells,
...(effectiveActiveValues.length === 0
? {}
: { values: effectiveActiveValues }),
includeCellLinks,
}).filter(({ kind }) => (kind === "strong" ? showStrong : showWeak)),
[
candidateMasks,
effectiveActiveValues,
includeCellLinks,
puzzle,
scopedCells,
scopedUnitIndices,
showStrong,
showWeak,
],
);
const focusedLink = links.find(({ id }) => id === focusedLinkId);
const visibleLinks = useMemo(
() =>
focusedLink === undefined
? links.slice(0, OVERLAY_LINK_LIMIT)
: [focusedLink],
[focusedLink, links],
);
const candidateCells = useMemo(() => {
if (effectiveActiveValues.length === 0) return [];
return candidateCellsForValues(candidateMasks, effectiveActiveValues);
}, [candidateMasks, effectiveActiveValues]);
const overlay = useMemo<CandidateOverlay>(
() => ({
activeValues: effectiveActiveValues,
candidateCells,
links: visibleLinks,
}),
[candidateCells, effectiveActiveValues, visibleLinks],
);
useEffect(() => {
onOverlayChange?.(overlay);
}, [onOverlayChange, overlay]);
useEffect(
() => () => {
onOverlayChange?.(undefined);
},
[onOverlayChange],
);
const toggleValue = (value: number) => {
setFocusedLinkId(undefined);
setActiveValues((current) => {
const valid = current.filter((candidate) => candidate <= puzzle.size);
return valid.includes(value)
? valid.filter((candidate) => candidate !== value)
: [...valid, value].sort((a, b) => a - b);
});
};
const strongCount = links.filter(({ kind }) => kind === "strong").length;
const weakCount = links.length - strongCount;
return (
<section
className="candidate-lab stack"
aria-labelledby="candidate-lab-title"
>
<div>
<p className="eyebrow">Candidate graph</p>
<h3 id="candidate-lab-title">Links and houses</h3>
<p className="muted">
Inspect legal candidates without changing any notes. A solid line is
strong (at least one endpoint is true); a dashed line is weak (both
endpoints cannot be true).
</p>
</div>
<fieldset className="candidate-filter">
<legend>Digit filters</legend>
<div
className={`candidate-filter__digits${puzzle.size > 9 ? " candidate-filter__digits--wide" : ""}`}
>
<button
type="button"
className={effectiveActiveValues.length === 0 ? "is-active" : ""}
aria-pressed={effectiveActiveValues.length === 0}
onClick={() => {
setActiveValues([]);
setFocusedLinkId(undefined);
}}
>
All
</button>
{Array.from({ length: puzzle.size }, (_, index) => index + 1).map(
(value) => (
<button
key={value}
type="button"
className={
effectiveActiveValues.includes(value) ? "is-active" : ""
}
aria-label={`Filter candidate ${symbolFor(value, puzzle.size)}`}
aria-pressed={effectiveActiveValues.includes(value)}
onClick={() => toggleValue(value)}
>
{symbolFor(value, puzzle.size)}
</button>
),
)}
</div>
</fieldset>
<div className="candidate-lab__controls">
<label>
Inspect
<select
aria-label="Candidate house scope"
value={effectiveScope}
onChange={(event) => {
setScope(event.target.value);
setFocusedLinkId(undefined);
}}
>
<option value="selection">
Selected cells and touching houses
</option>
{relatedUnitIndices.map((unitIndex) => (
<option key={unitIndex} value={`unit:${String(unitIndex)}`}>
{houseLabel(compiled.units[unitIndex]!)}
</option>
))}
<option value="all">Whole grid</option>
</select>
</label>
<label className="check-row">
<input
type="checkbox"
checked={showStrong}
onChange={(event) => {
setShowStrong(event.target.checked);
setFocusedLinkId(undefined);
}}
/>
Strong links
</label>
<label className="check-row">
<input
type="checkbox"
checked={showWeak}
onChange={(event) => {
setShowWeak(event.target.checked);
setFocusedLinkId(undefined);
}}
/>
Weak links
</label>
<label className="check-row">
<input
type="checkbox"
checked={includeCellLinks}
onChange={(event) => {
setIncludeCellLinks(event.target.checked);
setFocusedLinkId(undefined);
}}
/>
Links inside cells
</label>
</div>
<div className="metric-row" aria-live="polite">
<span>
Candidate cells <strong>{candidateCells.length}</strong>
</span>
<span>
Strong links <strong>{strongCount}</strong>
</span>
<span>
Weak links <strong>{weakCount}</strong>
</span>
</div>
<section
className="candidate-inspection"
aria-labelledby="selected-candidates-title"
>
<h4 id="selected-candidates-title">Selected cells</h4>
<div className="candidate-cell-list">
{cellInspections.map((inspection) => (
<article key={inspection.cell}>
<strong>{cellLabel(inspection.cell, puzzle.size)}</strong>
<span>
{inspection.values.length === 0
? values[inspection.cell]
? `Filled ${symbolFor(values[inspection.cell]!, puzzle.size)}`
: "No legal candidates"
: inspection.values
.map((value) => symbolFor(value, puzzle.size))
.join(" ")}
</span>
<small>{inspection.houseLabels.join(" · ")}</small>
</article>
))}
</div>
</section>
<section
className="candidate-inspection"
aria-labelledby="house-candidates-title"
>
<h4 id="house-candidates-title">House positions</h4>
<div className="candidate-house-list">
{houseInspections.map((inspection) => (
<details
key={inspection.unitIndex}
open={houseInspections.length === 1}
>
<summary>
<strong>{inspection.label}</strong>
<span>{inspection.missingValues.length} missing</span>
</summary>
<dl>
{inspection.positions
.filter(
({ value }) =>
effectiveActiveValues.length === 0 ||
effectiveActiveValues.includes(value),
)
.map((position) => (
<div
key={position.value}
data-link-kind={position.linkKind}
>
<dt>{symbolFor(position.value, puzzle.size)}</dt>
<dd>
{position.cells.length === 0
? "nowhere"
: position.cells
.map((cell) => cellLabel(cell, puzzle.size))
.join(" · ")}
{position.linkKind === "strong" ? " · strong" : ""}
</dd>
</div>
))}
</dl>
</details>
))}
</div>
</section>
<section
className="candidate-inspection"
aria-labelledby="candidate-links-title"
>
<div className="candidate-section-heading">
<h4 id="candidate-links-title">Visible links</h4>
{focusedLink !== undefined && (
<button
type="button"
className="text-button"
onClick={() => setFocusedLinkId(undefined)}
>
Show all
</button>
)}
</div>
{links.length === 0 ? (
<p className="muted">
No links match this scope and filter. Enable weak links or inspect
another house.
</p>
) : (
<div className="candidate-link-list">
{links.slice(0, LINK_LIST_LIMIT).map((link) => (
<button
key={link.id}
type="button"
className={`candidate-link candidate-link--${link.kind}`}
aria-pressed={focusedLink?.id === link.id}
title={link.contexts.map(({ label }) => label).join(", ")}
onClick={() =>
setFocusedLinkId((current) =>
current === link.id ? undefined : link.id,
)
}
>
<span>{linkLabel(link, puzzle.size)}</span>
<small>
{link.kind} ·{" "}
{link.contexts.map(({ label }) => label).join(" / ")}
</small>
</button>
))}
</div>
)}
{links.length > LINK_LIST_LIMIT && (
<p className="muted">
Showing the first {LINK_LIST_LIMIT} of {links.length} links. Narrow
the digit or house filter to inspect the rest.
</p>
)}
{focusedLink === undefined && links.length > OVERLAY_LINK_LIMIT && (
<p className="muted">
The board overlay is capped at {OVERLAY_LINK_LIMIT} links for
responsiveness. Focus a listed link or narrow the filters to see a
precise relationship.
</p>
)}
</section>
</section>
);
}
+362 -34
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { cellsFormQuadruple } from "../domain/geometry";
import type { PuzzleDefinition, VariantConstraint } from "../domain/types";
import {
removeKillerCagesAtCells,
@@ -19,6 +20,10 @@ interface ConstraintEditorProps {
function describeConstraint(constraint: VariantConstraint, size: number) {
const cell = (index: number) =>
`r${String(Math.floor(index / size) + 1)}c${String((index % size) + 1)}`;
const marked = (description: string) =>
"negated" in constraint && constraint.negated === true
? `false · ${description}`
: description;
switch (constraint.type) {
case "diagonal":
return `${constraint.direction} diagonal`;
@@ -29,22 +34,67 @@ function describeConstraint(constraint: VariantConstraint, size: number) {
case "non-consecutive":
return "non-consecutive";
case "killer-cage":
return `${String(constraint.sum)} cage · ${String(constraint.cells.length)} cells`;
return marked(
`${String(constraint.sum)} cage · ${String(constraint.cells.length)} cells${constraint.noRepeat === false ? " · repeats allowed" : ""}`,
);
case "thermo":
case "renban":
case "palindrome":
return `${constraint.type} · ${String(constraint.cells.length)} cells`;
return marked(
`${constraint.type} · ${String(constraint.cells.length)} cells`,
);
case "arrow":
return `arrow · ${String(constraint.bulb.length)} bulb / ${String(constraint.line.length)} line`;
return marked(
`arrow · ${String(constraint.bulb.length)} bulb / ${String(constraint.line.length)} line`,
);
case "kropki":
return `${constraint.kind} dot · ${cell(constraint.a)}${cell(constraint.b)}`;
return marked(
`${constraint.kind} dot · ${cell(constraint.a)}${cell(constraint.b)}`,
);
case "xv":
return `sum ${String(constraint.total)} pair · ${cell(constraint.a)}${cell(constraint.b)}`;
return marked(
`sum ${String(constraint.total)} pair · ${cell(constraint.a)}${cell(constraint.b)}`,
);
case "inequality":
return `${cell(constraint.lesser)} < ${cell(constraint.greater)}`;
return marked(`${cell(constraint.lesser)} < ${cell(constraint.greater)}`);
case "x-sum":
return marked(
`Σ${String(constraint.sum)} · ${constraint.side} ${String(constraint.index + 1)}`,
);
case "skyscraper":
return marked(
`skyscraper ${String(constraint.count)} · ${constraint.side} ${String(constraint.index + 1)}`,
);
case "quadruple":
return marked(
`quadruple ${constraint.digits.join("")} · ${String(constraint.cells.length)} cells`,
);
case "maximum":
return marked(`maximum · ${cell(constraint.cell)}`);
}
}
function supportsPolarity(constraint: VariantConstraint): boolean {
return !["diagonal", "anti-knight", "anti-king", "non-consecutive"].includes(
constraint.type,
);
}
function parseClueDigits(text: string, size: number): number[] {
const trimmed = text.trim();
if (!trimmed) return [];
const rawTokens = trimmed.split(/[\s,;]+/u).filter(Boolean);
const tokens =
size <= 9 && rawTokens.length === 1 && /^\d{2,4}$/u.test(rawTokens[0]!)
? [...rawTokens[0]!]
: rawTokens;
return tokens.map((token) =>
/^[A-G]$/iu.test(token)
? token.toUpperCase().charCodeAt(0) - 55
: Number(token),
);
}
export function ConstraintEditor({
puzzle,
selection,
@@ -55,23 +105,55 @@ export function ConstraintEditor({
busy,
}: ConstraintEditorProps) {
const [cageSum, setCageSum] = useState(10);
const [cageAllowsRepeats, setCageAllowsRepeats] = useState(false);
const [region, setRegion] = useState(1);
const [newCluesAreFalse, setNewCluesAreFalse] = useState(false);
const [quadrupleDigits, setQuadrupleDigits] = useState("1, 2, 3");
const [outsideType, setOutsideType] = useState<"x-sum" | "skyscraper">(
"x-sum",
);
const [outsideSide, setOutsideSide] = useState<
"top" | "right" | "bottom" | "left"
>("top");
const [outsideLine, setOutsideLine] = useState(1);
const [outsideValue, setOutsideValue] = useState(3);
const constraints = puzzle.constraints ?? [];
const append = (constraint: VariantConstraint) =>
onChange({ ...puzzle, constraints: [...constraints, constraint] });
onChange({
...puzzle,
constraints: [
...constraints,
supportsPolarity(constraint) && newCluesAreFalse
? ({ ...constraint, negated: true } as VariantConstraint)
: constraint,
],
});
const need = (count: number) => selection.length === count;
const atLeast = (count: number) => selection.length >= count;
const cageCellCount = Math.max(1, selection.length);
const minimumCageSum = (cageCellCount * (cageCellCount + 1)) / 2;
const maximumCageSum =
(cageCellCount * (2 * puzzle.size - cageCellCount + 1)) / 2;
const minimumCageSum = cageAllowsRepeats
? cageCellCount
: (cageCellCount * (cageCellCount + 1)) / 2;
const maximumCageSum = cageAllowsRepeats
? cageCellCount * puzzle.size
: (cageCellCount * (2 * puzzle.size - cageCellCount + 1)) / 2;
const validCage =
selection.length >= 1 &&
selection.length <= puzzle.size &&
Number.isInteger(cageSum) &&
cageSum >= minimumCageSum &&
cageSum <= maximumCageSum;
(newCluesAreFalse
? cageSum >= 1 && cageSum <= puzzle.size ** 3
: cageSum >= minimumCageSum && cageSum <= maximumCageSum);
const parsedQuadrupleDigits = parseClueDigits(quadrupleDigits, puzzle.size);
const validQuadruple =
cellsFormQuadruple(puzzle.size, selection) &&
parsedQuadrupleDigits.length >= 1 &&
parsedQuadrupleDigits.length <= 4 &&
parsedQuadrupleDigits.length <= selection.length &&
parsedQuadrupleDigits.every(
(digit) => Number.isInteger(digit) && digit >= 1 && digit <= puzzle.size,
);
const selectedCageExists = selectionTouchesKillerCage(constraints, selection);
const toggleGlobal = (
@@ -154,13 +236,21 @@ export function ConstraintEditor({
Selection order defines lines and pair direction. Shift-click or drag
to build a selection.
</p>
<label className="check-row">
<input
type="checkbox"
checked={newCluesAreFalse}
onChange={(event) => setNewCluesAreFalse(event.target.checked)}
/>
Newly added clues must be false (Wrogn mode)
</label>
<div className="inline-fields">
<label className="compact-field">
Cage sum
<input
type="number"
min={minimumCageSum}
max={maximumCageSum}
min={newCluesAreFalse ? 1 : minimumCageSum}
max={newCluesAreFalse ? puzzle.size ** 3 : maximumCageSum}
value={cageSum}
onChange={(event) => setCageSum(Number(event.target.value))}
/>
@@ -175,6 +265,8 @@ export function ConstraintEditor({
type: "killer-cage",
cells: selection,
sum: cageSum,
...(cageAllowsRepeats ? { noRepeat: false } : {}),
...(newCluesAreFalse ? { negated: true } : {}),
}),
})
}
@@ -195,6 +287,14 @@ export function ConstraintEditor({
Remove selected cage
</button>
</div>
<label className="check-row">
<input
type="checkbox"
checked={cageAllowsRepeats}
onChange={(event) => setCageAllowsRepeats(event.target.checked)}
/>
Cage digits may repeat
</label>
<p className="muted">
Adding replaces any cage touching the selection. Removing clears every
cage touching a selected cell.
@@ -316,7 +416,41 @@ export function ConstraintEditor({
>
1st &gt; 2nd (inequality)
</button>
<button
type="button"
disabled={!need(1)}
onClick={() => append({ type: "maximum", cell: selection[0]! })}
>
Maximum cell
</button>
</div>
<div className="inline-fields">
<label className="compact-field">
Quadruple digits
<input
value={quadrupleDigits}
placeholder="1, 3, 8"
onChange={(event) => setQuadrupleDigits(event.target.value)}
/>
</label>
<button
type="button"
disabled={!validQuadruple}
onClick={() =>
append({
type: "quadruple",
cells: selection,
digits: parsedQuadrupleDigits,
})
}
>
Add quadruple
</button>
</div>
<p className="muted">
Select the four cells meeting at one grid intersection for a
quadruple.
</p>
<div className="inline-fields">
<label className="compact-field">
Region
@@ -342,6 +476,120 @@ export function ConstraintEditor({
</div>
</section>
<section className="panel-section">
<p className="eyebrow">Outside clues</p>
<h3>X-sums and skyscrapers</h3>
<p className="muted">
Choose the edge and row or column. Σ badges are X-sums; badges are
visibility counts.
</p>
<div className="inline-fields outside-clue-fields">
<label className="compact-field">
Type
<select
value={outsideType}
onChange={(event) =>
setOutsideType(event.target.value as "x-sum" | "skyscraper")
}
>
<option value="x-sum">X-sum</option>
<option value="skyscraper">Skyscraper</option>
</select>
</label>
<label className="compact-field">
Side
<select
value={outsideSide}
onChange={(event) =>
setOutsideSide(
event.target.value as "top" | "right" | "bottom" | "left",
)
}
>
<option value="top">Top</option>
<option value="right">Right</option>
<option value="bottom">Bottom</option>
<option value="left">Left</option>
</select>
</label>
<label className="compact-field">
Row / column
<input
type="number"
min="1"
max={puzzle.size}
value={outsideLine}
onChange={(event) => setOutsideLine(Number(event.target.value))}
/>
</label>
<label className="compact-field">
Clue
<input
type="number"
min="1"
max={
newCluesAreFalse
? puzzle.size ** 4
: outsideType === "x-sum"
? (puzzle.size * (puzzle.size + 1)) / 2
: puzzle.size
}
value={outsideValue}
onChange={(event) => setOutsideValue(Number(event.target.value))}
/>
</label>
<button
type="button"
disabled={
!Number.isInteger(outsideLine) ||
outsideLine < 1 ||
outsideLine > puzzle.size ||
!Number.isInteger(outsideValue) ||
outsideValue < 1 ||
outsideValue >
(newCluesAreFalse
? puzzle.size ** 4
: outsideType === "x-sum"
? (puzzle.size * (puzzle.size + 1)) / 2
: puzzle.size)
}
onClick={() => {
const clue: VariantConstraint =
outsideType === "x-sum"
? {
type: "x-sum",
side: outsideSide,
index: outsideLine - 1,
sum: outsideValue,
}
: {
type: "skyscraper",
side: outsideSide,
index: outsideLine - 1,
count: outsideValue,
};
const withoutExisting = constraints.filter(
(constraint) =>
constraint.type !== outsideType ||
constraint.side !== outsideSide ||
constraint.index !== outsideLine - 1,
);
onChange({
...puzzle,
constraints: [
...withoutExisting,
newCluesAreFalse
? ({ ...clue, negated: true } as VariantConstraint)
: clue,
],
});
}}
>
Add / replace outside clue
</button>
</div>
</section>
<section className="panel-section">
<p className="eyebrow">Global rules</p>
<div className="button-grid">
@@ -398,27 +646,107 @@ export function ConstraintEditor({
{constraints.length > 0 && (
<section className="panel-section">
<p className="eyebrow">Constraints</p>
<div className="section-heading">
<p className="eyebrow">Constraints</p>
<div className="constraint-batch-actions">
<button
className="text-button"
type="button"
onClick={() =>
onChange({
...puzzle,
constraints: constraints.map((constraint) =>
supportsPolarity(constraint)
? ({
...constraint,
negated: true,
} as VariantConstraint)
: constraint,
),
})
}
>
Make all clues false
</button>
<button
className="text-button"
type="button"
onClick={() =>
onChange({
...puzzle,
constraints: constraints.map((constraint) => {
if (!supportsPolarity(constraint)) return constraint;
const cleaned = { ...constraint } as VariantConstraint & {
negated?: boolean;
};
delete cleaned.negated;
return cleaned;
}),
})
}
>
Make all clues true
</button>
</div>
</div>
<ul className="constraint-list">
{constraints.map((constraint, index) => (
<li key={`${constraint.type}-${String(index)}`}>
<span>{describeConstraint(constraint, puzzle.size)}</span>
<button
className="text-button danger"
type="button"
onClick={() =>
onChange({
...puzzle,
constraints: constraints.filter(
(_, item) => item !== index,
),
})
}
>
Remove
</button>
</li>
))}
{constraints.map((constraint, index) => {
const description = describeConstraint(constraint, puzzle.size);
const negated =
"negated" in constraint && constraint.negated === true;
return (
<li key={`${constraint.type}-${String(index)}`}>
<span>{description}</span>
<span className="constraint-actions">
{supportsPolarity(constraint) && (
<button
className="text-button"
type="button"
aria-label={`${negated ? "Require true for" : "Require false for"} ${description}`}
onClick={() =>
onChange({
...puzzle,
constraints: constraints.map((item, itemIndex) => {
if (itemIndex !== index) return item;
if ("negated" in item && item.negated === true) {
const cleaned = {
...item,
} as VariantConstraint & {
negated?: boolean;
};
delete cleaned.negated;
return cleaned;
}
return {
...item,
negated: true,
} as VariantConstraint;
}),
})
}
>
{negated ? "Require true" : "Require false"}
</button>
)}
<button
className="text-button danger"
type="button"
aria-label={`Remove ${description}`}
onClick={() =>
onChange({
...puzzle,
constraints: constraints.filter(
(_, item) => item !== index,
),
})
}
>
Remove
</button>
</span>
</li>
);
})}
</ul>
</section>
)}
+295
View File
@@ -0,0 +1,295 @@
import { useMemo, useState, type FormEvent } from "react";
import {
activeHypothesis,
gameplayMoment,
MAIN_BRANCH_ID,
type GameplayHistory,
} from "../state/playHistory";
import { Modal } from "./Modal";
interface GameplayHistoryDialogProps {
readonly open: boolean;
readonly history: GameplayHistory;
readonly replayMomentId?: string;
readonly onClose: () => void;
readonly onCreateSavepoint: (name: string) => void;
readonly onRestoreSavepoint: (savepointId: string) => void;
readonly onDeleteSavepoint: (savepointId: string) => void;
readonly onStartHypothesis: (name: string, fromMomentId?: string) => void;
readonly onFinishHypothesis: (decision: "keep" | "discard") => void;
readonly onReplayMoment: (momentId: string) => void;
readonly onReturnLive: () => void;
}
function formatElapsed(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(":");
}
export function GameplayHistoryDialog({
open,
history,
replayMomentId,
onClose,
onCreateSavepoint,
onRestoreSavepoint,
onDeleteSavepoint,
onStartHypothesis,
onFinishHypothesis,
onReplayMoment,
onReturnLive,
}: GameplayHistoryDialogProps) {
const [hypothesisName, setHypothesisName] = useState("");
const [savepointName, setSavepointName] = useState("");
const hypothesis = activeHypothesis(history);
const replayMoment = replayMomentId
? gameplayMoment(history, replayMomentId)
: undefined;
const replayIndex = replayMoment
? history.moments.findIndex((moment) => moment.id === replayMoment.id)
: history.moments.length - 1;
const branchNames = useMemo(
() =>
new Map([
[MAIN_BRANCH_ID, "Main solve"],
...history.branches.map((branch) => [branch.id, branch.name] as const),
]),
[history.branches],
);
const submitHypothesis = (event: FormEvent) => {
event.preventDefault();
const name = hypothesisName.trim();
if (!name) return;
onStartHypothesis(name, replayMoment?.id);
setHypothesisName("");
};
const submitSavepoint = (event: FormEvent) => {
event.preventDefault();
const name = savepointName.trim();
if (!name) return;
onCreateSavepoint(name);
setSavepointName("");
};
return (
<Modal
open={open}
title="Branches, savepoints and replay"
onClose={onClose}
wide
>
<div className="history-dialog-grid">
<div className="stack">
<section className="panel-section history-section">
<div>
<p className="eyebrow">Hypotheses</p>
<h3>Try a path without losing your solve</h3>
</div>
{hypothesis ? (
<div className="hypothesis-active" role="status">
<div>
<strong>{hypothesis.name}</strong>
<span>Every move is being kept on this branch.</span>
</div>
<div className="action-row">
<button
type="button"
onClick={() => onFinishHypothesis("keep")}
>
Keep changes
</button>
<button
type="button"
className="danger"
onClick={() => onFinishHypothesis("discard")}
>
Discard and return
</button>
</div>
</div>
) : (
<form className="history-name-form" onSubmit={submitHypothesis}>
<label>
Hypothesis name
<input
value={hypothesisName}
maxLength={80}
placeholder="e.g. Assume r4c7 is 8"
onChange={(event) => setHypothesisName(event.target.value)}
/>
</label>
<button type="submit" disabled={!hypothesisName.trim()}>
{replayMoment ? "Branch from this step" : "Start hypothesis"}
</button>
</form>
)}
{history.branches.length > 0 && (
<ul className="branch-list" aria-label="Hypothesis branches">
{history.branches.map((branch) => (
<li key={branch.id}>
<span>{branch.name}</span>
<span
className={`branch-status branch-status--${branch.status}`}
>
{branch.status}
</span>
</li>
))}
</ul>
)}
</section>
<section className="panel-section history-section">
<div>
<p className="eyebrow">Named savepoints</p>
<h3>Return to a deliberate checkpoint</h3>
</div>
<form className="history-name-form" onSubmit={submitSavepoint}>
<label>
Savepoint name
<input
value={savepointName}
maxLength={80}
disabled={replayMoment !== undefined}
placeholder="e.g. Before the long chain"
onChange={(event) => setSavepointName(event.target.value)}
/>
</label>
<button
type="submit"
disabled={!savepointName.trim() || replayMoment !== undefined}
>
Save current grid
</button>
</form>
{history.savepoints.length === 0 ? (
<p className="muted">No savepoints yet.</p>
) : (
<ul className="savepoint-list" aria-label="Named savepoints">
{history.savepoints.map((savepoint) => (
<li key={savepoint.id}>
<div>
<strong>{savepoint.name}</strong>
<span>
{formatElapsed(savepoint.state.elapsedSeconds)}
</span>
</div>
<div className="action-row">
<button
type="button"
disabled={replayMoment !== undefined}
onClick={() => onRestoreSavepoint(savepoint.id)}
>
Restore
</button>
<button
type="button"
className="danger"
aria-label={`Delete savepoint ${savepoint.name}`}
onClick={() => onDeleteSavepoint(savepoint.id)}
>
Delete
</button>
</div>
</li>
))}
</ul>
)}
</section>
</div>
<section className="panel-section history-section history-replay">
<div className="section-heading">
<div>
<p className="eyebrow">Replay</p>
<h3>Review every recorded state</h3>
</div>
<span className="status-pill">
{String(Math.max(0, history.moments.length - 1))} changes
</span>
</div>
{replayMoment ? (
<p className="callout" role="status">
Viewing {replayMoment.label}. The board is read-only until you
return live or branch from this step.
</p>
) : (
<p className="muted">
Choose a step to inspect its complete grid, notes, colours and
elapsed time.
</p>
)}
<div className="action-row history-replay-controls">
<button
type="button"
disabled={hypothesis !== undefined || replayIndex <= 0}
onClick={() =>
onReplayMoment(history.moments[replayIndex - 1]!.id)
}
>
Previous step
</button>
<button
type="button"
disabled={
replayMoment === undefined ||
hypothesis !== undefined ||
replayIndex >= history.moments.length - 1
}
onClick={() =>
onReplayMoment(history.moments[replayIndex + 1]!.id)
}
>
Next step
</button>
<button
type="button"
disabled={replayMoment === undefined}
onClick={onReturnLive}
>
Return to live grid
</button>
</div>
<ol className="history-list" aria-label="Solve history">
{[...history.moments].reverse().map((moment) => {
const isReplay = moment.id === replayMoment?.id;
const isCurrent =
replayMoment === undefined &&
moment.id === history.currentMomentId;
return (
<li key={moment.id}>
<button
type="button"
className={isReplay || isCurrent ? "is-current" : ""}
disabled={hypothesis !== undefined}
aria-current={isReplay || isCurrent ? "step" : undefined}
onClick={() =>
isCurrent ? onReturnLive() : onReplayMoment(moment.id)
}
>
<span className="history-step-number">
{String(moment.sequence).padStart(2, "0")}
</span>
<span className="history-step-copy">
<strong>{moment.label}</strong>
<span>
{branchNames.get(moment.branchId) ?? "Hypothesis"} ·{" "}
{formatElapsed(moment.state.elapsedSeconds)}
</span>
</span>
</button>
</li>
);
})}
</ol>
</section>
</div>
</Modal>
);
}
+66 -2
View File
@@ -1,11 +1,13 @@
import { useMemo, useState, type FormEvent } from "react";
import {
GENERATOR_VARIANTS,
PRACTICE_TECHNIQUES,
type DifficultyAssessment,
type GeneratedVariantPuzzle,
type GenerationDifficultyTarget,
type GeneratorVariant,
type GenerateVariantOptions,
type PracticeTechnique,
} from "../solver";
const difficultyTargets: readonly GenerationDifficultyTarget[] = [
@@ -49,6 +51,10 @@ export function GeneratorWorkspace({
const [symmetry, setSymmetry] = useState<"none" | "rotational">("rotational");
const [constraintCount, setConstraintCount] = useState(8);
const [seed, setSeed] = useState("");
const [requiredTechnique, setRequiredTechnique] = useState<
PracticeTechnique | ""
>("");
const [maxTechniqueAttempts, setMaxTechniqueAttempts] = useState(10);
const usesMarkingCount = ![
"classic",
"diagonal",
@@ -66,6 +72,9 @@ export function GeneratorWorkspace({
targetDifficulty,
symmetry,
...(usesMarkingCount ? { constraintCount } : {}),
...(requiredTechnique === ""
? {}
: { requiredTechnique, maxTechniqueAttempts }),
seed: seed.trim() || `local-${Date.now().toString(36)}`,
});
};
@@ -101,6 +110,11 @@ export function GeneratorWorkspace({
) {
setSize(nextDefinition.supportedSizes[0]);
}
if (next !== "killer") {
setRequiredTechnique((current) =>
current === "killer-cage" ? "" : current,
);
}
}}
>
{GENERATOR_VARIANTS.map((item) => (
@@ -175,6 +189,41 @@ export function GeneratorWorkspace({
onChange={(event) => setSeed(event.target.value)}
/>
</label>
<label>
Practice technique
<select
value={requiredTechnique}
onChange={(event) =>
setRequiredTechnique(
event.target.value as PracticeTechnique | "",
)
}
>
<option value="">No required technique</option>
{PRACTICE_TECHNIQUES.filter(
(technique) =>
technique !== "killer-cage" || variant === "killer",
).map((technique) => (
<option key={technique} value={technique}>
{techniqueLabel(technique)}
</option>
))}
</select>
</label>
{requiredTechnique !== "" && (
<label>
Mining attempts
<input
type="number"
min="1"
max="32"
value={maxTechniqueAttempts}
onChange={(event) =>
setMaxTechniqueAttempts(Number(event.target.value))
}
/>
</label>
)}
</div>
<p className="generator-description">{definition.description}</p>
<div className="action-row">
@@ -187,7 +236,9 @@ export function GeneratorWorkspace({
</div>
<p className="muted">
Difficulty is an estimate from reproducible solver evidence, not a
universal promise. Uniqueness is never claimed after a safety limit.
universal promise. Technique practice only returns a puzzle whose
logical path contains the requested move. Uniqueness is never claimed
after a safety limit.
</p>
</form>
@@ -229,11 +280,24 @@ export function GeneratorWorkspace({
Markings <strong>{generation.generatedConstraintCount}</strong>
</span>
)}
{generation?.requestedTechnique && (
<span>
Practice{" "}
<strong>
{techniqueLabel(generation.requestedTechnique)} ×
{generation.difficulty.techniqueCounts[
generation.requestedTechnique
] ?? 0}
</strong>
</span>
)}
</div>
<p>{assessment.summary}</p>
{generation && (
<p className="muted">
Seed: <code>{String(generation.seed)}</code>
Seed: <code>{String(generation.seed)}</code> · Found in{" "}
{generation.generationAttempts} attempt
{generation.generationAttempts === 1 ? "" : "s"}.
</p>
)}
</section>
+83 -10
View File
@@ -14,11 +14,12 @@ export function HelpDialog({
<h3>Five complementary workspaces</h3>
<p>
<strong>Play</strong> keeps values, two kinds of notes, colours,
history and elapsed time. <strong>Set</strong> edits clues and
constraints. <strong>Generate</strong> constructs and rates bounded,
seedable variants. <strong>Solve</strong> explains logical steps and
can verify uniqueness. <strong>Helpers</strong> answers focused
questions without changing the board.
branches, replay and elapsed time. <strong>Set</strong> edits clues
and constraints. <strong>Generate</strong> constructs and rates
bounded, seedable variants. <strong>Solve</strong> explains logical
steps and can verify uniqueness. <strong>Helpers</strong> answers
focused sum, candidate and relation questions without changing the
board.
</p>
</section>
<section>
@@ -32,6 +33,18 @@ export function HelpDialog({
<dt>Shift + arrows</dt>
<dd>Extend the selection</dd>
</div>
<div>
<dt>Home / End</dt>
<dd>Move to the first or last cell in the row</dd>
</div>
<div>
<dt>Ctrl/ + Home/End</dt>
<dd>Move to the first or last cell in the grid</dd>
</div>
<div>
<dt>Page Up / Down</dt>
<dd>Move to the first or last row in the same column</dd>
</div>
<div>
<dt>19 / AG</dt>
<dd>Enter the selected symbol</dd>
@@ -64,13 +77,62 @@ export function HelpDialog({
explanation.
</p>
</section>
<section>
<h3>Branches, savepoints and replay</h3>
<p>
Open <strong>History &amp; branches</strong> while playing to name a
checkpoint, isolate a hypothesis, or inspect an earlier grid.
Discarding a hypothesis restores its exact starting state but keeps
the abandoned path available in replay. Replayed grids are read-only
until you return live or deliberately branch from that step. This
working history stays in the current browser session; save the
puzzle to the Library for durable puzzle progress.
</p>
</section>
<section>
<h3>Sum and Candidate Labs</h3>
<p>
Sum Lab enumerates bounded combinations and, when selected board
cells are enabled, candidate-compatible assignments per position.
Required, excluded and manually eliminated combinations remain local
helper state. Candidate Lab inspects selected cells and houses,
filters one or more digits, and can overlay strong and weak links
without changing handwritten notes.
</p>
</section>
<section>
<h3>Aid-mémoire scratch cells</h3>
<p>
Enable the optional aid-mémoire in Play for a non-constraining row
or compact grid of scratch cells. Each cell has its own label and
accepts values, corner marks, centre marks and colours through the
regular keypad or keyboard. Arrow keys stay within the configured
grid; Home/End move within a row, Ctrl/ + Home/End reach the first
or last cell, and Page Up/Down move to the first or last row. The
layout and entries are preserved with local Library progress.
</p>
</section>
<section>
<h3>Variant and false clues</h3>
<p>
The setter supports cages, lines, pair clues, X-sums, skyscrapers,
quadruples and maximum cells. Enable Wrogn mode to make new local
clues false, or switch existing clues individually or as a batch.
Red dashed artwork and a mark identify false clues; Σ and
identify X-sum and skyscraper readings. A false multi-cell clue
often stays undecided until enough of its cells are known, so exact
searches for dense liar puzzles can be substantially slower.
</p>
</section>
<section>
<h3>Generation and ratings</h3>
<p>
Generation runs in a worker with explicit time and search limits. A
requested level guides clue removal; the reported rating is then
calculated independently from logical techniques, clue load and
bounded exact-search evidence. A limit never becomes a false
bounded exact-search evidence. Practice mode deterministically mines
several candidates and succeeds only when the analysed solve path
contains the requested technique. A limit never becomes a false
uniqueness claim.
</p>
</section>
@@ -78,10 +140,21 @@ export function HelpDialog({
<h3>Import and privacy</h3>
<p>
Files, text, solving and generation stay in this browser. Compact
grids, project JSON, share fragments and supported f-puzzles data
are decoded locally. SudokuPad short IDs need its server and are
intentionally rejected. Review an export before sharing: titles,
authors, rules, solutions and progress may be included.
grids, project JSON, share fragments, supported f-puzzles,
SudokuPad/CTC inline data and supported Penpa+ long links are
decoded locally. Server short IDs are intentionally rejected. SVG,
PNG and PDF rendering also stays in the browser. Review an export
before sharing: titles, authors, rules, solutions, progress and
aid-mémoire entries may be included.
</p>
</section>
<section>
<h3>Screen-reader detail</h3>
<p>
Each Sudoku cell reports its row, column, region, value or notes,
colour, conflict state, candidate highlights and touching variant
clues. The board and aid-mémoire use real row and gridcell roles;
their keyboard instructions are attached to the grids.
</p>
</section>
</div>
+339 -4
View File
@@ -1,11 +1,15 @@
import { useMemo, useState } from "react";
import type { NormalizedPuzzle } from "../domain";
import {
analyzeKillerCage,
analyzeSumLab,
calculateResidual,
relationPairs,
type CandidateOverlay,
type RelationSpec,
} from "../helpers";
import { maskValues, symbolFor } from "../state/session";
import { CandidateLab } from "./CandidateLab";
function digits(text: string) {
return [
@@ -25,18 +29,24 @@ function numbers(text: string) {
export function HelpersWorkspace({
size,
puzzle,
values,
selectedCells,
candidateMasks,
onCandidateOverlayChange,
}: {
size: number;
puzzle: NormalizedPuzzle;
values: readonly number[];
selectedCells: readonly number[];
candidateMasks: readonly number[];
onCandidateOverlayChange?: (overlay: CandidateOverlay | undefined) => void;
}) {
const [helper, setHelper] = useState<"killer" | "residual" | "relations">(
"killer",
);
const [helper, setHelper] = useState<
"candidates" | "killer" | "sum" | "residual" | "relations"
>("killer");
const [cellCount, setCellCount] = useState(2);
const [sum, setSum] = useState(10);
const [sum, setSum] = useState(Math.min(10, size + 1));
const [allowed, setAllowed] = useState("");
const [required, setRequired] = useState("");
const [excluded, setExcluded] = useState("");
@@ -47,6 +57,19 @@ export function HelpersWorkspace({
const [relation, setRelation] = useState("white");
const [firstCandidates, setFirstCandidates] = useState("");
const [secondCandidates, setSecondCandidates] = useState("");
const [sumLabCellCount, setSumLabCellCount] = useState(2);
const [sumLabTarget, setSumLabTarget] = useState(Math.min(10, size + 1));
const [sumLabMinimum, setSumLabMinimum] = useState(1);
const [sumLabMaximum, setSumLabMaximum] = useState(size);
const [sumLabRequired, setSumLabRequired] = useState("");
const [sumLabExcluded, setSumLabExcluded] = useState("");
const [sumLabRepeats, setSumLabRepeats] = useState(false);
const [sumLabUseCandidates, setSumLabUseCandidates] = useState(false);
const [sumLabShowEliminated, setSumLabShowEliminated] = useState(true);
const [sumLabEliminations, setSumLabEliminations] = useState<{
readonly scope: string;
readonly keys: ReadonlySet<string>;
}>({ scope: "", keys: new Set() });
const effectiveCount =
useBoardCandidates && selectedCells.length > 0
@@ -137,6 +160,84 @@ export function HelpersWorkspace({
}
}, [firstCandidates, relation, secondCandidates, size]);
const sumLabCandidateMasks = useMemo(
() =>
sumLabUseCandidates && selectedCells.length > 0
? selectedCells.map((cell) => candidateMasks[cell] ?? 0)
: undefined,
[candidateMasks, selectedCells, sumLabUseCandidates],
);
const sumLabEffectiveCount =
sumLabCandidateMasks === undefined
? sumLabCellCount
: sumLabCandidateMasks.length;
const sumLabScope = JSON.stringify({
count: sumLabEffectiveCount,
target: sumLabTarget,
minimum: sumLabMinimum,
maximum: sumLabMaximum,
required: sumLabRequired,
excluded: sumLabExcluded,
repeats: sumLabRepeats,
candidates: sumLabCandidateMasks,
});
const sumLabEliminatedKeys = useMemo(
() =>
sumLabEliminations.scope === sumLabScope
? sumLabEliminations.keys
: new Set<string>(),
[sumLabEliminations, sumLabScope],
);
const sumLab = useMemo(() => {
try {
return {
result: analyzeSumLab({
cellCount: sumLabEffectiveCount,
target: sumLabTarget,
minimumDigit: sumLabMinimum,
maximumDigit: sumLabMaximum,
allowRepeats: sumLabRepeats,
...(sumLabRequired.trim()
? { requiredDigits: digits(sumLabRequired) }
: {}),
...(sumLabExcluded.trim()
? { excludedDigits: digits(sumLabExcluded) }
: {}),
...(sumLabCandidateMasks === undefined
? {}
: { candidateMasks: sumLabCandidateMasks }),
eliminatedKeys: sumLabEliminatedKeys,
}),
};
} catch (error) {
return {
error:
error instanceof Error ? error.message : "Invalid Sum Lab input.",
};
}
}, [
sumLabCandidateMasks,
sumLabEffectiveCount,
sumLabExcluded,
sumLabMaximum,
sumLabMinimum,
sumLabEliminatedKeys,
sumLabRepeats,
sumLabRequired,
sumLabTarget,
]);
const toggleSumLabCombination = (key: string) => {
setSumLabEliminations((current) => {
const keys = new Set(
current.scope === sumLabScope ? current.keys : undefined,
);
if (keys.has(key)) keys.delete(key);
else keys.add(key);
return { scope: sumLabScope, keys };
});
};
return (
<div className="helpers-workspace stack">
<div>
@@ -151,6 +252,8 @@ export function HelpersWorkspace({
{(
[
["killer", "Killer combinations"],
["sum", "Sum Lab"],
["candidates", "Candidate links"],
["residual", `${String((size * (size + 1)) / 2)}-rule residual`],
["relations", "Pair relations"],
] as const
@@ -168,6 +271,238 @@ export function HelpersWorkspace({
))}
</div>
{helper === "candidates" && (
<div className="helper-card">
<CandidateLab
puzzle={puzzle}
values={values}
candidateMasks={candidateMasks}
selectedCells={selectedCells}
onOverlayChange={onCandidateOverlayChange}
/>
</div>
)}
{helper === "sum" && (
<section className="helper-card">
<div className="section-heading">
<div>
<h3>Generalized Sum Lab</h3>
<p className="muted">
Explore sums with custom ranges and repeats. Select board cells
first to filter cell orderings through their live candidates.
</p>
</div>
<button
type="button"
className="text-button"
disabled={sumLabEliminatedKeys.size === 0}
onClick={() =>
setSumLabEliminations({
scope: sumLabScope,
keys: new Set(),
})
}
>
Restore all
</button>
</div>
<div className="helper-controls sum-lab-controls">
<label>
Cell count
<input
type="number"
min="1"
max="25"
value={sumLabEffectiveCount}
disabled={sumLabCandidateMasks !== undefined}
onChange={(event) =>
setSumLabCellCount(Number(event.target.value))
}
/>
</label>
<label>
Target sum
<input
type="number"
min="0"
max={sumLabMaximum * sumLabEffectiveCount}
value={sumLabTarget}
onChange={(event) =>
setSumLabTarget(Number(event.target.value))
}
/>
</label>
<label>
Minimum digit
<input
type="number"
min="1"
max={size}
value={sumLabMinimum}
onChange={(event) =>
setSumLabMinimum(Number(event.target.value))
}
/>
</label>
<label>
Maximum digit
<input
type="number"
min={sumLabMinimum}
max={size}
value={sumLabMaximum}
onChange={(event) =>
setSumLabMaximum(Number(event.target.value))
}
/>
</label>
<label>
Must include
<input
value={sumLabRequired}
placeholder="e.g. 1, 7"
onChange={(event) => setSumLabRequired(event.target.value)}
/>
</label>
<label>
Exclude
<input
value={sumLabExcluded}
placeholder="e.g. 5"
onChange={(event) => setSumLabExcluded(event.target.value)}
/>
</label>
<label className="check-row">
<input
type="checkbox"
checked={sumLabRepeats}
onChange={(event) => setSumLabRepeats(event.target.checked)}
/>
Allow repeated digits
</label>
<label className="check-row">
<input
type="checkbox"
checked={sumLabUseCandidates}
disabled={selectedCells.length === 0}
onChange={(event) =>
setSumLabUseCandidates(event.target.checked)
}
/>
Use {selectedCells.length || "selected"} board cell
{selectedCells.length === 1 ? "" : "s"} and candidates
</label>
<label className="check-row">
<input
type="checkbox"
checked={sumLabShowEliminated}
onChange={(event) =>
setSumLabShowEliminated(event.target.checked)
}
/>
Show eliminated combinations
</label>
</div>
{sumLab.error ? (
<p className="error-callout" role="alert">
{sumLab.error}
</p>
) : sumLab.result ? (
<div className="helper-result">
<div className="metric-row">
<span>
<strong>{sumLab.result.activeCombinations.length}</strong>{" "}
active of {sumLab.result.combinations.length}
</span>
<span>
Possible{" "}
<strong>
{sumLab.result.possibleDigits
.map((value) => symbolFor(value, size))
.join(" ") || "none"}
</strong>
</span>
<span>
Necessary{" "}
<strong>
{sumLab.result.necessaryDigits
.map((value) => symbolFor(value, size))
.join(" ") || "none"}
</strong>
</span>
</div>
{sumLabCandidateMasks !== undefined && (
<ol className="cell-possibilities">
{sumLab.result.possibleByCell.map((entry, index) => {
const cell = selectedCells[index];
const label =
cell === undefined
? `Cell ${String(index + 1)}`
: `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
return (
<li key={cell ?? index}>
{label}:{" "}
{entry
.map((value) => symbolFor(value, size))
.join(" ") || "—"}
</li>
);
})}
</ol>
)}
<p className="muted sum-lab-guidance">
Select a combination to eliminate it; select it again to restore
it. Eliminated combinations are excluded from the summaries
above.
</p>
<div
className="combination-cloud sum-combination-cloud"
aria-label="Sum combinations"
>
{sumLab.result.combinations
.filter(
(combination) =>
sumLabShowEliminated || !combination.eliminated,
)
.slice(0, 500)
.map((combination) => (
<button
key={combination.key}
type="button"
className={`sum-combination${combination.eliminated ? " is-eliminated" : ""}`}
aria-pressed={combination.eliminated}
aria-label={`${combination.digits
.map((value) => symbolFor(value, size))
.join(
" + ",
)}; ${combination.eliminated ? "restore" : "eliminate"}`}
onClick={() => toggleSumLabCombination(combination.key)}
>
{combination.digits
.map((value) => symbolFor(value, size))
.join(" + ")}
</button>
))}
{sumLab.result.combinations.length === 0 && (
<span className="muted">No compatible combinations.</span>
)}
</div>
{(sumLab.result.combinations.length > 500 ||
sumLab.result.truncated) && (
<p className="callout">
{sumLab.result.truncated
? `Analysis reached its ${sumLab.result.truncationReason ?? "safety"} limit. `
: ""}
The visible list is bounded; narrow the range or add filters
before making deductions from an incomplete result.
</p>
)}
</div>
) : null}
</section>
)}
{helper === "killer" && (
<section className="helper-card">
<div className="helper-controls">
+192 -68
View File
@@ -1,15 +1,15 @@
import { useMemo, useRef, useState } from "react";
import type { PuzzleDefinition } from "../domain/types";
import { normalizePuzzle } from "../domain/validation";
import {
decodePuzzleHash,
encodePuzzleHash,
exportFpuzzlesJson,
exportFpuzzlesUrl,
fromDomainPuzzle,
importFpuzzles,
parseFpuzzles,
parsePlainGrid,
parseSudokuDocument,
importPuzzle,
renderPuzzlePdf,
renderPuzzlePng,
renderPuzzleSvg,
serializePlainGrid,
serializeSudokuDocument,
toDomainPuzzle,
@@ -17,31 +17,13 @@ import {
} from "../formats";
import type { PlaySession } from "../state/session";
import { maskValues } from "../state/session";
import type { PortableAidMemoire } from "../state/aidMemoire";
import { Modal } from "./Modal";
function parseImport(input: string): SudokuDocument {
const trimmed = input.trim();
if (trimmed.startsWith("#sudoku=") || trimmed.includes("#sudoku="))
return decodePuzzleHash(trimmed);
if (
/^(?:https?:\/\/)?(?:www\.)?(?:f-puzzles\.com|sudokupad\.app)\//iu.test(
trimmed,
)
)
return importFpuzzles(trimmed);
if (trimmed.startsWith("fpuzzles")) return importFpuzzles(trimmed);
if (trimmed.startsWith("{")) {
const parsed = JSON.parse(trimmed) as unknown;
if (typeof parsed === "object" && parsed !== null && "schema" in parsed)
return parseSudokuDocument(trimmed);
return parseFpuzzles(parsed);
}
return parsePlainGrid(trimmed);
}
function withProgress(
puzzle: PuzzleDefinition,
session: PlaySession,
aidMemoire?: PortableAidMemoire,
): SudokuDocument {
const base = fromDomainPuzzle(puzzle);
return {
@@ -55,22 +37,38 @@ function withProgress(
),
colors: [...session.colors],
elapsedMs: session.elapsedSeconds * 1_000,
...(aidMemoire === undefined ? {} : { aidMemoire }),
};
}
function download(name: string, contents: string, type: string) {
const url = URL.createObjectURL(new Blob([contents], { type }));
function downloadBlob(name: string, blob: Blob) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = name;
document.body.append(anchor);
anchor.click();
URL.revokeObjectURL(url);
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
function download(name: string, contents: string, type: string) {
downloadBlob(name, new Blob([contents], { type }));
}
function errorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
function checkedPuzzle(document: SudokuDocument): PuzzleDefinition {
return normalizePuzzle(toDomainPuzzle(document) as PuzzleDefinition);
}
interface ImportExportDialogProps {
open: boolean;
puzzle: PuzzleDefinition;
session: PlaySession;
aidMemoire?: PortableAidMemoire;
onClose: () => void;
onImport: (
puzzle: PuzzleDefinition,
@@ -82,6 +80,7 @@ interface ImportExportDialogProps {
| "candidates"
| "colors"
| "elapsedMs"
| "aidMemoire"
>,
) => void;
}
@@ -90,19 +89,21 @@ export function ImportExportDialog({
open,
puzzle,
session,
aidMemoire,
onClose,
onImport,
}: ImportExportDialogProps) {
const [input, setInput] = useState("");
const [feedback, setFeedback] = useState("");
const [includeProgress, setIncludeProgress] = useState(true);
const [busy, setBusy] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const documentValue = useMemo(
() =>
includeProgress
? withProgress(puzzle, session)
? withProgress(puzzle, session, aidMemoire)
: fromDomainPuzzle(puzzle),
[includeProgress, puzzle, session],
[aidMemoire, includeProgress, puzzle, session],
);
const copy = async (value: string, label: string) => {
@@ -116,18 +117,119 @@ export function ImportExportDialog({
}
};
const copyExport = async (render: () => string, label: string) => {
let value: string;
try {
value = render();
} catch (error) {
setFeedback(errorMessage(error, `${label} could not be created.`));
return;
}
await copy(value, label);
};
const exportText = (
name: string,
type: string,
label: string,
render: () => string,
) => {
try {
download(name, render(), type);
setFeedback(`${label} downloaded locally.`);
} catch (error) {
setFeedback(errorMessage(error, `${label} export failed.`));
}
};
const inspectImport = async () => {
setBusy(true);
try {
const result = await importPuzzle(input);
checkedPuzzle(result.document);
const givenCount = result.document.givens.filter(
(value) => value !== 0,
).length;
setFeedback(
`${result.label}: ${String(result.document.size)}×${String(result.document.size)}, ${String(givenCount)} givens and ${String(result.document.constraints.length)} constraints. Compatible and ready to import.`,
);
} catch (error) {
setFeedback(errorMessage(error, "The puzzle could not be inspected."));
} finally {
setBusy(false);
}
};
const applyImport = async () => {
setBusy(true);
try {
const result = await importPuzzle(input);
const parsed = result.document;
onImport(checkedPuzzle(parsed), {
values: parsed.values,
cornerMarks: parsed.cornerMarks,
centerMarks: parsed.centerMarks,
candidates: parsed.candidates,
colors: parsed.colors,
elapsedMs: parsed.elapsedMs,
aidMemoire: parsed.aidMemoire,
});
setFeedback(`${result.label} imported locally.`);
onClose();
} catch (error) {
setFeedback(errorMessage(error, "The puzzle could not be imported."));
} finally {
setBusy(false);
}
};
const exportVisual = async (format: "svg" | "png" | "pdf") => {
setBusy(true);
try {
const options = {
includeProgress,
includeNotes: includeProgress,
};
if (format === "svg") {
download(
"sudoku.svg",
renderPuzzleSvg(documentValue, options),
"image/svg+xml;charset=utf-8",
);
} else if (format === "png") {
downloadBlob(
"sudoku.png",
await renderPuzzlePng(documentValue, options),
);
} else {
downloadBlob(
"sudoku.pdf",
await renderPuzzlePdf(documentValue, options),
);
}
setFeedback(`${format.toUpperCase()} rendered and downloaded locally.`);
} catch (error) {
setFeedback(
errorMessage(error, `The ${format.toUpperCase()} export failed.`),
);
} finally {
setBusy(false);
}
};
return (
<Modal open={open} title="Import and export" onClose={onClose} wide>
<div className="import-export-grid">
<div className="import-export-grid" aria-busy={busy}>
<section className="stack">
<div>
<p className="eyebrow">Import</p>
<h3>Paste puzzle data</h3>
</div>
<p className="muted">
Accepts a plain grid, Sudoku Tools JSON/share link, raw f-puzzles
JSON, or a self-contained f-puzzles/SudokuPad link. Server-only
short IDs are never fetched.
Accepts a plain grid, Sudoku Tools JSON/share link, f-puzzles or
SudokuPad/CTC data, and supported Penpa+ long links. Everything is
decoded locally. Server-only short IDs are never fetched.
Unsupported constructs stop the import instead of being discarded.
</p>
<textarea
rows={12}
@@ -135,7 +237,10 @@ export function ImportExportDialog({
maxLength={1_048_576}
spellCheck={false}
placeholder="Paste 81 characters, JSON or a puzzle URL…"
onChange={(event) => setInput(event.target.value)}
onChange={(event) => {
setInput(event.target.value);
setFeedback("");
}}
/>
<div className="action-row">
<button type="button" onClick={() => fileRef.current?.click()}>
@@ -145,7 +250,7 @@ export function ImportExportDialog({
ref={fileRef}
className="sr-only"
type="file"
accept="application/json,text/plain,.json,.txt,.sdk"
accept="application/json,text/plain,.json,.txt,.sdk,.scl,.ctc,.penpa"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
@@ -169,28 +274,15 @@ export function ImportExportDialog({
/>
<button
type="button"
disabled={!input.trim()}
onClick={() => {
try {
const parsed = parseImport(input);
onImport(toDomainPuzzle(parsed) as PuzzleDefinition, {
values: parsed.values,
cornerMarks: parsed.cornerMarks,
centerMarks: parsed.centerMarks,
candidates: parsed.candidates,
colors: parsed.colors,
elapsedMs: parsed.elapsedMs,
});
setFeedback("Puzzle imported locally.");
onClose();
} catch (error) {
setFeedback(
error instanceof Error
? error.message
: "The puzzle could not be imported.",
);
}
}}
disabled={busy || !input.trim()}
onClick={() => void inspectImport()}
>
Check compatibility
</button>
<button
type="button"
disabled={busy || !input.trim()}
onClick={() => void applyImport()}
>
Import locally
</button>
@@ -213,11 +305,13 @@ export function ImportExportDialog({
<div className="export-actions">
<button
type="button"
disabled={busy}
onClick={() =>
download(
exportText(
"sudoku-tools-puzzle.json",
serializeSudokuDocument(documentValue, true),
"application/json",
"Project JSON",
() => serializeSudokuDocument(documentValue, true),
)
}
>
@@ -225,14 +319,13 @@ export function ImportExportDialog({
</button>
<button
type="button"
disabled={busy}
onClick={() =>
download(
"sudoku.txt",
exportText("sudoku.txt", "text/plain", "Grid text", () =>
serializePlainGrid(
documentValue,
includeProgress ? "values" : "givens",
),
"text/plain",
)
}
>
@@ -240,11 +333,13 @@ export function ImportExportDialog({
</button>
<button
type="button"
disabled={busy}
onClick={() =>
download(
exportText(
"sudoku.fpuzzles.json",
exportFpuzzlesJson(documentValue, true),
"application/json",
"f-puzzles JSON",
() => exportFpuzzlesJson(documentValue, true),
)
}
>
@@ -252,21 +347,50 @@ export function ImportExportDialog({
</button>
<button
type="button"
disabled={busy}
onClick={() =>
void copy(exportFpuzzlesUrl(documentValue), "f-puzzles URL")
void copyExport(
() => exportFpuzzlesUrl(documentValue),
"f-puzzles URL",
)
}
>
Copy f-puzzles URL
</button>
<button
type="button"
disabled={busy}
onClick={() => {
const share = `${location.href.split("#", 1)[0]}${encodePuzzleHash(documentValue)}`;
void copy(share, "Local share URL");
void copyExport(
() =>
`${location.href.split("#", 1)[0]}${encodePuzzleHash(documentValue)}`,
"Local share URL",
);
}}
>
Copy local share URL
</button>
<button
type="button"
disabled={busy}
onClick={() => void exportVisual("svg")}
>
Download SVG
</button>
<button
type="button"
disabled={busy}
onClick={() => void exportVisual("png")}
>
Download PNG
</button>
<button
type="button"
disabled={busy}
onClick={() => void exportVisual("pdf")}
>
Download PDF
</button>
</div>
<p className="callout">
Share links are self-contained. They may expose title, setter,
+9 -7
View File
@@ -141,13 +141,15 @@ export function SolveWorkspace({
</span>
</div>
<p>
{exact.count === 0
? "No completion satisfies every supported rule."
: exact.count === 1 && !exact.truncated
? "The current puzzle has exactly one solution within the configured search bounds."
: exact.count >= 2
? "At least two solutions exist; the puzzle is not unique."
: "Search stopped at a safety limit, so uniqueness is not established."}
{exact.count >= 2
? "At least two solutions exist; the puzzle is not unique."
: exact.truncated
? exact.count === 0
? "Search stopped at a safety limit before finding a solution, so solvability is not established."
: "Search stopped at a safety limit, so uniqueness is not established."
: exact.count === 0
? "No completion satisfies every supported rule."
: "The current puzzle has exactly one solution."}
</p>
{exact.solutions[0] && (
<button
+461 -57
View File
@@ -1,5 +1,11 @@
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
import {
useId,
type CSSProperties,
type KeyboardEvent,
type PointerEvent,
} from "react";
import type { NormalizedPuzzle, VariantConstraint } from "../domain/types";
import type { CandidateNode, CandidateOverlay } from "../helpers";
import { maskValues, symbolFor } from "../state/session";
interface SudokuBoardProps {
@@ -14,6 +20,7 @@ interface SudokuBoardProps {
conflicts?: ReadonlySet<number>;
activeCell: number;
showCandidates?: boolean;
candidateOverlay?: CandidateOverlay;
onCellPointerDown: (
cell: number,
event: PointerEvent<HTMLButtonElement>,
@@ -25,6 +32,67 @@ interface SudokuBoardProps {
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
}
function candidatePoint(size: number, node: CandidateNode) {
const columns = Math.ceil(Math.sqrt(size));
const rows = Math.ceil(size / columns);
const slot = node.value - 1;
return {
x: (node.cell % size) + ((slot % columns) + 0.5) / columns,
y: Math.floor(node.cell / size) + (Math.floor(slot / columns) + 0.5) / rows,
};
}
function CandidateLinkLayer({
size,
overlay,
}: {
size: number;
overlay: CandidateOverlay;
}) {
const nodes = new Map<string, CandidateNode>();
for (const link of overlay.links) {
nodes.set(`${String(link.a.cell)}:${String(link.a.value)}`, link.a);
nodes.set(`${String(link.b.cell)}:${String(link.b.value)}`, link.b);
}
return (
<svg
className="candidate-link-layer"
viewBox={`0 0 ${String(size)} ${String(size)}`}
aria-hidden="true"
>
{overlay.links.map((link) => {
const a = candidatePoint(size, link.a);
const b = candidatePoint(size, link.b);
return (
<line
key={link.id}
className={`candidate-overlay-link candidate-overlay-link--${link.kind}`}
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
/>
);
})}
{[...nodes.entries()].map(([key, node]) => {
const position = candidatePoint(size, node);
return (
<g
key={key}
className="candidate-overlay-node"
transform={`translate(${String(position.x)} ${String(position.y)})`}
>
<circle r="0.105" />
<text x="0" y="0">
{symbolFor(node.value, size)}
</text>
</g>
);
})}
</svg>
);
}
function point(size: number, cell: number) {
return {
x: (cell % size) + 0.5,
@@ -41,6 +109,23 @@ function polyline(size: number, cells: readonly number[]) {
.join(" ");
}
function outsidePoint(
size: number,
side: "top" | "right" | "bottom" | "left",
index: number,
) {
switch (side) {
case "top":
return { x: index + 0.5, y: -0.48 };
case "right":
return { x: size + 0.48, y: index + 0.5 };
case "bottom":
return { x: index + 0.5, y: size + 0.48 };
case "left":
return { x: -0.48, y: index + 0.5 };
}
}
function boundaryPath(size: number, cells: ReadonlySet<number>, inset: number) {
const commands: string[] = [];
for (const cell of cells) {
@@ -62,12 +147,168 @@ function boundaryPath(size: number, cells: ReadonlySet<number>, inset: number) {
return commands.join(" ");
}
function describeConstraint(
constraint: VariantConstraint,
size: number,
): string {
const cell = (index: number) =>
`row ${String(Math.floor(index / size) + 1)}, column ${String((index % size) + 1)}`;
const cells = (indices: readonly number[]) => indices.map(cell).join("; ");
const polarity =
"negated" in constraint && constraint.negated === true
? "False clue: "
: "";
switch (constraint.type) {
case "diagonal":
return `${constraint.direction} diagonal.`;
case "anti-knight":
return "Anti-knight rule.";
case "anti-king":
return "Anti-king rule.";
case "non-consecutive":
return "Non-consecutive orthogonal neighbours rule.";
case "killer-cage":
return `${polarity}killer cage ${String(constraint.sum)} at ${cells(constraint.cells)}.`;
case "thermo":
return `${polarity}thermometer from bulb through ${cells(constraint.cells)}.`;
case "arrow":
return `${polarity}arrow with bulb at ${cells(constraint.bulb)} and line through ${cells(constraint.line)}.`;
case "kropki":
return `${polarity}${constraint.kind} Kropki dot between ${cell(constraint.a)} and ${cell(constraint.b)}.`;
case "xv":
return `${polarity}sum ${String(constraint.total)} pair between ${cell(constraint.a)} and ${cell(constraint.b)}.`;
case "inequality":
return `${polarity}${cell(constraint.lesser)} is less than ${cell(constraint.greater)}.`;
case "renban":
return `${polarity}renban line through ${cells(constraint.cells)}.`;
case "palindrome":
return `${polarity}palindrome line through ${cells(constraint.cells)}.`;
case "x-sum":
return `${polarity}X-sum ${String(constraint.sum)} from the ${constraint.side}, ${constraint.side === "left" || constraint.side === "right" ? "row" : "column"} ${String(constraint.index + 1)}.`;
case "skyscraper":
return `${polarity}skyscraper count ${String(constraint.count)} from the ${constraint.side}, ${constraint.side === "left" || constraint.side === "right" ? "row" : "column"} ${String(constraint.index + 1)}.`;
case "quadruple":
return `${polarity}quadruple digits ${constraint.digits.map((digit) => symbolFor(digit, size)).join(", ")} touching ${cells(constraint.cells)}.`;
case "maximum":
return `${polarity}maximum at ${cell(constraint.cell)}.`;
}
}
function constraintTouchesCell(
constraint: VariantConstraint,
cell: number,
size: number,
): boolean {
switch (constraint.type) {
case "diagonal": {
const row = Math.floor(cell / size);
const column = cell % size;
return constraint.direction === "main"
? row === column
: row + column === size - 1;
}
case "killer-cage":
case "thermo":
case "renban":
case "palindrome":
case "quadruple":
return constraint.cells.includes(cell);
case "arrow":
return constraint.bulb.includes(cell) || constraint.line.includes(cell);
case "kropki":
case "xv":
return constraint.a === cell || constraint.b === cell;
case "inequality":
return constraint.lesser === cell || constraint.greater === cell;
case "maximum":
return constraint.cell === cell;
case "x-sum":
case "skyscraper": {
const row = Math.floor(cell / size);
const column = cell % size;
return constraint.side === "left" || constraint.side === "right"
? row === constraint.index
: column === constraint.index;
}
case "anti-knight":
case "anti-king":
case "non-consecutive":
return false;
}
}
function cellAccessibleDescription({
puzzle,
cell,
value,
corner,
center,
centerIsAutomatic,
color,
conflict,
candidateHighlights,
}: {
puzzle: NormalizedPuzzle;
cell: number;
value: number;
corner: readonly number[];
center: readonly number[];
centerIsAutomatic: boolean;
color: number;
conflict: boolean;
candidateHighlights: readonly number[];
}): string {
const parts = [`Region ${String((puzzle.regions[cell] ?? 0) + 1)}`];
if (value !== 0) {
parts.push(puzzle.givens[cell] ? "Given digit" : "Entered digit");
} else {
if (corner.length > 0) {
parts.push(
`corner notes ${corner.map((digit) => symbolFor(digit, puzzle.size)).join(", ")}`,
);
}
if (center.length > 0) {
parts.push(
`${centerIsAutomatic ? "automatic candidates" : "centre notes"} ${center
.map((digit) => symbolFor(digit, puzzle.size))
.join(", ")}`,
);
}
}
if (color > 0) parts.push(`colour ${String(color)}`);
if (conflict) parts.push("conflict");
if (candidateHighlights.length > 0) {
parts.push(
`Candidate Lab highlights ${candidateHighlights
.map((digit) => symbolFor(digit, puzzle.size))
.join(", ")}`,
);
}
const localConstraints = puzzle.constraints.filter((constraint) =>
constraintTouchesCell(constraint, cell, puzzle.size),
);
if (localConstraints.length > 0) {
parts.push(
`clues: ${localConstraints
.map((constraint) => describeConstraint(constraint, puzzle.size))
.join(" ")}`,
);
}
return parts.join(", ");
}
function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
const { size } = puzzle;
const regionCells = Array.from({ length: size }, () => new Set<number>());
puzzle.regions.forEach((region, cell) => regionCells[region]?.add(cell));
const renderConstraint = (constraint: VariantConstraint, index: number) => {
const polarityClass =
"negated" in constraint && constraint.negated === true
? " is-negated"
: "";
if (constraint.type === "diagonal") {
return (
<line
@@ -85,9 +326,10 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
const first = Math.min(...constraint.cells);
const position = point(size, first);
return (
<g key={index} className="constraint-cage">
<g key={index} className={`constraint-cage${polarityClass}`}>
<path d={boundaryPath(size, cells, 0.09)} />
<text x={position.x - 0.34} y={position.y - 0.27}>
{constraint.negated === true ? "≠" : ""}
{constraint.sum}
</text>
</g>
@@ -96,9 +338,14 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
if (constraint.type === "thermo") {
const bulb = point(size, constraint.cells[0]!);
return (
<g key={index} className="constraint-thermo">
<g key={index} className={`constraint-thermo${polarityClass}`}>
<polyline points={polyline(size, constraint.cells)} />
<circle cx={bulb.x} cy={bulb.y} r="0.31" />
{constraint.negated === true && (
<text className="constraint-false-mark" x={bulb.x} y={bulb.y}>
</text>
)}
</g>
);
}
@@ -107,16 +354,104 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
const end = point(size, constraint.line.at(-1)!);
const connectedLine = [constraint.bulb.at(-1)!, ...constraint.line];
return (
<g key={index} className="constraint-arrow">
<g key={index} className={`constraint-arrow${polarityClass}`}>
<circle cx={bulb.x} cy={bulb.y} r="0.32" />
<polyline points={polyline(size, connectedLine)} />
<circle cx={end.x} cy={end.y} r="0.08" />
</g>
);
}
if (constraint.type === "maximum") {
const center = point(size, constraint.cell);
return (
<g key={index} className={`constraint-maximum${polarityClass}`}>
<circle cx={center.x} cy={center.y} r="0.18" />
<path
d={[
`M${String(center.x - 0.1)} ${String(center.y - 0.17)}L${String(center.x)} ${String(center.y - 0.3)}L${String(center.x + 0.1)} ${String(center.y - 0.17)}`,
`M${String(center.x - 0.1)} ${String(center.y + 0.17)}L${String(center.x)} ${String(center.y + 0.3)}L${String(center.x + 0.1)} ${String(center.y + 0.17)}`,
`M${String(center.x - 0.17)} ${String(center.y - 0.1)}L${String(center.x - 0.3)} ${String(center.y)}L${String(center.x - 0.17)} ${String(center.y + 0.1)}`,
`M${String(center.x + 0.17)} ${String(center.y - 0.1)}L${String(center.x + 0.3)} ${String(center.y)}L${String(center.x + 0.17)} ${String(center.y + 0.1)}`,
].join("")}
/>
</g>
);
}
if (constraint.type === "quadruple") {
const positions = constraint.cells.map((cell) => point(size, cell));
const center = {
x:
positions.reduce((sum, position) => sum + position.x, 0) /
positions.length,
y:
positions.reduce((sum, position) => sum + position.y, 0) /
positions.length,
};
return (
<g key={index} className={`constraint-quadruple${polarityClass}`}>
<circle cx={center.x} cy={center.y} r="0.29" />
<text x={center.x} y={center.y}>
{constraint.negated === true ? "≠" : ""}
{constraint.digits.map((digit) => symbolFor(digit, size)).join("")}
</text>
</g>
);
}
if (constraint.type === "x-sum" || constraint.type === "skyscraper") {
const position = outsidePoint(size, constraint.side, constraint.index);
const value =
constraint.type === "x-sum" ? constraint.sum : constraint.count;
const companionIndex = puzzle.constraints.findIndex(
(candidate) =>
candidate.type !== constraint.type &&
(candidate.type === "x-sum" || candidate.type === "skyscraper") &&
candidate.side === constraint.side &&
candidate.index === constraint.index &&
(candidate.type === "x-sum" ? candidate.sum : candidate.count) ===
value &&
("negated" in candidate && candidate.negated === true) ===
(constraint.negated === true),
);
const combined = companionIndex >= 0;
if (combined && companionIndex < index) return null;
const label = `${constraint.negated === true ? "≠" : ""}${String(value)}`;
return (
<g
key={index}
className={`constraint-outside constraint-${constraint.type}${combined ? " is-combined" : ""}${polarityClass}`}
transform={`translate(${String(position.x)} ${String(position.y)})`}
>
<rect
x="-0.46"
y={combined ? "-0.31" : "-0.23"}
width="0.92"
height={combined ? "0.62" : "0.46"}
rx="0.12"
/>
{combined ? (
<>
<text className="outside-clue-kinds" x="0" y="-0.15">
Σ ·
</text>
<text className="outside-clue-value" x="0" y="0.1">
{label}
</text>
</>
) : (
<text x="0" y="0">
{constraint.type === "x-sum" ? "Σ" : "▥"}
{label}
</text>
)}
</g>
);
}
if (constraint.type === "renban" || constraint.type === "palindrome") {
return (
<g key={index} className={`constraint-${constraint.type}`}>
<g
key={index}
className={`constraint-${constraint.type}${polarityClass}`}
>
<polyline points={polyline(size, constraint.cells)} />
{constraint.type === "palindrome" &&
constraint.cells.map((cell) => {
@@ -145,7 +480,7 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
return (
<circle
key={index}
className={`constraint-kropki constraint-kropki--${constraint.kind}`}
className={`constraint-kropki constraint-kropki--${constraint.kind}${polarityClass}`}
cx={x}
cy={y}
r="0.115"
@@ -155,10 +490,11 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
return (
<g
key={index}
className={`constraint-xv constraint-xv--${String(constraint.total)}`}
className={`constraint-xv constraint-xv--${String(constraint.total)}${polarityClass}`}
>
<circle cx={x} cy={y} r="0.18" />
<text x={x} y={y}>
{constraint.negated === true ? "≠" : ""}
{constraint.total}
</text>
</g>
@@ -167,7 +503,7 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
return (
<g
key={index}
className="constraint-inequality"
className={`constraint-inequality${polarityClass}`}
transform={`translate(${String(x)} ${String(y)}) rotate(${String(rotation)})`}
>
<path d="M0.12 -0.17L-0.12 0L0.12 0.17" />
@@ -211,72 +547,140 @@ export function SudokuBoard({
conflicts = new Set<number>(),
activeCell,
showCandidates,
candidateOverlay,
onCellPointerDown,
onCellPointerEnter,
onKeyDown,
}: SudokuBoardProps) {
const { size } = puzzle;
const constraintDescriptionId = useId();
const navigationDescriptionId = useId();
const hasOutsideClues = puzzle.constraints.some(
({ type }) => type === "x-sum" || type === "skyscraper",
);
const constraintDescription = puzzle.constraints
.map((constraint) => describeConstraint(constraint, size))
.join(" ");
return (
<div
className="sudoku-board-frame"
className={`sudoku-board-frame${hasOutsideClues ? " has-outside-clues" : ""}`}
style={{ "--sudoku-size": size } as CSSProperties}
>
<p className="sr-only" id={navigationDescriptionId}>
Use the arrow keys to move without wrapping. Home and End move to the
first and last cell in the row. Control or Command plus Home and End
move to the first and last grid cell. Page Up and Page Down keep the
column and move to the first and last row. Hold Shift to extend the
selection.
</p>
{constraintDescription && (
<p className="sr-only" id={constraintDescriptionId}>
Variant constraints. {constraintDescription}
</p>
)}
<div
className="sudoku-board"
role="grid"
aria-label={`${String(size)} by ${String(size)} Sudoku grid`}
aria-rowcount={size}
aria-colcount={size}
aria-multiselectable="true"
aria-describedby={`${navigationDescriptionId}${constraintDescription ? ` ${constraintDescriptionId}` : ""}`}
onKeyDown={onKeyDown}
>
{values.map((value, cell) => {
const isGiven = Boolean(puzzle.givens[cell]);
const corner = maskValues(cornerMarks[cell] ?? 0, size);
const center = maskValues(
centerMarks[cell] || (showCandidates ? (candidates[cell] ?? 0) : 0),
size,
);
const row = Math.floor(cell / size) + 1;
const column = (cell % size) + 1;
return (
<button
key={cell}
type="button"
role="gridcell"
aria-label={`Row ${String(row)}, column ${String(column)}${value ? `, ${symbolFor(value, size)}` : ", empty"}`}
aria-selected={selected.has(cell)}
tabIndex={cell === activeCell ? 0 : -1}
className={[
"sudoku-cell",
isGiven ? "is-given" : "",
highlighted.has(cell) ? "is-digit-highlighted" : "",
selected.has(cell) ? "is-selected" : "",
conflicts.has(cell) ? "has-conflict" : "",
colors[cell] ? `has-color-${String(colors[cell])}` : "",
]
.filter(Boolean)
.join(" ")}
onPointerDown={(event) => onCellPointerDown(cell, event)}
onPointerEnter={(event) => onCellPointerEnter(cell, event)}
data-cell={cell}
>
{value ? (
<span className="cell-value">{symbolFor(value, size)}</span>
) : (
<>
<span className="corner-marks" aria-hidden="true">
{corner.map((mark) => (
<span key={mark}>{symbolFor(mark, size)}</span>
))}
</span>
<span className="center-marks" aria-hidden="true">
{center.map((mark) => symbolFor(mark, size)).join("")}
</span>
</>
)}
</button>
);
})}
{Array.from({ length: size }, (_, rowIndex) => (
<div
key={rowIndex}
className="sudoku-row"
role="row"
aria-rowindex={rowIndex + 1}
>
{Array.from({ length: size }, (_, columnIndex) => {
const cell = rowIndex * size + columnIndex;
const value = values[cell] ?? 0;
const isGiven = Boolean(puzzle.givens[cell]);
const corner = maskValues(cornerMarks[cell] ?? 0, size);
const userCenterMask = centerMarks[cell] ?? 0;
const automaticMask = showCandidates
? (candidates[cell] ?? 0)
: 0;
const centerIsAutomatic =
userCenterMask === 0 && automaticMask !== 0;
const center = maskValues(userCenterMask || automaticMask, size);
const candidateHighlighted =
candidateOverlay?.candidateCells.includes(cell) ?? false;
const candidateMask = candidates[cell] ?? 0;
const candidateHighlights = candidateHighlighted
? (candidateOverlay?.activeValues.filter(
(digit) =>
candidateMask === 0 ||
(candidateMask & (1 << (digit - 1))) !== 0,
) ?? [])
: [];
const conflict = conflicts.has(cell);
const accessibleDescription = cellAccessibleDescription({
puzzle,
cell,
value,
corner,
center,
centerIsAutomatic,
color: colors[cell] ?? 0,
conflict,
candidateHighlights,
});
return (
<button
key={cell}
type="button"
role="gridcell"
aria-rowindex={rowIndex + 1}
aria-colindex={columnIndex + 1}
aria-label={`Row ${String(rowIndex + 1)}, column ${String(columnIndex + 1)}${value ? `, ${symbolFor(value, size)}` : ", empty"}`}
aria-description={accessibleDescription}
aria-selected={selected.has(cell)}
aria-readonly={isGiven}
aria-invalid={conflict || undefined}
tabIndex={cell === activeCell ? 0 : -1}
className={[
"sudoku-cell",
isGiven ? "is-given" : "",
highlighted.has(cell) ? "is-digit-highlighted" : "",
candidateHighlighted ? "is-candidate-highlighted" : "",
selected.has(cell) ? "is-selected" : "",
conflict ? "has-conflict" : "",
colors[cell] ? `has-color-${String(colors[cell])}` : "",
]
.filter(Boolean)
.join(" ")}
onPointerDown={(event) => onCellPointerDown(cell, event)}
onPointerEnter={(event) => onCellPointerEnter(cell, event)}
data-cell={cell}
>
{value ? (
<span className="cell-value">{symbolFor(value, size)}</span>
) : (
<>
<span className="corner-marks" aria-hidden="true">
{corner.map((mark) => (
<span key={mark}>{symbolFor(mark, size)}</span>
))}
</span>
<span className="center-marks" aria-hidden="true">
{center.map((mark) => symbolFor(mark, size)).join("")}
</span>
</>
)}
</button>
);
})}
</div>
))}
</div>
<ConstraintLayer puzzle={puzzle} />
{candidateOverlay !== undefined && candidateOverlay.links.length > 0 && (
<CandidateLinkLayer size={size} overlay={candidateOverlay} />
)}
</div>
);
}
+557 -73
View File
@@ -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>
);
}