feat: expand sudoku analysis and interoperability
This commit is contained in:
@@ -16,6 +16,41 @@ All notable changes are documented here.
|
||||
constraint family.
|
||||
- Added bounded, seedable generation for classic and twelve variant families,
|
||||
plus independent uniqueness and evidence-based difficulty assessment.
|
||||
- Added X-sum, skyscraper, quadruple and maximum-cell constraints across the
|
||||
model, validator, exact solver, renderer, setter and f-puzzles interchange.
|
||||
- Added per-clue false semantics for liar/Wrogn puzzles, including bounded
|
||||
impossible decoy numbers and a setter-wide false-clue mode.
|
||||
- Added the original, uniquely verified “Truth and lies” example demonstrating
|
||||
true and false outside, inclusion, maximum, cage, line and pair clues.
|
||||
- Added outside-grid clue margins, combined dual X-sum/skyscraper labels,
|
||||
outward maximum arrows and visually distinct false clues.
|
||||
- Added strict, bounded local imports for SudokuPad/CTC data and supported
|
||||
Penpa+ long links, including source reporting and explicit incompatibility
|
||||
lists instead of silently discarded constructs.
|
||||
- Added a compatibility check which never fetches SudokuPad short IDs or other
|
||||
server-hosted puzzle references.
|
||||
- Added standalone SVG, high-resolution PNG and single-page PDF visual exports
|
||||
for supported constraints, givens and optional solving progress.
|
||||
- Added named savepoints, isolated hypothesis branches, keep/discard decisions
|
||||
and read-only replay of complete solving states.
|
||||
- Added an optional configurable aid-mémoire whose labelled scratch cells
|
||||
support values, both note styles and colours without constraining the puzzle;
|
||||
its state is preserved in undo, replay, Library progress and project/share
|
||||
interchange.
|
||||
- Added a generalized Sum Lab with independent digit bounds, repeats,
|
||||
required/excluded digits, selected-cell candidates, positional assignments
|
||||
and reversible manual eliminations.
|
||||
- Added Candidate Lab filters, selected/touching/specific-house scopes, house
|
||||
positions, strong conjugate/bivalue links, weak links and bounded board
|
||||
overlays.
|
||||
- Added technique-targeted practice generation which mines a bounded,
|
||||
deterministic set of uniquely checked puzzles and verifies the requested
|
||||
technique in the logical solve path.
|
||||
- Added real ARIA row/gridcell semantics, detailed cell descriptions and
|
||||
non-wrapping Arrow, Home/End, Control/Command + Home/End and Page Up/Down
|
||||
keyboard navigation for the puzzle and scratch grids.
|
||||
- Made Killer cage repeat semantics explicit in the setter and independent of
|
||||
whether a clue is required to be true or false.
|
||||
|
||||
## 0.1.0 - 2026-08-30
|
||||
|
||||
|
||||
@@ -8,33 +8,63 @@ release also runs independently from any static HTTPS host or local preview.
|
||||
## Workspaces
|
||||
|
||||
- **Play** — keyboard, mouse and touch entry; multi-cell selection; values,
|
||||
corner/centre notes and colours; undo/redo; conflict highlighting; timer and
|
||||
local progress; optional digit-completion counts and matching-digit
|
||||
highlights.
|
||||
corner/centre notes and colours; undo/redo; named savepoints, hypothesis
|
||||
branches and read-only replay; conflict highlighting; timer and local
|
||||
progress; optional digit-completion counts, matching-digit highlights and a
|
||||
configurable non-constraining aid-mémoire.
|
||||
- **Set** — givens, metadata, regions and typed constraints; uniqueness checks
|
||||
with overlap-safe cage replacement and selection-based cage removal.
|
||||
- **Generate** — seedable, bounded construction for classic and 12 variant
|
||||
families; independent uniqueness verification and evidence-based difficulty
|
||||
assessment.
|
||||
assessment; deterministic mining for a requested logical technique.
|
||||
- **Solve** — exact solution counting plus an original human-style engine whose
|
||||
steps include structured evidence, placements and eliminations.
|
||||
- **Helpers** — Killer combinations and candidate-aware assignments, 45-rule
|
||||
residuals, and Kropki, XV or inequality relation pairs.
|
||||
- **Helpers** — generalized sum combinations with positional candidates and
|
||||
manual eliminations; selected-cell and house candidate-link analysis; Killer
|
||||
combinations, 45-rule residuals, and Kropki, sum-pair or inequality pairs.
|
||||
|
||||
Classic grids and common variants share one bounded puzzle model: irregular
|
||||
regions, diagonals, Killer cages, thermometers, arrows, Kropki and numbered
|
||||
5/10 sum-pair clues, inequalities, renban lines, palindromes, anti-knight,
|
||||
anti-king and non-consecutive rules. Thirteen bundled, uniquely checked examples
|
||||
demonstrate every supported constraint. Import accepts compact grid strings,
|
||||
this project's JSON format, share fragments, and the common f-puzzles fields
|
||||
supported by the model. Unknown constraints are reported rather than silently
|
||||
discarded.
|
||||
5/10 sum-pair clues, inequalities, renban lines, palindromes, X-sums,
|
||||
skyscrapers, quadruples, maximum cells, anti-knight, anti-king and
|
||||
non-consecutive rules. Every local clue can also be required to be false for
|
||||
liar/Wrogn constructions. Fourteen original bundled examples demonstrate every
|
||||
supported constraint and are exact-search verified as unique.
|
||||
|
||||
The board exposes real row/gridcell semantics and concise per-cell state to
|
||||
assistive technology, including candidates, notes, colours, conflicts and
|
||||
touching variant clues. Arrow keys never wrap; Home/End, Control/Command +
|
||||
Home/End and Page Up/Down provide row- and grid-level navigation. The same
|
||||
navigation model is available in the aid-mémoire scratch grid.
|
||||
|
||||
## Interoperability and visual export
|
||||
|
||||
The import dialog recognises compact grids, Sudoku Tools JSON/share fragments,
|
||||
raw or inline f-puzzles data, SudokuPad/CTC (SCL) data, and Penpa+ self-contained
|
||||
long links. It never follows a URL or resolves a server-side short ID. Imports
|
||||
are size/decompression bounded, report their detected source before applying,
|
||||
and stop with a list when a construct cannot be represented faithfully.
|
||||
|
||||
SudokuPad compatibility currently preserves cells, givens and progress, notes,
|
||||
regions, metadata, solutions, Killer cages and supported global rules. Penpa+
|
||||
compatibility is intentionally narrower: square, unrotated Sudoku grids with
|
||||
givens/progress, thermometers and arrows. Visual-only lines, overlays, custom
|
||||
symbols and other unsupported geometry are reported rather than ignored.
|
||||
f-puzzles export likewise refuses false clues it cannot preserve.
|
||||
|
||||
Export can produce a standalone SVG, a high-resolution PNG, or a single-page
|
||||
PDF entirely in the browser. Each visual uses the same trusted puzzle model and
|
||||
renders region boundaries, supported variant clues and givens; current values,
|
||||
notes and cell colours can be included or omitted with the progress switch.
|
||||
|
||||
Generation supports only size/variant combinations which pass the bounded
|
||||
construction checks. The requested difficulty controls clue-removal targets;
|
||||
the displayed 0–100 result is calculated afterwards from logical techniques,
|
||||
clue load and reproducible exact-search evidence. A timeout or node limit is
|
||||
reported as unknown and never promoted to a uniqueness or difficulty claim.
|
||||
Technique practice performs a bounded, deterministic search and returns a
|
||||
puzzle only when its independently analysed solve path actually contains the
|
||||
requested technique.
|
||||
|
||||
## Privacy and storage
|
||||
|
||||
@@ -46,7 +76,8 @@ Exported files and share URLs contain the puzzle data you chose to include.
|
||||
|
||||
SudokuPad short IDs identify server-hosted records and therefore cannot be
|
||||
resolved by this deliberately offline application. Embedded f-puzzles payloads
|
||||
are accepted without contacting their originating site.
|
||||
and user-provided Sudoku Tools JSON documents are accepted without contacting
|
||||
their originating site. Externally authored puzzle definitions are not bundled.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -76,8 +107,8 @@ browser workflows and Toolbox contract before creating a deterministic
|
||||
|
||||
The interaction ideas and published solving concepts explored by SudokuPad,
|
||||
SudokuWiki and the wider puzzle community informed the product requirements.
|
||||
No code, artwork, wording or proprietary puzzle data from those applications is
|
||||
included. Algorithms and explanations in this repository are independently
|
||||
No externally authored puzzle definitions, code, artwork or documentation text
|
||||
is bundled. Algorithms, UI, original examples and explanations are independently
|
||||
implemented from mathematical rules and public standards.
|
||||
|
||||
## Licence
|
||||
|
||||
@@ -16,4 +16,12 @@ runtime package. Development-only packages build and test the source but are
|
||||
not shipped as runtime modules.
|
||||
|
||||
SudokuPad, SudokuWiki and HoDoKu are research references only. Their source,
|
||||
documentation, wording, visual assets and puzzle data are not bundled or copied.
|
||||
documentation wording, visual assets and externally authored puzzle data are
|
||||
not bundled or copied.
|
||||
|
||||
The local compatibility code was tested against the open format behaviour in
|
||||
[Penpa+](https://github.com/swaroopg92/penpa-edit) (MIT),
|
||||
[penpa-to-scl](https://github.com/marktekfan/penpa-to-scl) (MIT), and
|
||||
[SudokuPad sudokutools](https://github.com/SudokuPad/sudokutools) (MPL-2.0).
|
||||
Those projects are format references only: no upstream executable code,
|
||||
artwork, documentation text or puzzle data is distributed with Sudoku Tools.
|
||||
|
||||
@@ -16,6 +16,41 @@ All notable changes are documented here.
|
||||
constraint family.
|
||||
- Added bounded, seedable generation for classic and twelve variant families,
|
||||
plus independent uniqueness and evidence-based difficulty assessment.
|
||||
- Added X-sum, skyscraper, quadruple and maximum-cell constraints across the
|
||||
model, validator, exact solver, renderer, setter and f-puzzles interchange.
|
||||
- Added per-clue false semantics for liar/Wrogn puzzles, including bounded
|
||||
impossible decoy numbers and a setter-wide false-clue mode.
|
||||
- Added the original, uniquely verified “Truth and lies” example demonstrating
|
||||
true and false outside, inclusion, maximum, cage, line and pair clues.
|
||||
- Added outside-grid clue margins, combined dual X-sum/skyscraper labels,
|
||||
outward maximum arrows and visually distinct false clues.
|
||||
- Added strict, bounded local imports for SudokuPad/CTC data and supported
|
||||
Penpa+ long links, including source reporting and explicit incompatibility
|
||||
lists instead of silently discarded constructs.
|
||||
- Added a compatibility check which never fetches SudokuPad short IDs or other
|
||||
server-hosted puzzle references.
|
||||
- Added standalone SVG, high-resolution PNG and single-page PDF visual exports
|
||||
for supported constraints, givens and optional solving progress.
|
||||
- Added named savepoints, isolated hypothesis branches, keep/discard decisions
|
||||
and read-only replay of complete solving states.
|
||||
- Added an optional configurable aid-mémoire whose labelled scratch cells
|
||||
support values, both note styles and colours without constraining the puzzle;
|
||||
its state is preserved in undo, replay, Library progress and project/share
|
||||
interchange.
|
||||
- Added a generalized Sum Lab with independent digit bounds, repeats,
|
||||
required/excluded digits, selected-cell candidates, positional assignments
|
||||
and reversible manual eliminations.
|
||||
- Added Candidate Lab filters, selected/touching/specific-house scopes, house
|
||||
positions, strong conjugate/bivalue links, weak links and bounded board
|
||||
overlays.
|
||||
- Added technique-targeted practice generation which mines a bounded,
|
||||
deterministic set of uniquely checked puzzles and verifies the requested
|
||||
technique in the logical solve path.
|
||||
- Added real ARIA row/gridcell semantics, detailed cell descriptions and
|
||||
non-wrapping Arrow, Home/End, Control/Command + Home/End and Page Up/Down
|
||||
keyboard navigation for the puzzle and scratch grids.
|
||||
- Made Killer cage repeat semantics explicit in the setter and independent of
|
||||
whether a clue is required to be true or false.
|
||||
|
||||
## 0.1.0 - 2026-08-30
|
||||
|
||||
|
||||
+46
-15
@@ -8,33 +8,63 @@ release also runs independently from any static HTTPS host or local preview.
|
||||
## Workspaces
|
||||
|
||||
- **Play** — keyboard, mouse and touch entry; multi-cell selection; values,
|
||||
corner/centre notes and colours; undo/redo; conflict highlighting; timer and
|
||||
local progress; optional digit-completion counts and matching-digit
|
||||
highlights.
|
||||
corner/centre notes and colours; undo/redo; named savepoints, hypothesis
|
||||
branches and read-only replay; conflict highlighting; timer and local
|
||||
progress; optional digit-completion counts, matching-digit highlights and a
|
||||
configurable non-constraining aid-mémoire.
|
||||
- **Set** — givens, metadata, regions and typed constraints; uniqueness checks
|
||||
with overlap-safe cage replacement and selection-based cage removal.
|
||||
- **Generate** — seedable, bounded construction for classic and 12 variant
|
||||
families; independent uniqueness verification and evidence-based difficulty
|
||||
assessment.
|
||||
assessment; deterministic mining for a requested logical technique.
|
||||
- **Solve** — exact solution counting plus an original human-style engine whose
|
||||
steps include structured evidence, placements and eliminations.
|
||||
- **Helpers** — Killer combinations and candidate-aware assignments, 45-rule
|
||||
residuals, and Kropki, XV or inequality relation pairs.
|
||||
- **Helpers** — generalized sum combinations with positional candidates and
|
||||
manual eliminations; selected-cell and house candidate-link analysis; Killer
|
||||
combinations, 45-rule residuals, and Kropki, sum-pair or inequality pairs.
|
||||
|
||||
Classic grids and common variants share one bounded puzzle model: irregular
|
||||
regions, diagonals, Killer cages, thermometers, arrows, Kropki and numbered
|
||||
5/10 sum-pair clues, inequalities, renban lines, palindromes, anti-knight,
|
||||
anti-king and non-consecutive rules. Thirteen bundled, uniquely checked examples
|
||||
demonstrate every supported constraint. Import accepts compact grid strings,
|
||||
this project's JSON format, share fragments, and the common f-puzzles fields
|
||||
supported by the model. Unknown constraints are reported rather than silently
|
||||
discarded.
|
||||
5/10 sum-pair clues, inequalities, renban lines, palindromes, X-sums,
|
||||
skyscrapers, quadruples, maximum cells, anti-knight, anti-king and
|
||||
non-consecutive rules. Every local clue can also be required to be false for
|
||||
liar/Wrogn constructions. Fourteen original bundled examples demonstrate every
|
||||
supported constraint and are exact-search verified as unique.
|
||||
|
||||
The board exposes real row/gridcell semantics and concise per-cell state to
|
||||
assistive technology, including candidates, notes, colours, conflicts and
|
||||
touching variant clues. Arrow keys never wrap; Home/End, Control/Command +
|
||||
Home/End and Page Up/Down provide row- and grid-level navigation. The same
|
||||
navigation model is available in the aid-mémoire scratch grid.
|
||||
|
||||
## Interoperability and visual export
|
||||
|
||||
The import dialog recognises compact grids, Sudoku Tools JSON/share fragments,
|
||||
raw or inline f-puzzles data, SudokuPad/CTC (SCL) data, and Penpa+ self-contained
|
||||
long links. It never follows a URL or resolves a server-side short ID. Imports
|
||||
are size/decompression bounded, report their detected source before applying,
|
||||
and stop with a list when a construct cannot be represented faithfully.
|
||||
|
||||
SudokuPad compatibility currently preserves cells, givens and progress, notes,
|
||||
regions, metadata, solutions, Killer cages and supported global rules. Penpa+
|
||||
compatibility is intentionally narrower: square, unrotated Sudoku grids with
|
||||
givens/progress, thermometers and arrows. Visual-only lines, overlays, custom
|
||||
symbols and other unsupported geometry are reported rather than ignored.
|
||||
f-puzzles export likewise refuses false clues it cannot preserve.
|
||||
|
||||
Export can produce a standalone SVG, a high-resolution PNG, or a single-page
|
||||
PDF entirely in the browser. Each visual uses the same trusted puzzle model and
|
||||
renders region boundaries, supported variant clues and givens; current values,
|
||||
notes and cell colours can be included or omitted with the progress switch.
|
||||
|
||||
Generation supports only size/variant combinations which pass the bounded
|
||||
construction checks. The requested difficulty controls clue-removal targets;
|
||||
the displayed 0–100 result is calculated afterwards from logical techniques,
|
||||
clue load and reproducible exact-search evidence. A timeout or node limit is
|
||||
reported as unknown and never promoted to a uniqueness or difficulty claim.
|
||||
Technique practice performs a bounded, deterministic search and returns a
|
||||
puzzle only when its independently analysed solve path actually contains the
|
||||
requested technique.
|
||||
|
||||
## Privacy and storage
|
||||
|
||||
@@ -46,7 +76,8 @@ Exported files and share URLs contain the puzzle data you chose to include.
|
||||
|
||||
SudokuPad short IDs identify server-hosted records and therefore cannot be
|
||||
resolved by this deliberately offline application. Embedded f-puzzles payloads
|
||||
are accepted without contacting their originating site.
|
||||
and user-provided Sudoku Tools JSON documents are accepted without contacting
|
||||
their originating site. Externally authored puzzle definitions are not bundled.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -76,8 +107,8 @@ browser workflows and Toolbox contract before creating a deterministic
|
||||
|
||||
The interaction ideas and published solving concepts explored by SudokuPad,
|
||||
SudokuWiki and the wider puzzle community informed the product requirements.
|
||||
No code, artwork, wording or proprietary puzzle data from those applications is
|
||||
included. Algorithms and explanations in this repository are independently
|
||||
No externally authored puzzle definitions, code, artwork or documentation text
|
||||
is bundled. Algorithms, UI, original examples and explanations are independently
|
||||
implemented from mathematical rules and public standards.
|
||||
|
||||
## Licence
|
||||
|
||||
@@ -16,4 +16,12 @@ runtime package. Development-only packages build and test the source but are
|
||||
not shipped as runtime modules.
|
||||
|
||||
SudokuPad, SudokuWiki and HoDoKu are research references only. Their source,
|
||||
documentation, wording, visual assets and puzzle data are not bundled or copied.
|
||||
documentation wording, visual assets and externally authored puzzle data are
|
||||
not bundled or copied.
|
||||
|
||||
The local compatibility code was tested against the open format behaviour in
|
||||
[Penpa+](https://github.com/swaroopg92/penpa-edit) (MIT),
|
||||
[penpa-to-scl](https://github.com/marktekfan/penpa-to-scl) (MIT), and
|
||||
[SudokuPad sudokutools](https://github.com/SudokuPad/sudokutools) (MPL-2.0).
|
||||
Those projects are format references only: no upstream executable code,
|
||||
artwork, documentation text or puzzle data is distributed with Sudoku Tools.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 > 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,14 +646,92 @@ export function ConstraintEditor({
|
||||
|
||||
{constraints.length > 0 && (
|
||||
<section className="panel-section">
|
||||
<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) => (
|
||||
{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>{describeConstraint(constraint, puzzle.size)}</span>
|
||||
<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,
|
||||
@@ -417,8 +743,10 @@ export function ConstraintEditor({
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>1–9 / A–G</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 & 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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
{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.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
|
||||
|
||||
+422
-18
@@ -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,45 +547,108 @@ 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) => {
|
||||
{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 center = maskValues(
|
||||
centerMarks[cell] || (showCandidates ? (candidates[cell] ?? 0) : 0),
|
||||
size,
|
||||
);
|
||||
const row = Math.floor(cell / size) + 1;
|
||||
const column = (cell % size) + 1;
|
||||
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-label={`Row ${String(row)}, column ${String(column)}${value ? `, ${symbolFor(value, size)}` : ", empty"}`}
|
||||
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" : "",
|
||||
conflicts.has(cell) ? "has-conflict" : "",
|
||||
conflict ? "has-conflict" : "",
|
||||
colors[cell] ? `has-color-${String(colors[cell])}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -276,7 +675,12 @@ export function SudokuBoard({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ConstraintLayer puzzle={puzzle} />
|
||||
{candidateOverlay !== undefined && candidateOverlay.links.length > 0 && (
|
||||
<CandidateLinkLayer size={size} overlay={candidateOverlay} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+540
-56
@@ -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,11 +729,35 @@ 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 => {
|
||||
const erase = useCallback(
|
||||
(target: "auto" | "board" = "auto"): void => {
|
||||
if (session.paused) return;
|
||||
if (
|
||||
target === "auto" &&
|
||||
workspace === "play" &&
|
||||
aidMemoire.enabled &&
|
||||
aidMemoireActive
|
||||
) {
|
||||
updateAidMemoire(
|
||||
eraseAidMemoireCell(aidMemoire, aidMemoireCell, entryMode),
|
||||
`Erased aid-mémoire cell ${String(aidMemoireCell + 1)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (workspace === "set" && entryMode === "value") {
|
||||
const givens = [...puzzle.givens];
|
||||
const values = [...session.values];
|
||||
@@ -573,18 +772,25 @@ export function Workbench() {
|
||||
puzzle,
|
||||
eraseSelection(session, selectedSet, entryMode, puzzle.givens),
|
||||
);
|
||||
}, [commit, entryMode, puzzle, selectedSet, session, workspace]);
|
||||
},
|
||||
[
|
||||
aidMemoire,
|
||||
aidMemoireActive,
|
||||
aidMemoireCell,
|
||||
commit,
|
||||
entryMode,
|
||||
puzzle,
|
||||
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
|
||||
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 === 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,12 +1245,166 @@ 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}>
|
||||
@@ -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
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -237,6 +237,52 @@ export const NON_CONSECUTIVE_SAMPLE: PuzzleDefinition = {
|
||||
solution: grid("315264", "642531", "264153", "531426", "153642", "426315"),
|
||||
};
|
||||
|
||||
/** Original compact fixture demonstrating outside, inclusion, maximum and liar clues. */
|
||||
export const TRUTH_AND_LIES_SAMPLE: PuzzleDefinition = {
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: grid("0234", "3012", "4301", "2140"),
|
||||
title: "Truth and lies",
|
||||
author: "Sudoku Tools",
|
||||
rules:
|
||||
"Normal 4×4 Sudoku rules apply. Red dashed clues marked ≠ must be false; all other clues are true.",
|
||||
solution: grid("1234", "3412", "4321", "2143"),
|
||||
constraints: [
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 1 },
|
||||
{ type: "skyscraper", side: "left", index: 1, count: 2 },
|
||||
{ type: "quadruple", cells: [0, 1, 4, 5], digits: [1, 2, 3, 4] },
|
||||
{ type: "maximum", cell: 5 },
|
||||
{
|
||||
type: "killer-cage",
|
||||
cells: [8, 9],
|
||||
sum: 6,
|
||||
noRepeat: false,
|
||||
negated: true,
|
||||
},
|
||||
{ type: "thermo", cells: [12, 13], negated: true },
|
||||
{ type: "kropki", a: 14, b: 15, kind: "black", negated: true },
|
||||
{ type: "xv", a: 2, b: 3, total: 5, negated: true },
|
||||
{ type: "inequality", lesser: 10, greater: 11, negated: true },
|
||||
{ type: "renban", cells: [12, 14], negated: true },
|
||||
{ type: "palindrome", cells: [8, 11], negated: true },
|
||||
{ type: "x-sum", side: "right", index: 2, sum: 2, negated: true },
|
||||
{
|
||||
type: "skyscraper",
|
||||
side: "bottom",
|
||||
index: 3,
|
||||
count: 3,
|
||||
negated: true,
|
||||
},
|
||||
{
|
||||
type: "quadruple",
|
||||
cells: [4, 5, 8, 9],
|
||||
digits: [1],
|
||||
negated: true,
|
||||
},
|
||||
{ type: "maximum", cell: 10, negated: true },
|
||||
],
|
||||
};
|
||||
|
||||
export interface SamplePuzzleEntry {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
@@ -277,6 +323,11 @@ export const SAMPLE_CATALOG: readonly SamplePuzzleEntry[] = [
|
||||
label: "Non-consecutive 6 × 6",
|
||||
puzzle: NON_CONSECUTIVE_SAMPLE,
|
||||
},
|
||||
{
|
||||
id: "truth-and-lies",
|
||||
label: "Truth and lies 4 × 4",
|
||||
puzzle: TRUTH_AND_LIES_SAMPLE,
|
||||
},
|
||||
];
|
||||
|
||||
export const SAMPLE_PUZZLES: readonly PuzzleDefinition[] = SAMPLE_CATALOG.map(
|
||||
|
||||
+38
-2
@@ -1,5 +1,10 @@
|
||||
import { cellColumn, cellRow, orthogonalNeighbours } from "./geometry";
|
||||
import type { CellId, NormalizedPuzzle, VariantConstraint } from "./types";
|
||||
import type {
|
||||
CellId,
|
||||
NormalizedPuzzle,
|
||||
OutsideClueSide,
|
||||
VariantConstraint,
|
||||
} from "./types";
|
||||
|
||||
export type UnitKind = "row" | "column" | "region" | "diagonal";
|
||||
|
||||
@@ -25,6 +30,26 @@ function addPeerPair(peers: Set<CellId>[], a: CellId, b: CellId): void {
|
||||
peers[b]?.add(a);
|
||||
}
|
||||
|
||||
/** Cells seen from an outside clue, ordered from the clue into the grid. */
|
||||
export function outsideLineCells(
|
||||
size: number,
|
||||
side: OutsideClueSide,
|
||||
index: number,
|
||||
): CellId[] {
|
||||
return Array.from({ length: size }, (_, offset) => {
|
||||
switch (side) {
|
||||
case "top":
|
||||
return offset * size + index;
|
||||
case "right":
|
||||
return index * size + size - offset - 1;
|
||||
case "bottom":
|
||||
return (size - offset - 1) * size + index;
|
||||
case "left":
|
||||
return index * size + offset;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function cellsForConstraint(
|
||||
size: number,
|
||||
constraint: VariantConstraint,
|
||||
@@ -52,6 +77,13 @@ function cellsForConstraint(
|
||||
return [constraint.a, constraint.b];
|
||||
case "inequality":
|
||||
return [constraint.lesser, constraint.greater];
|
||||
case "x-sum":
|
||||
case "skyscraper":
|
||||
return outsideLineCells(size, constraint.side, constraint.index);
|
||||
case "quadruple":
|
||||
return constraint.cells;
|
||||
case "maximum":
|
||||
return [constraint.cell, ...orthogonalNeighbours(size, constraint.cell)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +133,11 @@ export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle {
|
||||
for (const cell of new Set(cellsForConstraint(size, constraint))) {
|
||||
constraintsByCell[cell]?.push(constraintIndex);
|
||||
}
|
||||
if (constraint.type === "killer-cage" && constraint.noRepeat !== false) {
|
||||
if (
|
||||
constraint.type === "killer-cage" &&
|
||||
constraint.noRepeat !== false &&
|
||||
constraint.negated !== true
|
||||
) {
|
||||
for (const a of constraint.cells) {
|
||||
for (const b of constraint.cells) addPeerPair(peers, a, b);
|
||||
}
|
||||
|
||||
@@ -126,3 +126,36 @@ export function orthogonalNeighbours(size: number, cell: CellId): CellId[] {
|
||||
if (column + 1 < size) result.push(cell + 1);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** True when four cells meet at one internal grid intersection. */
|
||||
export function cellsFormQuadruple(
|
||||
size: number,
|
||||
cells: readonly CellId[],
|
||||
): boolean {
|
||||
if (cells.length !== 4 || new Set(cells).size !== 4) return false;
|
||||
try {
|
||||
const rows = [...new Set(cells.map((cell) => cellRow(size, cell)))].sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
const columns = [
|
||||
...new Set(cells.map((cell) => cellColumn(size, cell))),
|
||||
].sort((a, b) => a - b);
|
||||
if (
|
||||
rows.length !== 2 ||
|
||||
columns.length !== 2 ||
|
||||
rows[1] !== rows[0]! + 1 ||
|
||||
columns[1] !== columns[0]! + 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const expected = new Set([
|
||||
cellId(size, rows[0]!, columns[0]!),
|
||||
cellId(size, rows[0]!, columns[1]!),
|
||||
cellId(size, rows[1]!, columns[0]!),
|
||||
cellId(size, rows[1]!, columns[1]!),
|
||||
]);
|
||||
return cells.every((cell) => expected.has(cell));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+374
-5
@@ -1,4 +1,9 @@
|
||||
import { compilePuzzle, type CompiledPuzzle } from "./compile";
|
||||
import {
|
||||
compilePuzzle,
|
||||
outsideLineCells,
|
||||
type CompiledPuzzle,
|
||||
} from "./compile";
|
||||
import { orthogonalNeighbours } from "./geometry";
|
||||
import type {
|
||||
CellId,
|
||||
NormalizedPuzzle,
|
||||
@@ -21,8 +26,9 @@ function sumRange(
|
||||
size: number,
|
||||
noRepeat: boolean,
|
||||
): readonly [number, number] {
|
||||
if (!noRepeat) {
|
||||
const current = assigned.reduce((sum, value) => sum + value, 0);
|
||||
if (blanks === 0) return [current, current];
|
||||
if (!noRepeat) {
|
||||
return [current + blanks, current + blanks * size];
|
||||
}
|
||||
const used = new Set(assigned);
|
||||
@@ -32,7 +38,6 @@ function sumRange(
|
||||
).filter((value) => !used.has(value));
|
||||
if (available.length < blanks)
|
||||
return [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
|
||||
const current = assigned.reduce((sum, value) => sum + value, 0);
|
||||
const low = available
|
||||
.slice(0, blanks)
|
||||
.reduce((sum, value) => sum + value, current);
|
||||
@@ -57,7 +62,217 @@ function sumsCanMeet(
|
||||
return [sum + blanks, sum + blanks * size];
|
||||
}
|
||||
|
||||
export function constraintIsFeasible(
|
||||
function canChooseDistinctSum(
|
||||
available: readonly number[],
|
||||
count: number,
|
||||
target: number,
|
||||
): boolean {
|
||||
if (count < 0 || count > available.length || target < 0) return false;
|
||||
if (count === 0) return target === 0;
|
||||
const ordered = [...available].sort((a, b) => a - b);
|
||||
const minimum = ordered
|
||||
.slice(0, count)
|
||||
.reduce((total, digit) => total + digit, 0);
|
||||
const maximum = ordered
|
||||
.slice(-count)
|
||||
.reduce((total, digit) => total + digit, 0);
|
||||
if (target < minimum || target > maximum) return false;
|
||||
|
||||
const sums = new Array<bigint>(count + 1).fill(0n);
|
||||
sums[0] = 1n;
|
||||
let processed = 0;
|
||||
for (const digit of ordered) {
|
||||
processed += 1;
|
||||
for (
|
||||
let selected = Math.min(count, processed);
|
||||
selected >= 1;
|
||||
selected -= 1
|
||||
) {
|
||||
sums[selected] =
|
||||
(sums[selected] ?? 0n) | ((sums[selected - 1] ?? 0n) << BigInt(digit));
|
||||
}
|
||||
}
|
||||
return ((sums[count] ?? 0n) & (1n << BigInt(target))) !== 0n;
|
||||
}
|
||||
|
||||
function xSumCanMeet(
|
||||
values: readonly number[],
|
||||
cells: readonly CellId[],
|
||||
size: number,
|
||||
target: number,
|
||||
): boolean {
|
||||
const line = valuesAt(values, cells);
|
||||
const fixed = line.filter((value) => value !== 0);
|
||||
if (
|
||||
fixed.some((value) => value < 1 || value > size) ||
|
||||
new Set(fixed).size !== fixed.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const first = line[0] ?? 0;
|
||||
const fixedDigits = new Set(fixed);
|
||||
const possibleFirsts =
|
||||
first === 0
|
||||
? Array.from({ length: size }, (_, index) => index + 1).filter(
|
||||
(digit) => !fixedDigits.has(digit),
|
||||
)
|
||||
: [first];
|
||||
|
||||
return possibleFirsts.some((firstDigit) => {
|
||||
if (firstDigit < 1 || firstDigit > size) return false;
|
||||
const used = new Set(fixedDigits);
|
||||
used.add(firstDigit);
|
||||
let assignedSum = firstDigit;
|
||||
let blanks = 0;
|
||||
for (let position = 1; position < firstDigit; position += 1) {
|
||||
const value = line[position] ?? 0;
|
||||
if (value === 0) blanks += 1;
|
||||
else assignedSum += value;
|
||||
}
|
||||
const available = Array.from(
|
||||
{ length: size },
|
||||
(_, index) => index + 1,
|
||||
).filter((digit) => !used.has(digit));
|
||||
return canChooseDistinctSum(available, blanks, target - assignedSum);
|
||||
});
|
||||
}
|
||||
|
||||
type SearchResult = "yes" | "no" | "unknown";
|
||||
const MAX_SKYSCRAPER_STATES = 50_000;
|
||||
|
||||
function skyscraperCanMeet(
|
||||
values: readonly number[],
|
||||
cells: readonly CellId[],
|
||||
size: number,
|
||||
target: number,
|
||||
): boolean {
|
||||
const line = valuesAt(values, cells);
|
||||
const fixed = line.filter((value) => value !== 0);
|
||||
if (
|
||||
fixed.some((value) => value < 1 || value > size) ||
|
||||
new Set(fixed).size !== fixed.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (fixed.length === 0) return target >= 1 && target <= size;
|
||||
|
||||
let prefixMaximum = 0;
|
||||
let prefixVisible = 0;
|
||||
for (const value of line) {
|
||||
if (value === 0) break;
|
||||
if (value > prefixMaximum) {
|
||||
prefixMaximum = value;
|
||||
prefixVisible += 1;
|
||||
}
|
||||
}
|
||||
const minimumVisible = prefixVisible + (prefixMaximum < size ? 1 : 0);
|
||||
const maximumVisible = prefixVisible + (size - prefixMaximum);
|
||||
if (target < minimumVisible || target > maximumVisible) return false;
|
||||
|
||||
let availableMask = 0;
|
||||
for (let digit = 1; digit <= size; digit += 1) {
|
||||
if (!fixed.includes(digit)) availableMask |= 1 << (digit - 1);
|
||||
}
|
||||
let explored = 0;
|
||||
const memo = new Map<string, SearchResult>();
|
||||
const visit = (
|
||||
position: number,
|
||||
remainingMask: number,
|
||||
tallest: number,
|
||||
visible: number,
|
||||
): SearchResult => {
|
||||
if (visible > target) return "no";
|
||||
if (position === size) return visible === target ? "yes" : "no";
|
||||
const positionsLeft = size - position;
|
||||
if (
|
||||
visible + positionsLeft < target ||
|
||||
visible + (size - tallest) < target
|
||||
) {
|
||||
return "no";
|
||||
}
|
||||
explored += 1;
|
||||
if (explored > MAX_SKYSCRAPER_STATES) return "unknown";
|
||||
const key = `${position}:${remainingMask}:${tallest}:${visible}`;
|
||||
const cached = memo.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const fixedValue = line[position] ?? 0;
|
||||
if (fixedValue !== 0) {
|
||||
const result = visit(
|
||||
position + 1,
|
||||
remainingMask,
|
||||
Math.max(tallest, fixedValue),
|
||||
visible + (fixedValue > tallest ? 1 : 0),
|
||||
);
|
||||
memo.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
let sawUnknown = false;
|
||||
for (let mask = remainingMask; mask !== 0; mask &= mask - 1) {
|
||||
const bit = mask & -mask;
|
||||
const digit = 32 - Math.clz32(bit);
|
||||
const result = visit(
|
||||
position + 1,
|
||||
remainingMask & ~bit,
|
||||
Math.max(tallest, digit),
|
||||
visible + (digit > tallest ? 1 : 0),
|
||||
);
|
||||
if (result === "yes") {
|
||||
memo.set(key, result);
|
||||
return result;
|
||||
}
|
||||
if (result === "unknown") sawUnknown = true;
|
||||
}
|
||||
const result = sawUnknown ? "unknown" : "no";
|
||||
memo.set(key, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
return visit(0, availableMask, 0, 0) !== "no";
|
||||
}
|
||||
|
||||
function quadrupleCanMeet(
|
||||
constraint: Extract<VariantConstraint, { readonly type: "quadruple" }>,
|
||||
values: readonly number[],
|
||||
): boolean {
|
||||
const assigned = new Map<number, number>();
|
||||
let blanks = 0;
|
||||
for (const cell of constraint.cells) {
|
||||
const value = values[cell] ?? 0;
|
||||
if (value === 0) blanks += 1;
|
||||
else assigned.set(value, (assigned.get(value) ?? 0) + 1);
|
||||
}
|
||||
const required = new Map<number, number>();
|
||||
for (const digit of constraint.digits) {
|
||||
required.set(digit, (required.get(digit) ?? 0) + 1);
|
||||
}
|
||||
let missing = 0;
|
||||
for (const [digit, count] of required) {
|
||||
missing += Math.max(0, count - (assigned.get(digit) ?? 0));
|
||||
}
|
||||
return missing <= blanks;
|
||||
}
|
||||
|
||||
function maximumCanMeet(
|
||||
cell: CellId,
|
||||
values: readonly number[],
|
||||
size: number,
|
||||
): boolean {
|
||||
const maximum = values[cell] ?? 0;
|
||||
const neighbours = orthogonalNeighbours(size, cell).map(
|
||||
(neighbour) => values[neighbour] ?? 0,
|
||||
);
|
||||
if (maximum === 0) {
|
||||
return neighbours.every((value) => value === 0 || value < size);
|
||||
}
|
||||
return neighbours.every((value) =>
|
||||
value === 0 ? maximum > 1 : value < maximum,
|
||||
);
|
||||
}
|
||||
|
||||
function positiveConstraintIsFeasible(
|
||||
constraint: VariantConstraint,
|
||||
values: readonly number[],
|
||||
size: number,
|
||||
@@ -168,9 +383,147 @@ export function constraintIsFeasible(
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "x-sum":
|
||||
return xSumCanMeet(
|
||||
values,
|
||||
outsideLineCells(size, constraint.side, constraint.index),
|
||||
size,
|
||||
constraint.sum,
|
||||
);
|
||||
case "skyscraper":
|
||||
return skyscraperCanMeet(
|
||||
values,
|
||||
outsideLineCells(size, constraint.side, constraint.index),
|
||||
size,
|
||||
constraint.count,
|
||||
);
|
||||
case "quadruple":
|
||||
return quadrupleCanMeet(constraint, values);
|
||||
case "maximum":
|
||||
return maximumCanMeet(constraint.cell, values, size);
|
||||
}
|
||||
}
|
||||
|
||||
function completedCells(
|
||||
constraint: VariantConstraint,
|
||||
size: number,
|
||||
): readonly CellId[] | undefined {
|
||||
switch (constraint.type) {
|
||||
case "diagonal":
|
||||
case "anti-knight":
|
||||
case "anti-king":
|
||||
case "non-consecutive":
|
||||
return undefined;
|
||||
case "killer-cage":
|
||||
case "thermo":
|
||||
case "renban":
|
||||
case "palindrome":
|
||||
case "quadruple":
|
||||
return constraint.cells;
|
||||
case "arrow":
|
||||
return [...constraint.bulb, ...constraint.line];
|
||||
case "kropki":
|
||||
case "xv":
|
||||
return [constraint.a, constraint.b];
|
||||
case "inequality":
|
||||
return [constraint.lesser, constraint.greater];
|
||||
case "x-sum":
|
||||
case "skyscraper":
|
||||
return outsideLineCells(size, constraint.side, constraint.index);
|
||||
case "maximum":
|
||||
return [constraint.cell, ...orthogonalNeighbours(size, constraint.cell)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a false clue can still be satisfied. Unlike an ordinary
|
||||
* feasibility check, a false clue only constrains the grid once the truth of
|
||||
* its positive statement is fixed. Several clue families become fixed before
|
||||
* every geometrically related cell is filled, which is important for bounded
|
||||
* exact search on liar/Wrogn puzzles.
|
||||
*/
|
||||
function negatedConstraintIsFeasible(
|
||||
constraint: VariantConstraint,
|
||||
values: readonly number[],
|
||||
size: number,
|
||||
): boolean {
|
||||
if (constraint.type === "x-sum") {
|
||||
const line = valuesAt(
|
||||
values,
|
||||
outsideLineCells(size, constraint.side, constraint.index),
|
||||
);
|
||||
const first = line[0] ?? 0;
|
||||
if (first === 0) return true;
|
||||
const prefix = line.slice(0, first);
|
||||
if (prefix.some((value) => value === 0)) return true;
|
||||
return prefix.reduce((sum, value) => sum + value, 0) !== constraint.sum;
|
||||
}
|
||||
|
||||
if (constraint.type === "quadruple") {
|
||||
const assigned = new Map<number, number>();
|
||||
for (const cell of constraint.cells) {
|
||||
const value = values[cell] ?? 0;
|
||||
if (value !== 0) assigned.set(value, (assigned.get(value) ?? 0) + 1);
|
||||
}
|
||||
const required = new Map<number, number>();
|
||||
for (const digit of constraint.digits) {
|
||||
required.set(digit, (required.get(digit) ?? 0) + 1);
|
||||
}
|
||||
const positiveAlreadyTrue = [...required].every(
|
||||
([digit, count]) => (assigned.get(digit) ?? 0) >= count,
|
||||
);
|
||||
return !positiveAlreadyTrue;
|
||||
}
|
||||
|
||||
if (constraint.type === "maximum") {
|
||||
const maximum = values[constraint.cell] ?? 0;
|
||||
if (maximum === 0) return true;
|
||||
const neighbours = orthogonalNeighbours(size, constraint.cell).map(
|
||||
(cell) => values[cell] ?? 0,
|
||||
);
|
||||
if (neighbours.some((value) => value !== 0 && value >= maximum)) {
|
||||
return true;
|
||||
}
|
||||
return !(maximum === size || neighbours.every((value) => value !== 0));
|
||||
}
|
||||
|
||||
if (constraint.type === "palindrome") {
|
||||
let allPairsKnown = true;
|
||||
for (
|
||||
let index = 0;
|
||||
index < Math.floor(constraint.cells.length / 2);
|
||||
index += 1
|
||||
) {
|
||||
const a = values[constraint.cells[index] ?? -1] ?? 0;
|
||||
const b =
|
||||
values[constraint.cells[constraint.cells.length - index - 1] ?? -1] ??
|
||||
0;
|
||||
if (a !== 0 && b !== 0 && a !== b) return true;
|
||||
if (a === 0 || b === 0) allPairsKnown = false;
|
||||
}
|
||||
if (allPairsKnown) return false;
|
||||
}
|
||||
|
||||
const relevant = completedCells(constraint, size);
|
||||
if (
|
||||
relevant === undefined ||
|
||||
relevant.some((cell) => (values[cell] ?? 0) === 0)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return !positiveConstraintIsFeasible(constraint, values, size);
|
||||
}
|
||||
|
||||
export function constraintIsFeasible(
|
||||
constraint: VariantConstraint,
|
||||
values: readonly number[],
|
||||
size: number,
|
||||
): boolean {
|
||||
const negated = "negated" in constraint && constraint.negated === true;
|
||||
if (!negated) return positiveConstraintIsFeasible(constraint, values, size);
|
||||
return negatedConstraintIsFeasible(constraint, values, size);
|
||||
}
|
||||
|
||||
function asCompiled(
|
||||
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
|
||||
): CompiledPuzzle {
|
||||
@@ -308,6 +661,7 @@ export function findConflicts(
|
||||
case "thermo":
|
||||
case "renban":
|
||||
case "palindrome":
|
||||
case "quadruple":
|
||||
return constraint.cells;
|
||||
case "arrow":
|
||||
return [...constraint.bulb, ...constraint.line];
|
||||
@@ -316,12 +670,27 @@ export function findConflicts(
|
||||
return [constraint.a, constraint.b];
|
||||
case "inequality":
|
||||
return [constraint.lesser, constraint.greater];
|
||||
case "x-sum":
|
||||
case "skyscraper":
|
||||
return outsideLineCells(
|
||||
compiled.puzzle.size,
|
||||
constraint.side,
|
||||
constraint.index,
|
||||
);
|
||||
case "maximum":
|
||||
return [
|
||||
constraint.cell,
|
||||
...orthogonalNeighbours(compiled.puzzle.size, constraint.cell),
|
||||
];
|
||||
}
|
||||
})();
|
||||
conflicts.push({
|
||||
kind: "constraint",
|
||||
cells,
|
||||
message: `${constraint.type} constraint cannot be satisfied.`,
|
||||
message:
|
||||
"negated" in constraint && constraint.negated === true
|
||||
? `${constraint.type} clue is true but must be false.`
|
||||
: `${constraint.type} constraint cannot be satisfied.`,
|
||||
constraintIndex,
|
||||
});
|
||||
}
|
||||
|
||||
+52
-1
@@ -29,12 +29,15 @@ export interface KillerCageConstraint {
|
||||
readonly sum: number;
|
||||
/** Killer cages normally do not repeat digits. Defaults to true. */
|
||||
readonly noRepeat?: boolean;
|
||||
/** If true, the completed clue must be false instead of true. */
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
export interface ThermoConstraint {
|
||||
readonly type: "thermo";
|
||||
/** Ordered from bulb to tip. Values must increase strictly. */
|
||||
readonly cells: readonly CellId[];
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
export interface ArrowConstraint {
|
||||
@@ -42,6 +45,7 @@ export interface ArrowConstraint {
|
||||
/** Bulb cells. Their values sum to the values on the line. */
|
||||
readonly bulb: readonly CellId[];
|
||||
readonly line: readonly CellId[];
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
export interface KropkiConstraint {
|
||||
@@ -50,6 +54,7 @@ export interface KropkiConstraint {
|
||||
readonly b: CellId;
|
||||
/** White means consecutive; black means a 1:2 ratio. */
|
||||
readonly kind: "white" | "black";
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
export interface XvConstraint {
|
||||
@@ -57,22 +62,64 @@ export interface XvConstraint {
|
||||
readonly a: CellId;
|
||||
readonly b: CellId;
|
||||
readonly total: 5 | 10;
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
export interface InequalityConstraint {
|
||||
readonly type: "inequality";
|
||||
readonly lesser: CellId;
|
||||
readonly greater: CellId;
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
export interface RenbanConstraint {
|
||||
readonly type: "renban";
|
||||
readonly cells: readonly CellId[];
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
export interface PalindromeConstraint {
|
||||
readonly type: "palindrome";
|
||||
readonly cells: readonly CellId[];
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
export type OutsideClueSide = "top" | "right" | "bottom" | "left";
|
||||
|
||||
/**
|
||||
* The first digit seen from `side` selects how many cells, including itself,
|
||||
* must add to `sum`. `index` is the zero-based row or column on that side.
|
||||
*/
|
||||
export interface XSumConstraint {
|
||||
readonly type: "x-sum";
|
||||
readonly side: OutsideClueSide;
|
||||
readonly index: number;
|
||||
readonly sum: number;
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
/** Number of increasing-height records seen across a row or column. */
|
||||
export interface SkyscraperConstraint {
|
||||
readonly type: "skyscraper";
|
||||
readonly side: OutsideClueSide;
|
||||
readonly index: number;
|
||||
readonly count: number;
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
/** Every listed digit, including repetitions, must occur in the clue cells. */
|
||||
export interface QuadrupleConstraint {
|
||||
readonly type: "quadruple";
|
||||
readonly cells: readonly CellId[];
|
||||
readonly digits: readonly CellValue[];
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
/** The marked cell is greater than each orthogonally adjacent cell. */
|
||||
export interface MaximumConstraint {
|
||||
readonly type: "maximum";
|
||||
readonly cell: CellId;
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
export type VariantConstraint =
|
||||
@@ -87,7 +134,11 @@ export type VariantConstraint =
|
||||
| XvConstraint
|
||||
| InequalityConstraint
|
||||
| RenbanConstraint
|
||||
| PalindromeConstraint;
|
||||
| PalindromeConstraint
|
||||
| XSumConstraint
|
||||
| SkyscraperConstraint
|
||||
| QuadrupleConstraint
|
||||
| MaximumConstraint;
|
||||
|
||||
export interface PuzzleDefinition {
|
||||
readonly version: 1;
|
||||
|
||||
+240
-12
@@ -1,4 +1,4 @@
|
||||
import { classicRegions } from "./geometry";
|
||||
import { cellsFormQuadruple, classicRegions } from "./geometry";
|
||||
import { compilePuzzle } from "./compile";
|
||||
import { findConflicts } from "./rules";
|
||||
import {
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
const MAX_CONSTRAINTS = 4_096;
|
||||
const MAX_SHORT_TEXT = 256;
|
||||
const MAX_RULES_TEXT = 16_384;
|
||||
/** Keeps deliberately impossible false-clue labels finite without requiring reachability. */
|
||||
const maximumFalseClueValue = (size: number): number => size ** 4;
|
||||
const ROOT_KEYS = new Set([
|
||||
"version",
|
||||
"size",
|
||||
@@ -35,16 +37,22 @@ const CONSTRAINT_KEYS: Readonly<
|
||||
"anti-knight": new Set(["type"]),
|
||||
"anti-king": new Set(["type"]),
|
||||
"non-consecutive": new Set(["type"]),
|
||||
"killer-cage": new Set(["type", "cells", "sum", "noRepeat"]),
|
||||
thermo: new Set(["type", "cells"]),
|
||||
arrow: new Set(["type", "bulb", "line"]),
|
||||
kropki: new Set(["type", "a", "b", "kind"]),
|
||||
xv: new Set(["type", "a", "b", "total"]),
|
||||
inequality: new Set(["type", "lesser", "greater"]),
|
||||
renban: new Set(["type", "cells"]),
|
||||
palindrome: new Set(["type", "cells"]),
|
||||
"killer-cage": new Set(["type", "cells", "sum", "noRepeat", "negated"]),
|
||||
thermo: new Set(["type", "cells", "negated"]),
|
||||
arrow: new Set(["type", "bulb", "line", "negated"]),
|
||||
kropki: new Set(["type", "a", "b", "kind", "negated"]),
|
||||
xv: new Set(["type", "a", "b", "total", "negated"]),
|
||||
inequality: new Set(["type", "lesser", "greater", "negated"]),
|
||||
renban: new Set(["type", "cells", "negated"]),
|
||||
palindrome: new Set(["type", "cells", "negated"]),
|
||||
"x-sum": new Set(["type", "side", "index", "sum", "negated"]),
|
||||
skyscraper: new Set(["type", "side", "index", "count", "negated"]),
|
||||
quadruple: new Set(["type", "cells", "digits", "negated"]),
|
||||
maximum: new Set(["type", "cell", "negated"]),
|
||||
};
|
||||
|
||||
const reachableXSumCache = new Map<number, ReadonlySet<number>>();
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -108,6 +116,72 @@ function validateCells(
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateIntegerRange(
|
||||
value: unknown,
|
||||
path: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
issues: ValidationIssue[],
|
||||
): value is number {
|
||||
if (
|
||||
!Number.isInteger(value) ||
|
||||
(value as number) < minimum ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
add(issues, path, `must be an integer from ${minimum} to ${maximum}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateOutsideClue(
|
||||
value: Record<string, unknown>,
|
||||
path: string,
|
||||
size: number,
|
||||
issues: ValidationIssue[],
|
||||
): void {
|
||||
if (
|
||||
value.side !== "top" &&
|
||||
value.side !== "right" &&
|
||||
value.side !== "bottom" &&
|
||||
value.side !== "left"
|
||||
) {
|
||||
add(issues, `${path}.side`, 'must be "top", "right", "bottom" or "left"');
|
||||
}
|
||||
validateIntegerRange(value.index, `${path}.index`, 0, size - 1, issues);
|
||||
}
|
||||
|
||||
function reachableXSumTotals(size: number): ReadonlySet<number> {
|
||||
const cached = reachableXSumCache.get(size);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const total = (size * (size + 1)) / 2;
|
||||
const reachable = new Set<number>();
|
||||
for (let first = 1; first <= size; first += 1) {
|
||||
const choose = first - 1;
|
||||
const sums = Array.from(
|
||||
{ length: choose + 1 },
|
||||
() => new Uint8Array(total + 1),
|
||||
);
|
||||
sums[0]![0] = 1;
|
||||
for (let digit = 1; digit <= size; digit += 1) {
|
||||
if (digit === first) continue;
|
||||
for (let count = choose; count >= 1; count -= 1) {
|
||||
const current = sums[count]!;
|
||||
const previous = sums[count - 1]!;
|
||||
for (let sum = total; sum >= digit; sum -= 1) {
|
||||
if (previous[sum - digit] !== 0) current[sum] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let sum = 0; sum <= total - first; sum += 1) {
|
||||
if (sums[choose]![sum] !== 0) reachable.add(first + sum);
|
||||
}
|
||||
}
|
||||
reachableXSumCache.set(size, reachable);
|
||||
return reachable;
|
||||
}
|
||||
|
||||
function validateConstraint(
|
||||
value: unknown,
|
||||
index: number,
|
||||
@@ -129,6 +203,13 @@ function validateConstraint(
|
||||
if (!allowed.has(key))
|
||||
add(issues, `${path}.${key}`, "is not a recognized field");
|
||||
}
|
||||
if (
|
||||
allowed.has("negated") &&
|
||||
value.negated !== undefined &&
|
||||
typeof value.negated !== "boolean"
|
||||
) {
|
||||
add(issues, `${path}.negated`, "must be a boolean");
|
||||
}
|
||||
const cellCount = size * size;
|
||||
switch (type) {
|
||||
case "diagonal":
|
||||
@@ -166,8 +247,17 @@ function validateConstraint(
|
||||
? (length * (2 * size - length + 1)) / 2
|
||||
: length * size;
|
||||
if (
|
||||
(value.sum as number) < minimum ||
|
||||
(value.sum as number) > maximum
|
||||
value.negated === true &&
|
||||
((value.sum as number) < 1 || (value.sum as number) > size ** 3)
|
||||
) {
|
||||
add(
|
||||
issues,
|
||||
`${path}.sum`,
|
||||
`must be a bounded positive integer (1 to ${size ** 3})`,
|
||||
);
|
||||
} else if (
|
||||
value.negated !== true &&
|
||||
((value.sum as number) < minimum || (value.sum as number) > maximum)
|
||||
) {
|
||||
add(
|
||||
issues,
|
||||
@@ -242,6 +332,86 @@ function validateConstraint(
|
||||
issues,
|
||||
);
|
||||
break;
|
||||
case "x-sum": {
|
||||
validateOutsideClue(value, path, size, issues);
|
||||
const sumValid = validateIntegerRange(
|
||||
value.sum,
|
||||
`${path}.sum`,
|
||||
1,
|
||||
value.negated === true
|
||||
? maximumFalseClueValue(size)
|
||||
: (size * (size + 1)) / 2,
|
||||
issues,
|
||||
);
|
||||
if (
|
||||
sumValid &&
|
||||
value.negated !== true &&
|
||||
!reachableXSumTotals(size).has(value.sum as number)
|
||||
) {
|
||||
add(issues, `${path}.sum`, "cannot be formed by a valid X-sum line");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "skyscraper":
|
||||
validateOutsideClue(value, path, size, issues);
|
||||
validateIntegerRange(
|
||||
value.count,
|
||||
`${path}.count`,
|
||||
1,
|
||||
value.negated === true ? maximumFalseClueValue(size) : size,
|
||||
issues,
|
||||
);
|
||||
break;
|
||||
case "quadruple": {
|
||||
const cellsValid = validateCells(
|
||||
value.cells,
|
||||
`${path}.cells`,
|
||||
cellCount,
|
||||
1,
|
||||
4,
|
||||
issues,
|
||||
);
|
||||
if (!Array.isArray(value.digits)) {
|
||||
add(issues, `${path}.digits`, "must be an array");
|
||||
break;
|
||||
}
|
||||
if (value.digits.length < 1 || value.digits.length > 4) {
|
||||
add(issues, `${path}.digits`, "must contain 1 to 4 digits");
|
||||
}
|
||||
value.digits.forEach((digit, digitIndex) => {
|
||||
validateIntegerRange(
|
||||
digit,
|
||||
`${path}.digits[${digitIndex}]`,
|
||||
1,
|
||||
size,
|
||||
issues,
|
||||
);
|
||||
});
|
||||
if (
|
||||
cellsValid &&
|
||||
value.digits.length > (value.cells as readonly number[]).length
|
||||
) {
|
||||
add(
|
||||
issues,
|
||||
`${path}.digits`,
|
||||
"must not contain more digits than clue cells",
|
||||
);
|
||||
}
|
||||
if (
|
||||
cellsValid &&
|
||||
!cellsFormQuadruple(size, value.cells as readonly number[])
|
||||
) {
|
||||
add(
|
||||
issues,
|
||||
`${path}.cells`,
|
||||
"must be the four cells surrounding one grid intersection",
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "maximum":
|
||||
validateCell(value.cell, `${path}.cell`, cellCount, issues);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,16 +457,28 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
|
||||
...(constraint.noRepeat === undefined
|
||||
? {}
|
||||
: { noRepeat: constraint.noRepeat }),
|
||||
...(constraint.negated === undefined
|
||||
? {}
|
||||
: { negated: constraint.negated }),
|
||||
};
|
||||
case "thermo":
|
||||
case "renban":
|
||||
case "palindrome":
|
||||
return { type: constraint.type, cells: [...constraint.cells] };
|
||||
return {
|
||||
type: constraint.type,
|
||||
cells: [...constraint.cells],
|
||||
...(constraint.negated === undefined
|
||||
? {}
|
||||
: { negated: constraint.negated }),
|
||||
};
|
||||
case "arrow":
|
||||
return {
|
||||
type: constraint.type,
|
||||
bulb: [...constraint.bulb],
|
||||
line: [...constraint.line],
|
||||
...(constraint.negated === undefined
|
||||
? {}
|
||||
: { negated: constraint.negated }),
|
||||
};
|
||||
case "kropki":
|
||||
return {
|
||||
@@ -304,6 +486,9 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
|
||||
a: constraint.a,
|
||||
b: constraint.b,
|
||||
kind: constraint.kind,
|
||||
...(constraint.negated === undefined
|
||||
? {}
|
||||
: { negated: constraint.negated }),
|
||||
};
|
||||
case "xv":
|
||||
return {
|
||||
@@ -311,12 +496,55 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
|
||||
a: constraint.a,
|
||||
b: constraint.b,
|
||||
total: constraint.total,
|
||||
...(constraint.negated === undefined
|
||||
? {}
|
||||
: { negated: constraint.negated }),
|
||||
};
|
||||
case "inequality":
|
||||
return {
|
||||
type: constraint.type,
|
||||
lesser: constraint.lesser,
|
||||
greater: constraint.greater,
|
||||
...(constraint.negated === undefined
|
||||
? {}
|
||||
: { negated: constraint.negated }),
|
||||
};
|
||||
case "x-sum":
|
||||
return {
|
||||
type: constraint.type,
|
||||
side: constraint.side,
|
||||
index: constraint.index,
|
||||
sum: constraint.sum,
|
||||
...(constraint.negated === undefined
|
||||
? {}
|
||||
: { negated: constraint.negated }),
|
||||
};
|
||||
case "skyscraper":
|
||||
return {
|
||||
type: constraint.type,
|
||||
side: constraint.side,
|
||||
index: constraint.index,
|
||||
count: constraint.count,
|
||||
...(constraint.negated === undefined
|
||||
? {}
|
||||
: { negated: constraint.negated }),
|
||||
};
|
||||
case "quadruple":
|
||||
return {
|
||||
type: constraint.type,
|
||||
cells: [...constraint.cells],
|
||||
digits: [...constraint.digits],
|
||||
...(constraint.negated === undefined
|
||||
? {}
|
||||
: { negated: constraint.negated }),
|
||||
};
|
||||
case "maximum":
|
||||
return {
|
||||
type: constraint.type,
|
||||
cell: constraint.cell,
|
||||
...(constraint.negated === undefined
|
||||
? {}
|
||||
: { negated: constraint.negated }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* A size-bounded adaptation of the LZ-String decompressor (MIT).
|
||||
*
|
||||
* LZ-String's public decompressors only return after constructing the complete
|
||||
* output. Importers need to stop while expanding untrusted payloads, so this
|
||||
* implementation applies both UTF-16 character and UTF-8 byte budgets before
|
||||
* appending every decoded dictionary entry.
|
||||
*/
|
||||
|
||||
const BASE64_ALPHABET =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
|
||||
const URI_SAFE_ALPHABET =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$";
|
||||
|
||||
export class LzStringOutputLimitError extends Error {
|
||||
readonly maxOutputBytes: number;
|
||||
|
||||
constructor(maxOutputBytes: number) {
|
||||
super(
|
||||
`The decompressed LZ-String output exceeds ${maxOutputBytes.toLocaleString()} bytes.`,
|
||||
);
|
||||
this.name = "LzStringOutputLimitError";
|
||||
this.maxOutputBytes = maxOutputBytes;
|
||||
}
|
||||
}
|
||||
|
||||
class OutputBudget {
|
||||
private characters = 0;
|
||||
private bytes = 0;
|
||||
private pendingHighSurrogate = false;
|
||||
private readonly maximum: number;
|
||||
|
||||
constructor(maximum: number) {
|
||||
this.maximum = maximum;
|
||||
}
|
||||
|
||||
append(value: string): void {
|
||||
this.characters += value.length;
|
||||
if (this.characters > this.maximum) this.exceeded();
|
||||
|
||||
let index = 0;
|
||||
if (this.pendingHighSurrogate) {
|
||||
if (isLowSurrogate(value.charCodeAt(0))) {
|
||||
this.bytes += 4;
|
||||
index = 1;
|
||||
} else {
|
||||
this.bytes += 3;
|
||||
}
|
||||
this.pendingHighSurrogate = false;
|
||||
}
|
||||
|
||||
while (index < value.length) {
|
||||
const codeUnit = value.charCodeAt(index);
|
||||
if (codeUnit <= 0x7f) {
|
||||
this.bytes += 1;
|
||||
} else if (codeUnit <= 0x7ff) {
|
||||
this.bytes += 2;
|
||||
} else if (isHighSurrogate(codeUnit)) {
|
||||
const next = value.charCodeAt(index + 1);
|
||||
if (isLowSurrogate(next)) {
|
||||
this.bytes += 4;
|
||||
index += 1;
|
||||
} else if (index + 1 === value.length) {
|
||||
this.pendingHighSurrogate = true;
|
||||
} else {
|
||||
this.bytes += 3;
|
||||
}
|
||||
} else {
|
||||
// TextEncoder replaces lone low surrogates with U+FFFD (three bytes).
|
||||
this.bytes += 3;
|
||||
}
|
||||
if (this.bytes > this.maximum) this.exceeded();
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
finish(): void {
|
||||
if (this.pendingHighSurrogate) {
|
||||
this.bytes += 3;
|
||||
this.pendingHighSurrogate = false;
|
||||
}
|
||||
if (this.bytes > this.maximum) this.exceeded();
|
||||
}
|
||||
|
||||
private exceeded(): never {
|
||||
throw new LzStringOutputLimitError(this.maximum);
|
||||
}
|
||||
}
|
||||
|
||||
function isHighSurrogate(codeUnit: number): boolean {
|
||||
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
|
||||
}
|
||||
|
||||
function isLowSurrogate(codeUnit: number): boolean {
|
||||
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
|
||||
}
|
||||
|
||||
function alphabetReader(alphabet: string, input: string) {
|
||||
const reverse = new Map<string, number>();
|
||||
for (let index = 0; index < alphabet.length; index += 1) {
|
||||
reverse.set(alphabet.charAt(index), index);
|
||||
}
|
||||
return (index: number) => reverse.get(input.charAt(index)) ?? 0;
|
||||
}
|
||||
|
||||
function decompressBounded(
|
||||
input: string,
|
||||
alphabet: string,
|
||||
maxOutputBytes: number,
|
||||
): string | null {
|
||||
if (input.length === 0) return null;
|
||||
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1) {
|
||||
throw new RangeError("maxOutputBytes must be a positive safe integer.");
|
||||
}
|
||||
|
||||
const nextValue = alphabetReader(alphabet, input);
|
||||
const dictionary: Array<string | null | undefined> = ["", "", ""];
|
||||
const output: string[] = [];
|
||||
const budget = new OutputBudget(maxOutputBytes);
|
||||
let enlargeIn = 4;
|
||||
let dictionarySize = 4;
|
||||
let numberOfBits = 3;
|
||||
let dataValue = nextValue(0);
|
||||
let dataPosition = 32;
|
||||
let dataIndex = 1;
|
||||
|
||||
const readBits = (count: number) => {
|
||||
let bits = 0;
|
||||
let power = 1;
|
||||
const maximumPower = 2 ** count;
|
||||
while (power !== maximumPower) {
|
||||
const bit = dataValue & dataPosition;
|
||||
dataPosition >>= 1;
|
||||
if (dataPosition === 0) {
|
||||
dataPosition = 32;
|
||||
dataValue = nextValue(dataIndex);
|
||||
dataIndex += 1;
|
||||
}
|
||||
if (bit > 0) bits |= power;
|
||||
power <<= 1;
|
||||
}
|
||||
return bits;
|
||||
};
|
||||
|
||||
const firstCode = readBits(2);
|
||||
let first: string;
|
||||
if (firstCode === 0) first = String.fromCharCode(readBits(8));
|
||||
else if (firstCode === 1) first = String.fromCharCode(readBits(16));
|
||||
else if (firstCode === 2) return "";
|
||||
else return null;
|
||||
|
||||
dictionary[3] = first;
|
||||
let previous = first;
|
||||
budget.append(first);
|
||||
output.push(first);
|
||||
|
||||
while (true) {
|
||||
if (dataIndex > input.length) return "";
|
||||
|
||||
let code = readBits(numberOfBits);
|
||||
if (code === 0 || code === 1) {
|
||||
const literal = String.fromCharCode(readBits(code === 0 ? 8 : 16));
|
||||
dictionary[dictionarySize] = literal;
|
||||
code = dictionarySize;
|
||||
dictionarySize += 1;
|
||||
enlargeIn -= 1;
|
||||
} else if (code === 2) {
|
||||
budget.finish();
|
||||
return output.join("");
|
||||
}
|
||||
|
||||
if (enlargeIn === 0) {
|
||||
enlargeIn = 2 ** numberOfBits;
|
||||
numberOfBits += 1;
|
||||
}
|
||||
|
||||
const known = dictionary[code];
|
||||
let entry: string;
|
||||
if (known === null) {
|
||||
throw new LzStringOutputLimitError(maxOutputBytes);
|
||||
} else if (known !== undefined && known.length > 0) {
|
||||
entry = known;
|
||||
} else if (code === dictionarySize) {
|
||||
if (previous.length >= maxOutputBytes) {
|
||||
throw new LzStringOutputLimitError(maxOutputBytes);
|
||||
}
|
||||
entry = previous + previous.charAt(0);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
budget.append(entry);
|
||||
output.push(entry);
|
||||
|
||||
dictionary[dictionarySize] =
|
||||
previous.length < maxOutputBytes ? previous + entry.charAt(0) : null;
|
||||
dictionarySize += 1;
|
||||
enlargeIn -= 1;
|
||||
previous = entry;
|
||||
|
||||
if (enlargeIn === 0) {
|
||||
enlargeIn = 2 ** numberOfBits;
|
||||
numberOfBits += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function decompressFromBase64Bounded(
|
||||
input: string,
|
||||
maxOutputBytes: number,
|
||||
): string | null {
|
||||
return decompressBounded(input, BASE64_ALPHABET, maxOutputBytes);
|
||||
}
|
||||
|
||||
export function decompressFromEncodedURIComponentBounded(
|
||||
input: string,
|
||||
maxOutputBytes: number,
|
||||
): string | null {
|
||||
return decompressBounded(
|
||||
input.replaceAll(" ", "+"),
|
||||
URI_SAFE_ALPHABET,
|
||||
maxOutputBytes,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an LZ-String payload which may use either public six-bit alphabet.
|
||||
*
|
||||
* Values 0–62 have identical symbols in the Base64 and URI-safe alphabets.
|
||||
* Base64 uniquely uses `/` and `=`, while the URI-safe form uniquely uses `-`
|
||||
* and `$`. Selecting from those markers is important: LZ-String treats an
|
||||
* unknown symbol as zero, which can otherwise yield non-empty, subtly corrupt
|
||||
* JSON instead of a decompression failure.
|
||||
*/
|
||||
export function decompressFromBase64OrUriComponentBounded(
|
||||
input: string,
|
||||
maxOutputBytes: number,
|
||||
): string | null {
|
||||
const normalized = input.replaceAll(" ", "+");
|
||||
const hasBase64OnlySymbol = /[/=]/u.test(normalized);
|
||||
const hasUriOnlySymbol = /[-$]/u.test(normalized);
|
||||
if (hasBase64OnlySymbol && hasUriOnlySymbol) return null;
|
||||
if (hasUriOnlySymbol) {
|
||||
return decompressFromEncodedURIComponentBounded(normalized, maxOutputBytes);
|
||||
}
|
||||
return decompressFromBase64Bounded(normalized, maxOutputBytes);
|
||||
}
|
||||
+140
-2
@@ -1,7 +1,10 @@
|
||||
import { cellsFormQuadruple } from "../domain/geometry";
|
||||
import { normalizePortableAidMemoire } from "../state/aidMemoire";
|
||||
import {
|
||||
SUDOKU_DOCUMENT_SCHEMA,
|
||||
SUDOKU_DOCUMENT_VERSION,
|
||||
cloneConstraint,
|
||||
type PortableAidMemoire,
|
||||
type PortableConstraint,
|
||||
type SudokuDocument,
|
||||
} from "./types";
|
||||
@@ -121,6 +124,34 @@ function pair(
|
||||
return { a, b };
|
||||
}
|
||||
|
||||
function outsideSide(value: unknown): "top" | "right" | "bottom" | "left" {
|
||||
if (
|
||||
value !== "top" &&
|
||||
value !== "right" &&
|
||||
value !== "bottom" &&
|
||||
value !== "left"
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_CONSTRAINT",
|
||||
"An outside clue side must be top, right, bottom, or left.",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function cluePolarity(
|
||||
value: Record<string, unknown>,
|
||||
label: string,
|
||||
): { negated?: boolean } {
|
||||
if (value.negated !== undefined && typeof value.negated !== "boolean") {
|
||||
return fail(
|
||||
"INVALID_CONSTRAINT",
|
||||
`${label}.negated must be true or false.`,
|
||||
);
|
||||
}
|
||||
return value.negated === true ? { negated: true } : {};
|
||||
}
|
||||
|
||||
function parseConstraint(
|
||||
value: unknown,
|
||||
cellCount: number,
|
||||
@@ -131,6 +162,7 @@ function parseConstraint(
|
||||
"Each constraint must be an object with a type.",
|
||||
);
|
||||
}
|
||||
const size = Math.sqrt(cellCount);
|
||||
switch (value.type) {
|
||||
case "diagonal": {
|
||||
if (value.direction !== "main" && value.direction !== "anti") {
|
||||
@@ -164,6 +196,7 @@ function parseConstraint(
|
||||
cells: cageCells,
|
||||
sum,
|
||||
...(value.noRepeat === undefined ? {} : { noRepeat: value.noRepeat }),
|
||||
...cluePolarity(value, "killer-cage"),
|
||||
};
|
||||
}
|
||||
case "thermo":
|
||||
@@ -172,12 +205,14 @@ function parseConstraint(
|
||||
return {
|
||||
type: value.type,
|
||||
cells: cells(value.cells, `${value.type}.cells`, cellCount, 2),
|
||||
...cluePolarity(value, value.type),
|
||||
};
|
||||
case "arrow":
|
||||
return {
|
||||
type: "arrow",
|
||||
bulb: cells(value.bulb, "arrow.bulb", cellCount),
|
||||
line: cells(value.line, "arrow.line", cellCount),
|
||||
...cluePolarity(value, "arrow"),
|
||||
};
|
||||
case "kropki": {
|
||||
const related = pair(value, cellCount);
|
||||
@@ -187,7 +222,12 @@ function parseConstraint(
|
||||
"A Kropki kind must be white or black.",
|
||||
);
|
||||
}
|
||||
return { type: "kropki", ...related, kind: value.kind };
|
||||
return {
|
||||
type: "kropki",
|
||||
...related,
|
||||
kind: value.kind,
|
||||
...cluePolarity(value, "kropki"),
|
||||
};
|
||||
}
|
||||
case "xv": {
|
||||
const related = pair(value, cellCount);
|
||||
@@ -198,6 +238,7 @@ function parseConstraint(
|
||||
type: "xv",
|
||||
...related,
|
||||
total: value.total,
|
||||
...cluePolarity(value, "xv"),
|
||||
};
|
||||
}
|
||||
case "inequality": {
|
||||
@@ -209,8 +250,83 @@ function parseConstraint(
|
||||
"An inequality must join two different cells.",
|
||||
);
|
||||
}
|
||||
return { type: "inequality", lesser, greater };
|
||||
return {
|
||||
type: "inequality",
|
||||
lesser,
|
||||
greater,
|
||||
...cluePolarity(value, "inequality"),
|
||||
};
|
||||
}
|
||||
case "x-sum": {
|
||||
const polarity = cluePolarity(value, "x-sum");
|
||||
return {
|
||||
type: "x-sum",
|
||||
side: outsideSide(value.side),
|
||||
index: integer(value.index, "x-sum.index", 0, size - 1),
|
||||
sum: integer(
|
||||
value.sum,
|
||||
"x-sum.sum",
|
||||
1,
|
||||
polarity.negated === true ? size ** 4 : (size * (size + 1)) / 2,
|
||||
),
|
||||
...polarity,
|
||||
};
|
||||
}
|
||||
case "skyscraper": {
|
||||
const polarity = cluePolarity(value, "skyscraper");
|
||||
return {
|
||||
type: "skyscraper",
|
||||
side: outsideSide(value.side),
|
||||
index: integer(value.index, "skyscraper.index", 0, size - 1),
|
||||
count: integer(
|
||||
value.count,
|
||||
"skyscraper.count",
|
||||
1,
|
||||
polarity.negated === true ? size ** 4 : size,
|
||||
),
|
||||
...polarity,
|
||||
};
|
||||
}
|
||||
case "quadruple": {
|
||||
const quadrupleCells = cells(value.cells, "quadruple.cells", cellCount);
|
||||
if (quadrupleCells.length > 4) {
|
||||
return fail(
|
||||
"INVALID_CELLS",
|
||||
"quadruple.cells must contain at most four cells.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Array.isArray(value.digits) ||
|
||||
value.digits.length < 1 ||
|
||||
value.digits.length > 4 ||
|
||||
value.digits.length > quadrupleCells.length
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_CONSTRAINT",
|
||||
"quadruple.digits must contain one to four digits and no more digits than clue cells.",
|
||||
);
|
||||
}
|
||||
if (!cellsFormQuadruple(size, quadrupleCells)) {
|
||||
return fail(
|
||||
"INVALID_CELLS",
|
||||
"quadruple.cells must be the four cells surrounding one grid intersection.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: "quadruple",
|
||||
cells: quadrupleCells,
|
||||
digits: value.digits.map((digit, index) =>
|
||||
integer(digit, `quadruple.digits[${index}]`, 1, size),
|
||||
),
|
||||
...cluePolarity(value, "quadruple"),
|
||||
};
|
||||
}
|
||||
case "maximum":
|
||||
return {
|
||||
type: "maximum",
|
||||
cell: cell(value.cell, "maximum.cell", cellCount),
|
||||
...cluePolarity(value, "maximum"),
|
||||
};
|
||||
default:
|
||||
return fail(
|
||||
"UNSUPPORTED_CONSTRAINT",
|
||||
@@ -316,6 +432,19 @@ export function normalizeSudokuDocument(value: unknown): SudokuDocument {
|
||||
}
|
||||
elapsedMs = value.elapsedMs;
|
||||
}
|
||||
let aidMemoire: PortableAidMemoire | undefined;
|
||||
if (value.aidMemoire !== undefined) {
|
||||
try {
|
||||
aidMemoire = normalizePortableAidMemoire(value.aidMemoire, size);
|
||||
} catch (error) {
|
||||
return fail(
|
||||
"INVALID_AID_MEMOIRE",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "The aid-mémoire data is invalid.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(value.constraints) ||
|
||||
@@ -348,6 +477,7 @@ export function normalizeSudokuDocument(value: unknown): SudokuDocument {
|
||||
...(candidates === undefined ? {} : { candidates }),
|
||||
...(colors === undefined ? {} : { colors }),
|
||||
...(elapsedMs === undefined ? {} : { elapsedMs }),
|
||||
...(aidMemoire === undefined ? {} : { aidMemoire }),
|
||||
...(regions === undefined ? {} : { regions }),
|
||||
...(title === undefined ? {} : { title }),
|
||||
...(author === undefined ? {} : { author }),
|
||||
@@ -416,6 +546,14 @@ export function cloneSudokuDocument(value: SudokuDocument): SudokuDocument {
|
||||
...(normalized.colors === undefined
|
||||
? {}
|
||||
: { colors: [...normalized.colors] }),
|
||||
...(normalized.aidMemoire === undefined
|
||||
? {}
|
||||
: {
|
||||
aidMemoire: normalizePortableAidMemoire(
|
||||
normalized.aidMemoire,
|
||||
normalized.size,
|
||||
),
|
||||
}),
|
||||
...(normalized.regions === undefined
|
||||
? {}
|
||||
: { regions: [...normalized.regions] }),
|
||||
|
||||
+179
-30
@@ -1,14 +1,16 @@
|
||||
import { compressToBase64 } from "lz-string";
|
||||
import { cellsFormQuadruple } from "../domain/geometry";
|
||||
import {
|
||||
compressToBase64,
|
||||
decompressFromBase64,
|
||||
decompressFromEncodedURIComponent,
|
||||
} from "lz-string";
|
||||
LzStringOutputLimitError,
|
||||
decompressFromBase64OrUriComponentBounded,
|
||||
} from "./boundedLz";
|
||||
import {
|
||||
MAX_BOARD_SIZE,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
MIN_BOARD_SIZE,
|
||||
SudokuFormatError,
|
||||
} from "./document";
|
||||
import { UnsupportedPuzzleConstructsError } from "./interoperability";
|
||||
import {
|
||||
SUDOKU_DOCUMENT_SCHEMA,
|
||||
SUDOKU_DOCUMENT_VERSION,
|
||||
@@ -38,6 +40,10 @@ const SUPPORTED_ROOT_FIELDS = new Set([
|
||||
"ratio",
|
||||
"xv",
|
||||
"inequality",
|
||||
"xsum",
|
||||
"skyscraper",
|
||||
"quadruple",
|
||||
"maximum",
|
||||
"renban",
|
||||
"palindrome",
|
||||
"disabledlogic",
|
||||
@@ -54,10 +60,8 @@ const UNSUPPORTED_RULE_FIELDS: Readonly<Record<string, string>> = {
|
||||
odd: "odd cells",
|
||||
extraregion: "extra regions",
|
||||
clone: "clone regions",
|
||||
quadruple: "quadruples",
|
||||
betweenline: "between lines",
|
||||
minimum: "minimum cells",
|
||||
maximum: "maximum cells",
|
||||
whispers: "whisper lines",
|
||||
regionsumline: "region-sum lines",
|
||||
entropicline: "entropic lines",
|
||||
@@ -69,8 +73,6 @@ const UNSUPPORTED_RULE_FIELDS: Readonly<Record<string, string>> = {
|
||||
rowindexer: "row indexers",
|
||||
columnindexer: "column indexers",
|
||||
boxindexer: "box indexers",
|
||||
xsum: "X-sums",
|
||||
skyscraper: "skyscrapers",
|
||||
fogofwar: "fog of war",
|
||||
foglight: "fog lights",
|
||||
cage: "generic cages",
|
||||
@@ -115,32 +117,29 @@ function present(value: unknown): boolean {
|
||||
}
|
||||
|
||||
function assertSupportedRootFields(value: JsonRecord): void {
|
||||
const constructs: string[] = [];
|
||||
for (const [field, raw] of Object.entries(value)) {
|
||||
const unsupported = UNSUPPORTED_RULE_FIELDS[field];
|
||||
if (unsupported !== undefined && present(raw)) {
|
||||
return fail(
|
||||
"UNSUPPORTED_FPUZZLES",
|
||||
`This puzzle uses ${unsupported}, which Sudoku Tools cannot enforce yet. Import stopped rather than silently weakening the puzzle.`,
|
||||
);
|
||||
constructs.push(unsupported);
|
||||
continue;
|
||||
}
|
||||
const decoration = DECORATION_FIELDS[field];
|
||||
if (decoration !== undefined && present(raw)) {
|
||||
return fail(
|
||||
"UNSUPPORTED_FPUZZLES",
|
||||
`This puzzle contains ${decoration}, which cannot be preserved yet. Import stopped rather than discarding them.`,
|
||||
);
|
||||
constructs.push(decoration);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!SUPPORTED_ROOT_FIELDS.has(field) &&
|
||||
unsupported === undefined &&
|
||||
decoration === undefined
|
||||
) {
|
||||
return fail(
|
||||
"UNSUPPORTED_FPUZZLES",
|
||||
`Unknown fpuzzles field “${field}”. Import stopped so a rule or visual cannot be silently lost.`,
|
||||
);
|
||||
constructs.push(`Unknown fpuzzles field “${field}”`);
|
||||
}
|
||||
}
|
||||
if (constructs.length > 0) {
|
||||
throw new UnsupportedPuzzleConstructsError("f-puzzles", constructs);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedJson(input: string): unknown {
|
||||
@@ -190,6 +189,56 @@ export function addressFromCellIndex(index: number, size: number): string {
|
||||
return `R${Math.floor(index / size) + 1}C${(index % size) + 1}`;
|
||||
}
|
||||
|
||||
type OutsideSide = "top" | "right" | "bottom" | "left";
|
||||
|
||||
function outsideClueFromAddress(
|
||||
value: unknown,
|
||||
size: number,
|
||||
): { side: OutsideSide; index: number } {
|
||||
if (typeof value !== "string") {
|
||||
return fail(
|
||||
"INVALID_CELL",
|
||||
"An fpuzzles outside clue must use an RnCn address.",
|
||||
);
|
||||
}
|
||||
const match = /^R(\d+)C(\d+)$/iu.exec(value.trim());
|
||||
if (match === null) {
|
||||
return fail("INVALID_CELL", `Invalid fpuzzles outside address: ${value}.`);
|
||||
}
|
||||
const row = Number(match[1]);
|
||||
const column = Number(match[2]);
|
||||
if (row === 0 && column >= 1 && column <= size)
|
||||
return { side: "top", index: column - 1 };
|
||||
if (row === size + 1 && column >= 1 && column <= size)
|
||||
return { side: "bottom", index: column - 1 };
|
||||
if (column === 0 && row >= 1 && row <= size)
|
||||
return { side: "left", index: row - 1 };
|
||||
if (column === size + 1 && row >= 1 && row <= size)
|
||||
return { side: "right", index: row - 1 };
|
||||
return fail(
|
||||
"INVALID_CELL",
|
||||
`Outside clue ${value} must sit immediately beyond one grid edge.`,
|
||||
);
|
||||
}
|
||||
|
||||
function addressFromOutsideClue(
|
||||
side: OutsideSide,
|
||||
index: number,
|
||||
size: number,
|
||||
): string {
|
||||
const position = index + 1;
|
||||
switch (side) {
|
||||
case "top":
|
||||
return `R0C${position}`;
|
||||
case "right":
|
||||
return `R${position}C${size + 1}`;
|
||||
case "bottom":
|
||||
return `R${size + 1}C${position}`;
|
||||
case "left":
|
||||
return `R${position}C0`;
|
||||
}
|
||||
}
|
||||
|
||||
function fpCells(value: unknown, size: number, minimum = 1): number[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
@@ -470,7 +519,62 @@ export function parseFpuzzles(value: unknown): SudokuDocument {
|
||||
const [a, b] = inequalityCells as [number, number];
|
||||
if (inequality.value === ">")
|
||||
constraints.push({ type: "inequality", lesser: b, greater: a });
|
||||
else constraints.push({ type: "inequality", lesser: a, greater: b });
|
||||
else if (inequality.value === "<")
|
||||
constraints.push({ type: "inequality", lesser: a, greater: b });
|
||||
else {
|
||||
return fail(
|
||||
"INVALID_FPUZZLES",
|
||||
'inequality.value must be either ">" or "<".',
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const clue of objects(value.xsum)) {
|
||||
constraints.push({
|
||||
type: "x-sum",
|
||||
...outsideClueFromAddress(clue.cell, size),
|
||||
sum: numeric(clue.value, "xsum.value", 1, (size * (size + 1)) / 2),
|
||||
});
|
||||
}
|
||||
for (const clue of objects(value.skyscraper)) {
|
||||
constraints.push({
|
||||
type: "skyscraper",
|
||||
...outsideClueFromAddress(clue.cell, size),
|
||||
count: numeric(clue.value, "skyscraper.value", 1, size),
|
||||
});
|
||||
}
|
||||
for (const clue of objects(value.quadruple)) {
|
||||
const quadrupleCells = fpCells(clue.cells, size);
|
||||
if (
|
||||
quadrupleCells.length > 4 ||
|
||||
!Array.isArray(clue.values) ||
|
||||
clue.values.length < 1 ||
|
||||
clue.values.length > 4 ||
|
||||
clue.values.length > quadrupleCells.length
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_FPUZZLES",
|
||||
"A quadruple must contain one to four cells and clue digits.",
|
||||
);
|
||||
}
|
||||
if (!cellsFormQuadruple(size, quadrupleCells)) {
|
||||
return fail(
|
||||
"INVALID_CELLS",
|
||||
"A quadruple must use the four cells surrounding one grid intersection.",
|
||||
);
|
||||
}
|
||||
constraints.push({
|
||||
type: "quadruple",
|
||||
cells: quadrupleCells,
|
||||
digits: clue.values.map((digit, index) =>
|
||||
numeric(digit, `quadruple.values[${index}]`, 1, size),
|
||||
),
|
||||
});
|
||||
}
|
||||
for (const clue of objects(value.maximum)) {
|
||||
constraints.push({
|
||||
type: "maximum",
|
||||
cell: cellIndexFromAddress(clue.cell, size),
|
||||
});
|
||||
}
|
||||
|
||||
const regions = readRegions(grid, size);
|
||||
@@ -548,6 +652,12 @@ export function exportFpuzzles(document: SudokuDocument): JsonRecord {
|
||||
};
|
||||
|
||||
for (const constraint of document.constraints) {
|
||||
if ("negated" in constraint && constraint.negated === true) {
|
||||
return fail(
|
||||
"UNSUPPORTED_FPUZZLES",
|
||||
"fpuzzles export cannot preserve an individually false clue. Use Sudoku Tools JSON instead.",
|
||||
);
|
||||
}
|
||||
switch (constraint.type) {
|
||||
case "diagonal":
|
||||
output[constraint.direction === "main" ? "diagonal+" : "diagonal-"] =
|
||||
@@ -621,6 +731,39 @@ export function exportFpuzzles(document: SudokuDocument): JsonRecord {
|
||||
value: "<",
|
||||
});
|
||||
break;
|
||||
case "x-sum":
|
||||
append("xsum", {
|
||||
cell: addressFromOutsideClue(
|
||||
constraint.side,
|
||||
numeric(constraint.index, "xsum.index", 0, size - 1),
|
||||
size,
|
||||
),
|
||||
value: String(
|
||||
numeric(constraint.sum, "xsum.value", 1, (size * (size + 1)) / 2),
|
||||
),
|
||||
});
|
||||
break;
|
||||
case "skyscraper":
|
||||
append("skyscraper", {
|
||||
cell: addressFromOutsideClue(
|
||||
constraint.side,
|
||||
numeric(constraint.index, "skyscraper.index", 0, size - 1),
|
||||
size,
|
||||
),
|
||||
value: String(numeric(constraint.count, "skyscraper.value", 1, size)),
|
||||
});
|
||||
break;
|
||||
case "quadruple":
|
||||
append("quadruple", {
|
||||
cells: constraintCells(constraint.cells, size),
|
||||
values: [...constraint.digits],
|
||||
});
|
||||
break;
|
||||
case "maximum":
|
||||
append("maximum", {
|
||||
cell: addressFromCellIndex(constraint.cell, size),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,21 +818,27 @@ function decodePayload(payload: string): string {
|
||||
} catch {
|
||||
// URLSearchParams already decodes input. Keep the original if a literal % is malformed.
|
||||
}
|
||||
const base64 = decompressFromBase64(decodedPayload.replaceAll(" ", "+"));
|
||||
const uriEncoded =
|
||||
base64 || decompressFromEncodedURIComponent(decodedPayload);
|
||||
let uriEncoded: string | null;
|
||||
try {
|
||||
uriEncoded = decompressFromBase64OrUriComponentBounded(
|
||||
decodedPayload,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof LzStringOutputLimitError) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decompressed fpuzzles puzzle is too large to open safely.",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (uriEncoded === null || uriEncoded === "") {
|
||||
return fail(
|
||||
"INVALID_FPUZZLES",
|
||||
"The inline fpuzzles payload could not be decompressed.",
|
||||
);
|
||||
}
|
||||
if (new TextEncoder().encode(uriEncoded).byteLength > MAX_DOCUMENT_BYTES) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decompressed fpuzzles puzzle is too large to open safely.",
|
||||
);
|
||||
}
|
||||
return uriEncoded;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
MAX_DOCUMENT_BYTES,
|
||||
parseSudokuDocument,
|
||||
SudokuFormatError,
|
||||
} from "./document";
|
||||
import {
|
||||
importFpuzzles,
|
||||
NetworkPuzzleIdError,
|
||||
parseFpuzzles,
|
||||
} from "./fpuzzles";
|
||||
import {
|
||||
type PuzzleImportResult,
|
||||
RemotePuzzleReferenceError,
|
||||
} from "./interoperability";
|
||||
import { importPenpa, parsePenpaText } from "./penpa";
|
||||
import { parsePlainGrid } from "./grid";
|
||||
import { decodePuzzleHash } from "./share";
|
||||
import { importSudokuPad, parseSudokuPadPuzzle } from "./sudokupad";
|
||||
import type { SudokuDocument } from "./types";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function urlPayload(input: string): {
|
||||
readonly kind: "fpuzzles" | "sudokupad" | "penpa" | "remote";
|
||||
readonly value: string;
|
||||
} | null {
|
||||
if (!/^https?:\/\//iu.test(input)) return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(input);
|
||||
} catch {
|
||||
return { kind: "remote", value: input };
|
||||
}
|
||||
const host = url.hostname.toLowerCase();
|
||||
if (url.pathname.includes("/penpa-edit/")) {
|
||||
return { kind: "penpa", value: input };
|
||||
}
|
||||
if (host === "f-puzzles.com" || host === "www.f-puzzles.com") {
|
||||
return { kind: "fpuzzles", value: input };
|
||||
}
|
||||
if (host === "sudokupad.app" || host.endsWith(".sudokupad.app")) {
|
||||
let pathCandidate: string;
|
||||
try {
|
||||
pathCandidate = decodeURIComponent(
|
||||
url.pathname.replace(/^\/+|\/+$/gu, ""),
|
||||
);
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_SUDOKUPAD_URL",
|
||||
"The SudokuPad URL contains invalid escaped text.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const candidate =
|
||||
url.searchParams.get("puzzleid") ??
|
||||
url.searchParams.get("load") ??
|
||||
pathCandidate;
|
||||
if (/^(?:ctc|scl)/iu.test(candidate)) {
|
||||
return { kind: "sudokupad", value: candidate };
|
||||
}
|
||||
if (/^fpuzzles/iu.test(candidate)) {
|
||||
return { kind: "fpuzzles", value: input };
|
||||
}
|
||||
throw new NetworkPuzzleIdError(candidate || "unknown");
|
||||
}
|
||||
return { kind: "remote", value: input };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect and import every supported local representation. The function is
|
||||
* asynchronous only because Penpa+ uses browser-native raw-deflate streams;
|
||||
* no branch performs a network request.
|
||||
*/
|
||||
export async function importPuzzle(
|
||||
input: string,
|
||||
): Promise<PuzzleImportResult<SudokuDocument>> {
|
||||
if (input.length > MAX_DOCUMENT_BYTES) {
|
||||
throw new SudokuFormatError(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The pasted puzzle data is too large to open safely.",
|
||||
);
|
||||
}
|
||||
const trimmed = input.trim().replace(/^\uFEFF/u, "");
|
||||
const url = urlPayload(trimmed);
|
||||
if (url?.kind === "remote") {
|
||||
throw new RemotePuzzleReferenceError("The pasted URL");
|
||||
}
|
||||
if (url?.kind === "penpa") {
|
||||
return {
|
||||
document: await importPenpa(url.value),
|
||||
format: "penpa",
|
||||
label: "Penpa+",
|
||||
};
|
||||
}
|
||||
if (url?.kind === "sudokupad") {
|
||||
return {
|
||||
document: importSudokuPad(url.value),
|
||||
format: "sudokupad",
|
||||
label: "SudokuPad/CTC",
|
||||
};
|
||||
}
|
||||
if (url?.kind === "fpuzzles") {
|
||||
return {
|
||||
document: importFpuzzles(url.value),
|
||||
format: "fpuzzles",
|
||||
label: "f-puzzles",
|
||||
};
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("#sudoku=") || trimmed.includes("#sudoku=")) {
|
||||
return {
|
||||
document: decodePuzzleHash(trimmed),
|
||||
format: "sudoku-tools",
|
||||
label: "Sudoku Tools share link",
|
||||
};
|
||||
}
|
||||
if (/^(?:ctc|scl)/iu.test(trimmed)) {
|
||||
return {
|
||||
document: importSudokuPad(trimmed),
|
||||
format: "sudokupad",
|
||||
label: "SudokuPad/CTC",
|
||||
};
|
||||
}
|
||||
if (/^penpa:/iu.test(trimmed) || /^[?#]?(?:m=[^&]+&)?p=/iu.test(trimmed)) {
|
||||
return {
|
||||
document: await importPenpa(trimmed),
|
||||
format: "penpa",
|
||||
label: "Penpa+",
|
||||
};
|
||||
}
|
||||
if (/^(?:square|sudoku),[^\r\n]+[\r\n]/iu.test(trimmed)) {
|
||||
return {
|
||||
document: parsePenpaText(trimmed),
|
||||
format: "penpa",
|
||||
label: "Penpa+ text",
|
||||
};
|
||||
}
|
||||
if (/^fpuzzles/iu.test(trimmed)) {
|
||||
return {
|
||||
document: importFpuzzles(trimmed),
|
||||
format: "fpuzzles",
|
||||
label: "f-puzzles",
|
||||
};
|
||||
}
|
||||
if (trimmed.startsWith("{")) {
|
||||
if (new TextEncoder().encode(trimmed).byteLength > MAX_DOCUMENT_BYTES) {
|
||||
throw new SudokuFormatError(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The pasted puzzle JSON is too large to open safely.",
|
||||
);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed) as unknown;
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_JSON",
|
||||
"The pasted puzzle data is not valid JSON.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
if (isRecord(parsed) && "schema" in parsed) {
|
||||
return {
|
||||
document: parseSudokuDocument(trimmed),
|
||||
format: "sudoku-tools",
|
||||
label: "Sudoku Tools JSON",
|
||||
};
|
||||
}
|
||||
if (isRecord(parsed) && "cells" in parsed && !("grid" in parsed)) {
|
||||
return {
|
||||
document: parseSudokuPadPuzzle(parsed),
|
||||
format: "sudokupad",
|
||||
label: "SudokuPad/CTC JSON",
|
||||
};
|
||||
}
|
||||
return {
|
||||
document: parseFpuzzles(parsed),
|
||||
format: "fpuzzles",
|
||||
label: "f-puzzles JSON",
|
||||
};
|
||||
}
|
||||
return {
|
||||
document: parsePlainGrid(trimmed),
|
||||
format: "plain-grid",
|
||||
label: "plain grid",
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
export * from "./document";
|
||||
export * from "./fpuzzles";
|
||||
export * from "./grid";
|
||||
export * from "./import";
|
||||
export * from "./interoperability";
|
||||
export * from "./penpa";
|
||||
export * from "./share";
|
||||
export * from "./sudokupad";
|
||||
export * from "./types";
|
||||
export * from "./visual";
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { SudokuFormatError } from "./document";
|
||||
|
||||
export type PuzzleSourceFormat =
|
||||
"sudoku-tools" | "plain-grid" | "fpuzzles" | "sudokupad" | "penpa";
|
||||
|
||||
export interface PuzzleImportResult<T> {
|
||||
readonly document: T;
|
||||
readonly format: PuzzleSourceFormat;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export class UnsupportedPuzzleConstructsError extends SudokuFormatError {
|
||||
readonly format: string;
|
||||
readonly constructs: readonly string[];
|
||||
|
||||
constructor(format: string, constructs: readonly string[]) {
|
||||
const unique = [...new Set(constructs)].slice(0, 24);
|
||||
const remainder = Math.max(0, new Set(constructs).size - unique.length);
|
||||
super(
|
||||
`UNSUPPORTED_${format.toUpperCase().replaceAll(/[^A-Z0-9]+/gu, "_")}`,
|
||||
`${format} contains unsupported constructs: ${unique.join(", ")}${remainder > 0 ? `, and ${String(remainder)} more` : ""}. Import stopped rather than silently weakening or changing the puzzle.`,
|
||||
);
|
||||
this.name = "UnsupportedPuzzleConstructsError";
|
||||
this.format = format;
|
||||
this.constructs = unique;
|
||||
}
|
||||
}
|
||||
|
||||
export class RemotePuzzleReferenceError extends SudokuFormatError {
|
||||
constructor(service: string) {
|
||||
super(
|
||||
"REMOTE_PUZZLE_REFERENCE",
|
||||
`${service} is a server-hosted or shortened puzzle reference. This local-only app never fetches remote puzzle IDs; paste a self-contained link or exported puzzle data instead.`,
|
||||
);
|
||||
this.name = "RemotePuzzleReferenceError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
import {
|
||||
MAX_BOARD_SIZE,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
MIN_BOARD_SIZE,
|
||||
SudokuFormatError,
|
||||
} from "./document";
|
||||
import {
|
||||
RemotePuzzleReferenceError,
|
||||
UnsupportedPuzzleConstructsError,
|
||||
} from "./interoperability";
|
||||
import {
|
||||
SUDOKU_DOCUMENT_SCHEMA,
|
||||
SUDOKU_DOCUMENT_VERSION,
|
||||
type PortableConstraint,
|
||||
type SudokuDocument,
|
||||
} from "./types";
|
||||
|
||||
export const MAX_PENPA_PAYLOAD_LENGTH = 524_288;
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
/* Penpa+ applies these substitutions before raw-deflate compression. */
|
||||
const PENPA_SUBSTITUTIONS = [
|
||||
["z", "zZ"],
|
||||
['"qa"', "z9"],
|
||||
['"pu_q"', "zQ"],
|
||||
['"pu_a"', "zA"],
|
||||
['"grid"', "zG"],
|
||||
['"edit_mode"', "zM"],
|
||||
['"surface"', "zS"],
|
||||
['"line"', "zL"],
|
||||
['"lineE"', "zE"],
|
||||
['"wall"', "zW"],
|
||||
['"cage"', "zC"],
|
||||
['"number"', "zN"],
|
||||
['"symbol"', "zY"],
|
||||
['"special"', "zP"],
|
||||
['"board"', "zB"],
|
||||
['"command_redo"', "zR"],
|
||||
['"command_undo"', "zU"],
|
||||
['"command_replay"', "z8"],
|
||||
['"numberS"', "z1"],
|
||||
['"freeline"', "zF"],
|
||||
['"freelineE"', "z2"],
|
||||
['"thermo"', "zT"],
|
||||
['"arrows"', "z3"],
|
||||
['"direction"', "zD"],
|
||||
['"squareframe"', "z0"],
|
||||
['"polygon"', "z5"],
|
||||
['"deletelineE"', "z4"],
|
||||
['"killercages"', "z6"],
|
||||
['"nobulbthermo"', "z7"],
|
||||
['"__a"', "z_"],
|
||||
["null", "zO"],
|
||||
] as const;
|
||||
|
||||
const LAYER_FIELDS = new Set([
|
||||
"command_redo",
|
||||
"command_undo",
|
||||
"command_replay",
|
||||
"surface",
|
||||
"number",
|
||||
"numberS",
|
||||
"symbol",
|
||||
"thermo",
|
||||
"arrows",
|
||||
"direction",
|
||||
"squareframe",
|
||||
"polygon",
|
||||
"line",
|
||||
"lineE",
|
||||
"wall",
|
||||
"cage",
|
||||
"freeline",
|
||||
"freelineE",
|
||||
"deletelineE",
|
||||
"killercages",
|
||||
"nobulbthermo",
|
||||
]);
|
||||
|
||||
function fail(code: string, message: string): never {
|
||||
throw new SudokuFormatError(code, message);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function boundedJson(input: string, label: string): unknown {
|
||||
if (input.length > MAX_DOCUMENT_BYTES) {
|
||||
return fail("LIMIT_EXCEEDED", `${label} is too large to open safely.`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(input) as unknown;
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_PENPA",
|
||||
`${label} is not valid JSON.`,
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function integer(
|
||||
value: unknown,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const parsed =
|
||||
typeof value === "string" && value.trim() !== "" ? Number(value) : value;
|
||||
if (
|
||||
!Number.isInteger(parsed) ||
|
||||
(parsed as number) < minimum ||
|
||||
(parsed as number) > maximum
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_PENPA",
|
||||
`${label} must be an integer from ${minimum} to ${maximum}.`,
|
||||
);
|
||||
}
|
||||
return parsed as number;
|
||||
}
|
||||
|
||||
function hasContent(value: unknown): boolean {
|
||||
if (value === undefined || value === null) return false;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
if (isRecord(value)) {
|
||||
if (
|
||||
Object.keys(value).length === 1 &&
|
||||
isRecord(value.command_redo) &&
|
||||
Array.isArray(value.command_redo.__a)
|
||||
) {
|
||||
return value.command_redo.__a.length > 0;
|
||||
}
|
||||
return Object.keys(value).length > 0;
|
||||
}
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function expandCenterList(value: unknown): number[] {
|
||||
if (!Array.isArray(value) || value.length > MAX_BOARD_SIZE ** 2) {
|
||||
return fail("LIMIT_EXCEEDED", "Penpa+ has an invalid or oversized grid.");
|
||||
}
|
||||
const result: number[] = [];
|
||||
let previous = 0;
|
||||
value.forEach((raw, index) => {
|
||||
const delta = integer(
|
||||
raw,
|
||||
`centerlist[${String(index)}]`,
|
||||
-100_000,
|
||||
100_000,
|
||||
);
|
||||
const point = index === 0 ? delta : previous + delta;
|
||||
if (point < 0) {
|
||||
return fail(
|
||||
"INVALID_PENPA",
|
||||
"Penpa+ centerlist contains a negative point.",
|
||||
);
|
||||
}
|
||||
result.push(point);
|
||||
previous = point;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function restoreSubstitutions(value: string): string {
|
||||
let restored = value;
|
||||
for (let index = PENPA_SUBSTITUTIONS.length - 1; index >= 0; index -= 1) {
|
||||
const substitution = PENPA_SUBSTITUTIONS[index];
|
||||
if (substitution === undefined) continue;
|
||||
restored = restored.split(substitution[1]).join(substitution[0]);
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
|
||||
function decodeNumber(value: unknown, size: number): number | undefined {
|
||||
if (!Array.isArray(value) || value.length < 3 || value.length > 8) {
|
||||
return undefined;
|
||||
}
|
||||
const rawDigit = value[0];
|
||||
const type = String(value[2]);
|
||||
if (["1", "2", "4", "10"].includes(type)) {
|
||||
const parsed =
|
||||
typeof rawDigit === "string" && /^\d+$/u.test(rawDigit)
|
||||
? Number(rawDigit)
|
||||
: rawDigit;
|
||||
return Number.isInteger(parsed) && (parsed as number) >= 1 && parsed <= size
|
||||
? (parsed as number)
|
||||
: undefined;
|
||||
}
|
||||
if (type === "7" && Array.isArray(rawDigit) && rawDigit.length === size) {
|
||||
const enabled = rawDigit
|
||||
.map((flag, index) => (flag === 1 ? index + 1 : 0))
|
||||
.filter(Boolean);
|
||||
return enabled.length === 1 ? enabled[0] : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function plainMetadata(value: string | undefined, prefix: string) {
|
||||
if (value === undefined) return undefined;
|
||||
return value
|
||||
.replaceAll("%2C", ",")
|
||||
.replace(new RegExp(`^${prefix}:\\s*`, "iu"), "")
|
||||
.slice(0, 256);
|
||||
}
|
||||
|
||||
function rulesMetadata(value: string | undefined) {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
return value
|
||||
.replaceAll("%2C", ",")
|
||||
.replaceAll("%2D", "\n")
|
||||
.replaceAll("%2E", "&")
|
||||
.replaceAll("%2F", "=")
|
||||
.slice(0, 16_384);
|
||||
}
|
||||
|
||||
function layer(value: unknown, label: string): JsonRecord {
|
||||
if (!isRecord(value) || Object.keys(value).length > 100) {
|
||||
return fail("INVALID_PENPA", `${label} must be a bounded object.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateLayerFields(value: JsonRecord, label: string): void {
|
||||
const unsupported = Object.keys(value)
|
||||
.filter((field) => !LAYER_FIELDS.has(field))
|
||||
.map((field) => `${label} field “${field}”`);
|
||||
if (unsupported.length > 0) {
|
||||
throw new UnsupportedPuzzleConstructsError("Penpa+", unsupported);
|
||||
}
|
||||
}
|
||||
|
||||
function parseLineConstraints(
|
||||
value: unknown,
|
||||
field: "thermo" | "arrows",
|
||||
pointToCell: ReadonlyMap<number, number>,
|
||||
): PortableConstraint[] {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || value.length > 5_000) {
|
||||
return fail("LIMIT_EXCEEDED", `Penpa+ ${field} data is too large.`);
|
||||
}
|
||||
return value.map((raw, index) => {
|
||||
if (
|
||||
!Array.isArray(raw) ||
|
||||
raw.length < 2 ||
|
||||
raw.length > pointToCell.size
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_PENPA",
|
||||
`Penpa+ ${field}[${String(index)}] is not a bounded cell path.`,
|
||||
);
|
||||
}
|
||||
const cells = raw.map((point, pointIndex) => {
|
||||
const parsed = integer(
|
||||
point,
|
||||
`${field}[${String(index)}][${String(pointIndex)}]`,
|
||||
0,
|
||||
1_000_000,
|
||||
);
|
||||
const cell = pointToCell.get(parsed);
|
||||
if (cell === undefined) {
|
||||
return fail(
|
||||
"UNSUPPORTED_PENPA_GEOMETRY",
|
||||
`Penpa+ ${field} uses a point outside the rectangular Sudoku grid.`,
|
||||
);
|
||||
}
|
||||
return cell;
|
||||
});
|
||||
if (new Set(cells).size !== cells.length) {
|
||||
return fail("INVALID_PENPA", `Penpa+ ${field} repeats a path cell.`);
|
||||
}
|
||||
return field === "thermo"
|
||||
? ({ type: "thermo", cells } as const)
|
||||
: ({ type: "arrow", bulb: [cells[0]!], line: cells.slice(1) } as const);
|
||||
});
|
||||
}
|
||||
|
||||
/** Parse the locally decompressed text stored in a Penpa+ long URL. */
|
||||
export function parsePenpaText(compressedText: string): SudokuDocument {
|
||||
if (
|
||||
new TextEncoder().encode(compressedText).byteLength > MAX_DOCUMENT_BYTES
|
||||
) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decompressed Penpa+ puzzle is too large to open safely.",
|
||||
);
|
||||
}
|
||||
const text = restoreSubstitutions(compressedText);
|
||||
const rows = text.split("\n");
|
||||
if (rows.length < 7 || rows.length > 32) {
|
||||
return fail("INVALID_PENPA", "The Penpa+ puzzle record is incomplete.");
|
||||
}
|
||||
const header = rows[0]?.split(",") ?? [];
|
||||
if (header.length < 15) {
|
||||
return fail("INVALID_PENPA", "The Penpa+ puzzle header is incomplete.");
|
||||
}
|
||||
const gridType = header[0]?.toLowerCase();
|
||||
if (gridType !== "square" && gridType !== "sudoku") {
|
||||
throw new UnsupportedPuzzleConstructsError("Penpa+", [
|
||||
`grid type “${gridType ?? "unknown"}”`,
|
||||
]);
|
||||
}
|
||||
const nx = integer(header[1], "Penpa+ width", 1, 100);
|
||||
const ny = integer(header[2], "Penpa+ height", 1, 100);
|
||||
const rotation = integer(header[4] ?? 0, "Penpa+ rotation", -360, 360);
|
||||
const reflectX = integer(header[5] ?? 1, "Penpa+ reflection", -1, 1);
|
||||
const reflectY = integer(header[6] ?? 1, "Penpa+ reflection", -1, 1);
|
||||
if (rotation !== 0 || reflectX !== 1 || reflectY !== 1) {
|
||||
throw new UnsupportedPuzzleConstructsError("Penpa+", [
|
||||
"rotated or reflected grids",
|
||||
]);
|
||||
}
|
||||
boundedJson(rows[1] ?? "", "Penpa+ spacing data");
|
||||
const problem = layer(
|
||||
boundedJson(rows[3] ?? "", "Penpa+ problem layer"),
|
||||
"Penpa+ problem layer",
|
||||
);
|
||||
const progress = layer(
|
||||
boundedJson(rows[4] ?? "{}", "Penpa+ answer layer"),
|
||||
"Penpa+ answer layer",
|
||||
);
|
||||
validateLayerFields(problem, "problem layer");
|
||||
validateLayerFields(progress, "answer layer");
|
||||
|
||||
const centerList = expandCenterList(
|
||||
boundedJson(rows[5] ?? "", "Penpa+ centerlist"),
|
||||
);
|
||||
if (centerList.length === 0) {
|
||||
return fail("INVALID_PENPA", "Penpa+ contains no active grid cells.");
|
||||
}
|
||||
const nx0 = nx + 4;
|
||||
const positions = centerList.map((point) => ({
|
||||
point,
|
||||
row: Math.floor(point / nx0) - 2,
|
||||
column: (point % nx0) - 2,
|
||||
}));
|
||||
const minimumRow = Math.min(...positions.map(({ row }) => row));
|
||||
const maximumRow = Math.max(...positions.map(({ row }) => row));
|
||||
const minimumColumn = Math.min(...positions.map(({ column }) => column));
|
||||
const maximumColumn = Math.max(...positions.map(({ column }) => column));
|
||||
const height = maximumRow - minimumRow + 1;
|
||||
const width = maximumColumn - minimumColumn + 1;
|
||||
if (
|
||||
width !== height ||
|
||||
width < MIN_BOARD_SIZE ||
|
||||
width > MAX_BOARD_SIZE ||
|
||||
positions.length !== width * height ||
|
||||
width > nx ||
|
||||
height > ny
|
||||
) {
|
||||
throw new UnsupportedPuzzleConstructsError("Penpa+", [
|
||||
"masked, non-square or unsupported-size Sudoku grids",
|
||||
]);
|
||||
}
|
||||
const pointToCell = new Map<number, number>();
|
||||
for (const position of positions) {
|
||||
const row = position.row - minimumRow;
|
||||
const column = position.column - minimumColumn;
|
||||
const cell = row * width + column;
|
||||
if (
|
||||
pointToCell.has(position.point) ||
|
||||
[...pointToCell.values()].includes(cell)
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_PENPA",
|
||||
"Penpa+ centerlist contains duplicate cells.",
|
||||
);
|
||||
}
|
||||
pointToCell.set(position.point, cell);
|
||||
}
|
||||
|
||||
const unsupported: string[] = [];
|
||||
const supportedFields = new Set(["number", "thermo", "arrows"]);
|
||||
for (const [field, raw] of Object.entries(problem)) {
|
||||
if (
|
||||
!supportedFields.has(field) &&
|
||||
!field.startsWith("command_") &&
|
||||
hasContent(raw)
|
||||
) {
|
||||
unsupported.push(`problem ${field}`);
|
||||
}
|
||||
}
|
||||
for (const [field, raw] of Object.entries(progress)) {
|
||||
if (
|
||||
field !== "number" &&
|
||||
!field.startsWith("command_") &&
|
||||
hasContent(raw)
|
||||
) {
|
||||
unsupported.push(`answer ${field}`);
|
||||
}
|
||||
}
|
||||
if (unsupported.length > 0) {
|
||||
throw new UnsupportedPuzzleConstructsError("Penpa+", unsupported);
|
||||
}
|
||||
|
||||
const givens = Array<number>(width * width).fill(0);
|
||||
const values = Array<number>(width * width).fill(0);
|
||||
const parseNumbers = (
|
||||
raw: unknown,
|
||||
target: number[],
|
||||
requireGivenStyle: boolean,
|
||||
label: string,
|
||||
) => {
|
||||
if (raw === undefined) return;
|
||||
if (!isRecord(raw) || Object.keys(raw).length > width * width * 4) {
|
||||
return fail("LIMIT_EXCEEDED", `Penpa+ ${label} numbers are too large.`);
|
||||
}
|
||||
const unsupportedNumbers: string[] = [];
|
||||
for (const [pointText, entry] of Object.entries(raw)) {
|
||||
const point = integer(pointText, `${label} point`, 0, 1_000_000);
|
||||
const cell = pointToCell.get(point);
|
||||
const digit = decodeNumber(entry, width);
|
||||
const style = Array.isArray(entry) ? entry[1] : undefined;
|
||||
if (
|
||||
cell === undefined ||
|
||||
digit === undefined ||
|
||||
(requireGivenStyle && style !== 1)
|
||||
) {
|
||||
unsupportedNumbers.push(
|
||||
`${label} number or style at point ${pointText}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
target[cell] = digit;
|
||||
}
|
||||
if (unsupportedNumbers.length > 0) {
|
||||
throw new UnsupportedPuzzleConstructsError("Penpa+", unsupportedNumbers);
|
||||
}
|
||||
};
|
||||
parseNumbers(problem.number, givens, true, "problem");
|
||||
values.splice(0, values.length, ...givens);
|
||||
parseNumbers(progress.number, values, false, "answer");
|
||||
for (const [cell, given] of givens.entries()) {
|
||||
if (given !== 0) values[cell] = given;
|
||||
}
|
||||
|
||||
const constraints = [
|
||||
...parseLineConstraints(problem.thermo, "thermo", pointToCell),
|
||||
...parseLineConstraints(problem.arrows, "arrows", pointToCell),
|
||||
];
|
||||
const title = plainMetadata(header[15], "Title");
|
||||
const author = plainMetadata(header[16], "Author");
|
||||
const rules = rulesMetadata(header[18]);
|
||||
return {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: SUDOKU_DOCUMENT_VERSION,
|
||||
size: width,
|
||||
givens,
|
||||
values,
|
||||
constraints,
|
||||
...(title === undefined || title === "" ? {} : { title }),
|
||||
...(author === undefined || author === "" ? {} : { author }),
|
||||
...(rules === undefined ? {} : { rules: [rules] }),
|
||||
};
|
||||
}
|
||||
|
||||
function extractPenpaPayload(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed.startsWith("penpa:")) return trimmed.slice("penpa:".length);
|
||||
let params: URLSearchParams;
|
||||
if (/^https?:\/\//iu.test(trimmed)) {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed);
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError("INVALID_PENPA_URL", "Invalid Penpa+ URL.", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (!url.pathname.includes("/penpa-edit/")) {
|
||||
throw new RemotePuzzleReferenceError("The pasted URL");
|
||||
}
|
||||
params = new URLSearchParams(
|
||||
url.hash.length > 1 ? url.hash.slice(1) : url.search.slice(1),
|
||||
);
|
||||
} else {
|
||||
params = new URLSearchParams(trimmed.replace(/^[?#]/u, ""));
|
||||
}
|
||||
const payload = params.get("p");
|
||||
if (payload === null || payload.startsWith("http")) {
|
||||
throw new RemotePuzzleReferenceError("The Penpa+ link");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function base64Bytes(payload: string): Uint8Array {
|
||||
const normalized = payload.replaceAll(" ", "+");
|
||||
if (
|
||||
normalized.length === 0 ||
|
||||
normalized.length > MAX_PENPA_PAYLOAD_LENGTH ||
|
||||
!/^[A-Za-z0-9+/]*={0,2}$/u.test(normalized)
|
||||
) {
|
||||
return fail("INVALID_PENPA", "The Penpa+ payload is invalid or too large.");
|
||||
}
|
||||
let binary: string;
|
||||
try {
|
||||
binary = atob(normalized);
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_PENPA",
|
||||
"The Penpa+ payload is not valid base64.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function inflateRaw(bytes: Uint8Array): Promise<string> {
|
||||
if (typeof DecompressionStream === "undefined") {
|
||||
return fail(
|
||||
"UNSUPPORTED_BROWSER",
|
||||
"This browser cannot decompress Penpa+ data locally.",
|
||||
);
|
||||
}
|
||||
let stream: ReadableStream<Uint8Array>;
|
||||
try {
|
||||
const input = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(input).set(bytes);
|
||||
const body = new Response(input).body;
|
||||
if (body === null) {
|
||||
return fail("INVALID_PENPA", "The Penpa+ payload stream is empty.");
|
||||
}
|
||||
stream = body.pipeThrough(new DecompressionStream("deflate-raw"));
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_PENPA",
|
||||
"The Penpa+ raw-deflate stream could not be opened.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const reader = stream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
total += chunk.value.byteLength;
|
||||
if (total > MAX_DOCUMENT_BYTES) {
|
||||
await reader.cancel();
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decompressed Penpa+ puzzle is too large to open safely.",
|
||||
);
|
||||
}
|
||||
chunks.push(chunk.value);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_PENPA",
|
||||
"The Penpa+ payload could not be decompressed.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const output = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
output.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(output);
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_PENPA",
|
||||
"The decompressed Penpa+ payload is not valid UTF-8 text.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Import a self-contained Penpa+ long URL without making a network request. */
|
||||
export async function importPenpa(input: string): Promise<SudokuDocument> {
|
||||
return parsePenpaText(
|
||||
await inflateRaw(base64Bytes(extractPenpaPayload(input))),
|
||||
);
|
||||
}
|
||||
+20
-10
@@ -1,7 +1,8 @@
|
||||
import { compressToEncodedURIComponent } from "lz-string";
|
||||
import {
|
||||
compressToEncodedURIComponent,
|
||||
decompressFromEncodedURIComponent,
|
||||
} from "lz-string";
|
||||
LzStringOutputLimitError,
|
||||
decompressFromEncodedURIComponentBounded,
|
||||
} from "./boundedLz";
|
||||
import {
|
||||
MAX_DOCUMENT_BYTES,
|
||||
SudokuFormatError,
|
||||
@@ -49,18 +50,27 @@ export function decodePuzzleHash(hashOrUrl: string): SudokuDocument {
|
||||
);
|
||||
}
|
||||
const compressed = hash.slice(PUZZLE_HASH_PREFIX.length);
|
||||
const json = decompressFromEncodedURIComponent(compressed);
|
||||
let json: string | null;
|
||||
try {
|
||||
json = decompressFromEncodedURIComponentBounded(
|
||||
compressed,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof LzStringOutputLimitError) {
|
||||
throw new SudokuFormatError(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decompressed puzzle is too large to open safely.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (json === null || json === "") {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_SHARE",
|
||||
"The share payload could not be decompressed.",
|
||||
);
|
||||
}
|
||||
if (new TextEncoder().encode(json).byteLength > MAX_DOCUMENT_BYTES) {
|
||||
throw new SudokuFormatError(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decompressed puzzle is too large to open safely.",
|
||||
);
|
||||
}
|
||||
return parseSudokuDocument(json);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
import {
|
||||
LzStringOutputLimitError,
|
||||
decompressFromBase64OrUriComponentBounded,
|
||||
} from "./boundedLz";
|
||||
import {
|
||||
MAX_BOARD_SIZE,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
MIN_BOARD_SIZE,
|
||||
SudokuFormatError,
|
||||
} from "./document";
|
||||
import { UnsupportedPuzzleConstructsError } from "./interoperability";
|
||||
import {
|
||||
SUDOKU_DOCUMENT_SCHEMA,
|
||||
SUDOKU_DOCUMENT_VERSION,
|
||||
type PortableConstraint,
|
||||
type SudokuDocument,
|
||||
} from "./types";
|
||||
|
||||
export const MAX_SUDOKUPAD_PAYLOAD_LENGTH = 262_144;
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const ROOT_FIELDS = new Set([
|
||||
"id",
|
||||
"cellSize",
|
||||
"cells",
|
||||
"settings",
|
||||
"metadata",
|
||||
"metaData",
|
||||
"global",
|
||||
"regions",
|
||||
"lines",
|
||||
"underlays",
|
||||
"overlays",
|
||||
"arrows",
|
||||
"cages",
|
||||
"title",
|
||||
"author",
|
||||
"rules",
|
||||
"solution",
|
||||
]);
|
||||
|
||||
const CELL_FIELDS = new Set(["value", "given", "pencilMarks", "centremarks"]);
|
||||
|
||||
function fail(code: string, message: string): never {
|
||||
throw new SudokuFormatError(code, message);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function present(value: unknown): boolean {
|
||||
if (value === undefined || value === null || value === false) return false;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
if (isRecord(value)) return Object.keys(value).length > 0;
|
||||
if (typeof value === "string") return value.trim().length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, label: string, maximum = 16_384) {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
if (typeof value !== "string" || value.length > maximum) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
`${label} must be text of at most ${maximum} characters.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(
|
||||
value: unknown,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const parsed =
|
||||
typeof value === "string" && value.trim() !== "" ? Number(value) : value;
|
||||
if (
|
||||
!Number.isInteger(parsed) ||
|
||||
(parsed as number) < minimum ||
|
||||
(parsed as number) > maximum
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
`${label} must be an integer from ${minimum} to ${maximum}.`,
|
||||
);
|
||||
}
|
||||
return parsed as number;
|
||||
}
|
||||
|
||||
function coordinate(value: unknown, size: number, label: string): number {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length !== 2 ||
|
||||
!Number.isInteger(value[0]) ||
|
||||
!Number.isInteger(value[1])
|
||||
) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
`${label} must be a zero-based [row, column] cell.`,
|
||||
);
|
||||
}
|
||||
const row = integer(value[0], `${label}[0]`, 0, size - 1);
|
||||
const column = integer(value[1], `${label}[1]`, 0, size - 1);
|
||||
return row * size + column;
|
||||
}
|
||||
|
||||
function digitList(value: unknown, size: number, label: string): number[] {
|
||||
if (!Array.isArray(value) || value.length > size) {
|
||||
return fail("INVALID_SUDOKUPAD", `${label} must be a bounded digit array.`);
|
||||
}
|
||||
return [
|
||||
...new Set(
|
||||
value.map((digit, index) =>
|
||||
integer(digit, `${label}[${String(index)}]`, 1, size),
|
||||
),
|
||||
),
|
||||
].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function metadataFrom(value: JsonRecord): JsonRecord {
|
||||
const metadata = value.metadata ?? value.metaData;
|
||||
if (metadata === undefined) return {};
|
||||
if (!isRecord(metadata)) {
|
||||
return fail("INVALID_SUDOKUPAD", "SudokuPad metadata must be an object.");
|
||||
}
|
||||
if (Object.keys(metadata).length > 100) {
|
||||
return fail("LIMIT_EXCEEDED", "SudokuPad metadata has too many fields.");
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function metaText(
|
||||
root: JsonRecord,
|
||||
metadata: JsonRecord,
|
||||
key: "title" | "author" | "rules" | "solution",
|
||||
): string | undefined {
|
||||
const value = root[key] ?? metadata[key];
|
||||
if (Array.isArray(value)) {
|
||||
if (!value.every((entry) => typeof entry === "string")) {
|
||||
return fail("INVALID_SUDOKUPAD", `${key} metadata must be text.`);
|
||||
}
|
||||
return value.join(key === "rules" ? "\n" : " ").slice(0, 16_384);
|
||||
}
|
||||
return boundedText(value, `SudokuPad ${key}`);
|
||||
}
|
||||
|
||||
function solutionDigits(value: string | undefined, size: number) {
|
||||
if (value === undefined) return undefined;
|
||||
const tokens = value.includes(",") ? value.split(",") : [...value];
|
||||
if (tokens.length !== size * size) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
`SudokuPad solution must contain exactly ${size * size} digits.`,
|
||||
);
|
||||
}
|
||||
return tokens.map((digit, index) =>
|
||||
integer(digit, `solution[${String(index)}]`, 1, size),
|
||||
);
|
||||
}
|
||||
|
||||
function parseRegions(value: unknown, size: number): number[] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Array.isArray(value) || value.length !== size) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
`SudokuPad regions must contain exactly ${size} regions.`,
|
||||
);
|
||||
}
|
||||
const regions = Array<number>(size * size).fill(-1);
|
||||
value.forEach((rawRegion, regionIndex) => {
|
||||
if (!Array.isArray(rawRegion) || rawRegion.length !== size) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
`SudokuPad region ${String(regionIndex + 1)} must contain ${size} cells.`,
|
||||
);
|
||||
}
|
||||
rawRegion.forEach((cell, cellIndex) => {
|
||||
const index = coordinate(
|
||||
cell,
|
||||
size,
|
||||
`regions[${String(regionIndex)}][${String(cellIndex)}]`,
|
||||
);
|
||||
if (regions[index] !== -1) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
"SudokuPad regions contain a duplicate cell.",
|
||||
);
|
||||
}
|
||||
regions[index] = regionIndex;
|
||||
});
|
||||
});
|
||||
if (regions.some((region) => region === -1)) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
"SudokuPad regions do not cover every grid cell.",
|
||||
);
|
||||
}
|
||||
return regions;
|
||||
}
|
||||
|
||||
function parseCages(
|
||||
value: unknown,
|
||||
size: number,
|
||||
metadata: JsonRecord,
|
||||
): PortableConstraint[] {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || value.length > 5_000) {
|
||||
return fail("LIMIT_EXCEEDED", "SudokuPad cages must be a bounded array.");
|
||||
}
|
||||
const constraints: PortableConstraint[] = [];
|
||||
const unsupported: string[] = [];
|
||||
for (const [index, raw] of value.entries()) {
|
||||
if (!isRecord(raw)) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
`cages[${String(index)}] must be an object.`,
|
||||
);
|
||||
}
|
||||
const cells = raw.cells;
|
||||
if (!Array.isArray(cells) || cells.length === 0) {
|
||||
const rawValue = boundedText(raw.value, `cages[${String(index)}].value`);
|
||||
const match = /^([^: ]+):\s*([\s\S]+)$/u.exec(rawValue ?? "");
|
||||
if (match !== null && metadata[match[1]!] === undefined) {
|
||||
metadata[match[1]!] = match[2]!;
|
||||
} else if (rawValue !== undefined) {
|
||||
unsupported.push("cell-free cage annotations");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (cells.length > size * size) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"A SudokuPad cage contains too many cells.",
|
||||
);
|
||||
}
|
||||
const cageCells = cells.map((cell, cellIndex) =>
|
||||
coordinate(
|
||||
cell,
|
||||
size,
|
||||
`cages[${String(index)}].cells[${String(cellIndex)}]`,
|
||||
),
|
||||
);
|
||||
if (new Set(cageCells).size !== cageCells.length) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
`cages[${String(index)}] contains a duplicate cell.`,
|
||||
);
|
||||
}
|
||||
if (raw.hidden === true) {
|
||||
unsupported.push("hidden or masked cells");
|
||||
continue;
|
||||
}
|
||||
const sumValue = raw.sum ?? raw.value;
|
||||
if (sumValue === undefined || sumValue === "") {
|
||||
unsupported.push("cages without numeric sums");
|
||||
continue;
|
||||
}
|
||||
const sum = integer(sumValue, `cages[${String(index)}].sum`, 1, size ** 3);
|
||||
constraints.push({
|
||||
type: "killer-cage",
|
||||
cells: cageCells,
|
||||
sum,
|
||||
noRepeat: raw.unique !== false,
|
||||
});
|
||||
}
|
||||
if (unsupported.length > 0) {
|
||||
throw new UnsupportedPuzzleConstructsError("SudokuPad/CTC", unsupported);
|
||||
}
|
||||
return constraints;
|
||||
}
|
||||
|
||||
/** Parse raw, already-decoded SudokuPad/CTC (often called SCL) JSON. */
|
||||
export function parseSudokuPadPuzzle(value: unknown): SudokuDocument {
|
||||
if (!isRecord(value)) {
|
||||
return fail("INVALID_SUDOKUPAD", "The SudokuPad puzzle must be an object.");
|
||||
}
|
||||
const unsupported: string[] = [];
|
||||
for (const field of Object.keys(value)) {
|
||||
if (!ROOT_FIELDS.has(field))
|
||||
unsupported.push(`unknown root field “${field}”`);
|
||||
}
|
||||
for (const field of ["lines", "underlays", "overlays", "arrows"] as const) {
|
||||
if (present(value[field])) unsupported.push(`visual ${field}`);
|
||||
}
|
||||
if (present(value.global)) unsupported.push("custom global rules");
|
||||
if (isRecord(value.settings)) {
|
||||
for (const key of Object.keys(value.settings)) {
|
||||
if (key !== "conflictchecker") unsupported.push(`setting “${key}”`);
|
||||
}
|
||||
} else if (value.settings !== undefined) {
|
||||
return fail("INVALID_SUDOKUPAD", "SudokuPad settings must be an object.");
|
||||
}
|
||||
if (unsupported.length > 0) {
|
||||
throw new UnsupportedPuzzleConstructsError("SudokuPad/CTC", unsupported);
|
||||
}
|
||||
|
||||
if (!Array.isArray(value.cells)) {
|
||||
return fail("INVALID_SUDOKUPAD", "SudokuPad cells must be a square array.");
|
||||
}
|
||||
const size = integer(
|
||||
value.cells.length,
|
||||
"SudokuPad grid size",
|
||||
MIN_BOARD_SIZE,
|
||||
MAX_BOARD_SIZE,
|
||||
);
|
||||
if (!value.cells.every((row) => Array.isArray(row) && row.length === size)) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
"SudokuPad cells must form a square grid.",
|
||||
);
|
||||
}
|
||||
|
||||
const givens: number[] = [];
|
||||
const values: number[] = [];
|
||||
const cornerMarks: number[][] = [];
|
||||
const centerMarks: number[][] = [];
|
||||
for (const [rowIndex, rawRow] of value.cells.entries()) {
|
||||
for (const [columnIndex, rawCell] of (rawRow as unknown[]).entries()) {
|
||||
if (!isRecord(rawCell)) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
`cells[${String(rowIndex)}][${String(columnIndex)}] must be an object.`,
|
||||
);
|
||||
}
|
||||
const unknown = Object.keys(rawCell).filter(
|
||||
(field) => !CELL_FIELDS.has(field),
|
||||
);
|
||||
if (unknown.length > 0) {
|
||||
throw new UnsupportedPuzzleConstructsError(
|
||||
"SudokuPad/CTC",
|
||||
unknown.map((field) => `cell field “${field}”`),
|
||||
);
|
||||
}
|
||||
const digit =
|
||||
rawCell.value === undefined || rawCell.value === ""
|
||||
? 0
|
||||
: integer(
|
||||
rawCell.value,
|
||||
`cells[${String(rowIndex)}][${String(columnIndex)}].value`,
|
||||
1,
|
||||
size,
|
||||
);
|
||||
const isGiven = digit !== 0 && rawCell.given !== false;
|
||||
givens.push(isGiven ? digit : 0);
|
||||
values.push(digit);
|
||||
cornerMarks.push(
|
||||
digitList(
|
||||
rawCell.pencilMarks ?? [],
|
||||
size,
|
||||
`cells[${String(rowIndex)}][${String(columnIndex)}].pencilMarks`,
|
||||
),
|
||||
);
|
||||
centerMarks.push(
|
||||
digitList(
|
||||
rawCell.centremarks ?? [],
|
||||
size,
|
||||
`cells[${String(rowIndex)}][${String(columnIndex)}].centremarks`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = metadataFrom(value);
|
||||
const constraints = parseCages(value.cages, size, metadata);
|
||||
if (metadata.antiknight === true || metadata.antiknight === "true") {
|
||||
constraints.push({ type: "anti-knight" });
|
||||
}
|
||||
if (metadata.antiking === true || metadata.antiking === "true") {
|
||||
constraints.push({ type: "anti-king" });
|
||||
}
|
||||
if (metadata.nonconsecutive === true || metadata.nonconsecutive === "true") {
|
||||
constraints.push({ type: "non-consecutive" });
|
||||
}
|
||||
const knownMetadata = new Set([
|
||||
"title",
|
||||
"author",
|
||||
"rules",
|
||||
"solution",
|
||||
"antiknight",
|
||||
"antiking",
|
||||
"nonconsecutive",
|
||||
]);
|
||||
const unknownMetadata = Object.keys(metadata).filter(
|
||||
(key) => !knownMetadata.has(key),
|
||||
);
|
||||
if (unknownMetadata.length > 0) {
|
||||
throw new UnsupportedPuzzleConstructsError(
|
||||
"SudokuPad/CTC",
|
||||
unknownMetadata.map((key) => `metadata “${key}”`),
|
||||
);
|
||||
}
|
||||
|
||||
const title = metaText(value, metadata, "title");
|
||||
const author = metaText(value, metadata, "author");
|
||||
const rules = metaText(value, metadata, "rules");
|
||||
const solution = solutionDigits(metaText(value, metadata, "solution"), size);
|
||||
const regions = parseRegions(value.regions, size);
|
||||
const id = boundedText(value.id, "SudokuPad id", 256);
|
||||
return {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: SUDOKU_DOCUMENT_VERSION,
|
||||
size,
|
||||
givens,
|
||||
values,
|
||||
cornerMarks,
|
||||
centerMarks,
|
||||
constraints,
|
||||
...(regions === undefined ? {} : { regions }),
|
||||
...(title === undefined ? {} : { title: title.slice(0, 256) }),
|
||||
...(author === undefined ? {} : { author: author.slice(0, 256) }),
|
||||
...(rules === undefined ? {} : { rules: [rules.slice(0, 16_384)] }),
|
||||
...(solution === undefined ? {} : { solution }),
|
||||
...(id === undefined ? {} : { id }),
|
||||
};
|
||||
}
|
||||
|
||||
function boundedJson(input: string): unknown {
|
||||
if (new TextEncoder().encode(input).byteLength > MAX_DOCUMENT_BYTES) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decoded SudokuPad puzzle is too large to open safely.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(input) as unknown;
|
||||
} catch (error) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_SUDOKUPAD",
|
||||
"The SudokuPad payload does not contain valid JSON.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function decodePayload(payload: string): string {
|
||||
if (payload.length === 0 || payload.length > MAX_SUDOKUPAD_PAYLOAD_LENGTH) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The inline SudokuPad payload is empty or too large.",
|
||||
);
|
||||
}
|
||||
let decoded = payload;
|
||||
try {
|
||||
decoded = decodeURIComponent(payload);
|
||||
} catch {
|
||||
// A malformed literal percent will fail decompression below.
|
||||
}
|
||||
let text: string | null;
|
||||
try {
|
||||
text = decompressFromBase64OrUriComponentBounded(
|
||||
decoded,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof LzStringOutputLimitError) {
|
||||
return fail(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The decoded SudokuPad puzzle is too large to open safely.",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (text === null || text === "") {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
"The inline SudokuPad payload could not be decompressed.",
|
||||
);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Import raw SCL/CTC JSON or a self-contained `ctc…`/`scl…` payload. */
|
||||
export function importSudokuPad(input: string): SudokuDocument {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed.startsWith("{")) {
|
||||
return parseSudokuPadPuzzle(boundedJson(trimmed));
|
||||
}
|
||||
const payload = trimmed.replace(/^(?:ctc|scl)/iu, "");
|
||||
if (payload === trimmed) {
|
||||
return fail(
|
||||
"INVALID_SUDOKUPAD",
|
||||
"Expected raw SudokuPad JSON or a self-contained ctc/scl payload.",
|
||||
);
|
||||
}
|
||||
return parseSudokuPadPuzzle(boundedJson(decodePayload(payload)));
|
||||
}
|
||||
+60
-13
@@ -1,45 +1,84 @@
|
||||
import type { PortableAidMemoire } from "../state/aidMemoire";
|
||||
|
||||
export type {
|
||||
PortableAidMemoire,
|
||||
PortableAidMemoireCell,
|
||||
} from "../state/aidMemoire";
|
||||
|
||||
export const SUDOKU_DOCUMENT_SCHEMA =
|
||||
"de.add-ideas.sudoku-tools.puzzle" as const;
|
||||
export const SUDOKU_DOCUMENT_VERSION = 1 as const;
|
||||
|
||||
export type CellIndex = number;
|
||||
export type OutsideSide = "top" | "right" | "bottom" | "left";
|
||||
export interface CluePolarity {
|
||||
/** When true, the completed clue must be false rather than true. */
|
||||
readonly negated?: boolean;
|
||||
}
|
||||
|
||||
export type PortableConstraint =
|
||||
| { readonly type: "diagonal"; readonly direction: "main" | "anti" }
|
||||
| { readonly type: "anti-knight" }
|
||||
| { readonly type: "anti-king" }
|
||||
| { readonly type: "non-consecutive" }
|
||||
| {
|
||||
| ({
|
||||
readonly type: "killer-cage";
|
||||
readonly cells: readonly CellIndex[];
|
||||
readonly sum: number;
|
||||
readonly noRepeat?: boolean;
|
||||
}
|
||||
| { readonly type: "thermo"; readonly cells: readonly CellIndex[] }
|
||||
| {
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "thermo";
|
||||
readonly cells: readonly CellIndex[];
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "arrow";
|
||||
readonly bulb: readonly CellIndex[];
|
||||
readonly line: readonly CellIndex[];
|
||||
}
|
||||
| {
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "kropki";
|
||||
readonly a: CellIndex;
|
||||
readonly b: CellIndex;
|
||||
readonly kind: "white" | "black";
|
||||
}
|
||||
| {
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "xv";
|
||||
readonly a: CellIndex;
|
||||
readonly b: CellIndex;
|
||||
readonly total: 5 | 10;
|
||||
}
|
||||
| {
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "inequality";
|
||||
readonly lesser: CellIndex;
|
||||
readonly greater: CellIndex;
|
||||
}
|
||||
| { readonly type: "renban"; readonly cells: readonly CellIndex[] }
|
||||
| { readonly type: "palindrome"; readonly cells: readonly CellIndex[] };
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "x-sum";
|
||||
readonly side: OutsideSide;
|
||||
readonly index: number;
|
||||
readonly sum: number;
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "skyscraper";
|
||||
readonly side: OutsideSide;
|
||||
readonly index: number;
|
||||
readonly count: number;
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "quadruple";
|
||||
readonly cells: readonly CellIndex[];
|
||||
readonly digits: readonly number[];
|
||||
} & CluePolarity)
|
||||
| ({ readonly type: "maximum"; readonly cell: CellIndex } & CluePolarity)
|
||||
| ({
|
||||
readonly type: "renban";
|
||||
readonly cells: readonly CellIndex[];
|
||||
} & CluePolarity)
|
||||
| ({
|
||||
readonly type: "palindrome";
|
||||
readonly cells: readonly CellIndex[];
|
||||
} & CluePolarity);
|
||||
|
||||
/**
|
||||
* Stable, versioned interchange format owned by Sudoku Tools. Cell indices are
|
||||
@@ -57,6 +96,8 @@ export interface SudokuDocument {
|
||||
readonly candidates?: readonly (readonly number[])[];
|
||||
readonly colors?: readonly number[];
|
||||
readonly elapsedMs?: number;
|
||||
/** Optional, non-constraining scratch cells used while solving. */
|
||||
readonly aidMemoire?: PortableAidMemoire;
|
||||
readonly solution?: readonly number[];
|
||||
readonly regions?: readonly number[];
|
||||
readonly constraints: readonly PortableConstraint[];
|
||||
@@ -136,6 +177,12 @@ export function cloneConstraint(
|
||||
case "renban":
|
||||
case "palindrome":
|
||||
return { ...constraint, cells: [...constraint.cells] };
|
||||
case "quadruple":
|
||||
return {
|
||||
...constraint,
|
||||
cells: [...constraint.cells],
|
||||
digits: [...constraint.digits],
|
||||
};
|
||||
case "arrow":
|
||||
return {
|
||||
...constraint,
|
||||
|
||||
@@ -0,0 +1,669 @@
|
||||
import { normalizePuzzle } from "../domain/validation";
|
||||
import type {
|
||||
NormalizedPuzzle,
|
||||
OutsideClueSide,
|
||||
VariantConstraint,
|
||||
} from "../domain/types";
|
||||
import { symbolFor } from "../state/session";
|
||||
import { normalizeSudokuDocument, SudokuFormatError } from "./document";
|
||||
import { toDomainPuzzle, type SudokuDocument } from "./types";
|
||||
|
||||
export interface VisualExportOptions {
|
||||
readonly includeProgress?: boolean;
|
||||
readonly includeNotes?: boolean;
|
||||
readonly rasterScale?: number;
|
||||
}
|
||||
|
||||
export const MAX_VISUAL_EXPORT_DIMENSION = 4_096;
|
||||
export const MAX_VISUAL_EXPORT_BYTES = 5_242_880;
|
||||
|
||||
const CELL_SIZE = 72;
|
||||
const OUTSIDE_MARGIN = 58;
|
||||
const BOARD_MARGIN = 20;
|
||||
const HEADER_HEIGHT = 74;
|
||||
|
||||
function escapeXml(value: unknown): string {
|
||||
const unicode = new TextDecoder().decode(
|
||||
new TextEncoder().encode(String(value)),
|
||||
);
|
||||
const xmlCharacters = [...unicode]
|
||||
.map((character) => {
|
||||
const codePoint = character.codePointAt(0)!;
|
||||
return codePoint === 0x09 ||
|
||||
codePoint === 0x0a ||
|
||||
codePoint === 0x0d ||
|
||||
(codePoint >= 0x20 && codePoint <= 0xd7ff) ||
|
||||
(codePoint >= 0xe000 && codePoint <= 0xfffd) ||
|
||||
(codePoint >= 0x1_0000 && codePoint <= 0x10_ffff)
|
||||
? character
|
||||
: "�";
|
||||
})
|
||||
.join("");
|
||||
return xmlCharacters
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function number(value: number): string {
|
||||
return Number(value.toFixed(3)).toString();
|
||||
}
|
||||
|
||||
interface BoardOrigin {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
}
|
||||
|
||||
function point(size: number, cell: number, origin: BoardOrigin) {
|
||||
return {
|
||||
x: origin.x + ((cell % size) + 0.5) * CELL_SIZE,
|
||||
y: origin.y + (Math.floor(cell / size) + 0.5) * CELL_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
function linePoints(
|
||||
size: number,
|
||||
cells: readonly number[],
|
||||
origin: BoardOrigin,
|
||||
) {
|
||||
return cells
|
||||
.map((cell) => {
|
||||
const position = point(size, cell, origin);
|
||||
return `${number(position.x)},${number(position.y)}`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function pathBoundary(
|
||||
size: number,
|
||||
cells: ReadonlySet<number>,
|
||||
origin: BoardOrigin,
|
||||
inset: number,
|
||||
): string {
|
||||
const commands: string[] = [];
|
||||
for (const cell of cells) {
|
||||
const row = Math.floor(cell / size);
|
||||
const column = cell % size;
|
||||
const x0 = origin.x + column * CELL_SIZE + inset;
|
||||
const x1 = origin.x + (column + 1) * CELL_SIZE - inset;
|
||||
const y0 = origin.y + row * CELL_SIZE + inset;
|
||||
const y1 = origin.y + (row + 1) * CELL_SIZE - inset;
|
||||
if (row === 0 || !cells.has(cell - size))
|
||||
commands.push(`M${number(x0)} ${number(y0)}H${number(x1)}`);
|
||||
if (row === size - 1 || !cells.has(cell + size))
|
||||
commands.push(`M${number(x0)} ${number(y1)}H${number(x1)}`);
|
||||
if (column === 0 || !cells.has(cell - 1))
|
||||
commands.push(`M${number(x0)} ${number(y0)}V${number(y1)}`);
|
||||
if (column === size - 1 || !cells.has(cell + 1))
|
||||
commands.push(`M${number(x1)} ${number(y0)}V${number(y1)}`);
|
||||
}
|
||||
return commands.join(" ");
|
||||
}
|
||||
|
||||
function outsidePoint(
|
||||
size: number,
|
||||
side: OutsideClueSide,
|
||||
index: number,
|
||||
origin: BoardOrigin,
|
||||
) {
|
||||
const board = size * CELL_SIZE;
|
||||
switch (side) {
|
||||
case "top":
|
||||
return {
|
||||
x: origin.x + (index + 0.5) * CELL_SIZE,
|
||||
y: origin.y - OUTSIDE_MARGIN * 0.52,
|
||||
};
|
||||
case "right":
|
||||
return {
|
||||
x: origin.x + board + OUTSIDE_MARGIN * 0.52,
|
||||
y: origin.y + (index + 0.5) * CELL_SIZE,
|
||||
};
|
||||
case "bottom":
|
||||
return {
|
||||
x: origin.x + (index + 0.5) * CELL_SIZE,
|
||||
y: origin.y + board + OUTSIDE_MARGIN * 0.52,
|
||||
};
|
||||
case "left":
|
||||
return {
|
||||
x: origin.x - OUTSIDE_MARGIN * 0.52,
|
||||
y: origin.y + (index + 0.5) * CELL_SIZE,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function globalRuleLabels(puzzle: NormalizedPuzzle): string[] {
|
||||
const labels: string[] = [];
|
||||
if (puzzle.constraints.some(({ type }) => type === "anti-knight"))
|
||||
labels.push("Anti-knight");
|
||||
if (puzzle.constraints.some(({ type }) => type === "anti-king"))
|
||||
labels.push("Anti-king");
|
||||
if (puzzle.constraints.some(({ type }) => type === "non-consecutive"))
|
||||
labels.push("Non-consecutive");
|
||||
return labels;
|
||||
}
|
||||
|
||||
function renderConstraint(
|
||||
constraint: VariantConstraint,
|
||||
index: number,
|
||||
puzzle: NormalizedPuzzle,
|
||||
origin: BoardOrigin,
|
||||
): string {
|
||||
const { size } = puzzle;
|
||||
const negated = "negated" in constraint && constraint.negated === true;
|
||||
const polarity = negated ? " negated" : "";
|
||||
const marker = negated ? "≠" : "";
|
||||
if (
|
||||
constraint.type === "anti-knight" ||
|
||||
constraint.type === "anti-king" ||
|
||||
constraint.type === "non-consecutive"
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
if (constraint.type === "diagonal") {
|
||||
const startX =
|
||||
origin.x + (constraint.direction === "main" ? 0 : size * CELL_SIZE);
|
||||
const endX =
|
||||
origin.x + (constraint.direction === "main" ? size * CELL_SIZE : 0);
|
||||
return `<line class="constraint diagonal" x1="${number(startX)}" y1="${number(origin.y)}" x2="${number(endX)}" y2="${number(origin.y + size * CELL_SIZE)}"/>`;
|
||||
}
|
||||
if (constraint.type === "killer-cage") {
|
||||
const first = Math.min(...constraint.cells);
|
||||
const label = point(size, first, origin);
|
||||
return `<g class="constraint cage${polarity}" data-constraint="${index}"><path d="${pathBoundary(size, new Set(constraint.cells), origin, 7)}"/><text class="cage-label" x="${number(label.x - CELL_SIZE * 0.34)}" y="${number(label.y - CELL_SIZE * 0.27)}">${marker}${String(constraint.sum)}</text></g>`;
|
||||
}
|
||||
if (constraint.type === "thermo") {
|
||||
const bulb = point(size, constraint.cells[0]!, origin);
|
||||
return `<g class="constraint thermo${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/><circle cx="${number(bulb.x)}" cy="${number(bulb.y)}" r="${number(CELL_SIZE * 0.3)}"/>${negated ? `<text class="false-marker" x="${number(bulb.x)}" y="${number(bulb.y)}">≠</text>` : ""}</g>`;
|
||||
}
|
||||
if (constraint.type === "arrow") {
|
||||
const bulbPoints = constraint.bulb.map((cell) => point(size, cell, origin));
|
||||
const bulb = bulbPoints[0]!;
|
||||
const path = [constraint.bulb.at(-1)!, ...constraint.line];
|
||||
const tip = point(size, constraint.line.at(-1)!, origin);
|
||||
const minimumX = Math.min(...bulbPoints.map(({ x }) => x));
|
||||
const maximumX = Math.max(...bulbPoints.map(({ x }) => x));
|
||||
const minimumY = Math.min(...bulbPoints.map(({ y }) => y));
|
||||
const maximumY = Math.max(...bulbPoints.map(({ y }) => y));
|
||||
return `<g class="constraint arrow${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, path, origin)}"/><rect x="${number(minimumX - CELL_SIZE * 0.3)}" y="${number(minimumY - CELL_SIZE * 0.3)}" width="${number(maximumX - minimumX + CELL_SIZE * 0.6)}" height="${number(maximumY - minimumY + CELL_SIZE * 0.6)}" rx="${number(CELL_SIZE * 0.3)}"/><circle class="arrow-tip" cx="${number(tip.x)}" cy="${number(tip.y)}" r="${number(CELL_SIZE * 0.075)}"/>${negated ? `<text class="false-marker" x="${number(bulb.x)}" y="${number(bulb.y)}">≠</text>` : ""}</g>`;
|
||||
}
|
||||
if (constraint.type === "renban" || constraint.type === "palindrome") {
|
||||
return `<g class="constraint ${constraint.type}${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/>${
|
||||
constraint.type === "palindrome"
|
||||
? constraint.cells
|
||||
.map((cell) => {
|
||||
const center = point(size, cell, origin);
|
||||
return `<circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.13)}"/>`;
|
||||
})
|
||||
.join("")
|
||||
: ""
|
||||
}</g>`;
|
||||
}
|
||||
if (constraint.type === "maximum") {
|
||||
const center = point(size, constraint.cell, origin);
|
||||
const offset = CELL_SIZE * 0.28;
|
||||
const inner = CELL_SIZE * 0.14;
|
||||
return `<g class="constraint maximum${polarity}" data-constraint="${index}"><circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.18)}"/><path d="M${number(center.x - inner)} ${number(center.y - inner)}L${number(center.x)} ${number(center.y - offset)}L${number(center.x + inner)} ${number(center.y - inner)}M${number(center.x - inner)} ${number(center.y + inner)}L${number(center.x)} ${number(center.y + offset)}L${number(center.x + inner)} ${number(center.y + inner)}M${number(center.x - inner)} ${number(center.y - inner)}L${number(center.x - offset)} ${number(center.y)}L${number(center.x - inner)} ${number(center.y + inner)}M${number(center.x + inner)} ${number(center.y - inner)}L${number(center.x + offset)} ${number(center.y)}L${number(center.x + inner)} ${number(center.y + inner)}"/></g>`;
|
||||
}
|
||||
if (constraint.type === "quadruple") {
|
||||
const positions = constraint.cells.map((cell) => point(size, cell, origin));
|
||||
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 class="constraint quadruple${polarity}" data-constraint="${index}"><circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.29)}"/><text x="${number(center.x)}" y="${number(center.y)}">${marker}${escapeXml(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,
|
||||
origin,
|
||||
);
|
||||
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) === negated,
|
||||
);
|
||||
const combined = companionIndex >= 0;
|
||||
if (combined && companionIndex < index) return "";
|
||||
if (combined) {
|
||||
return `<g class="constraint outside combined${polarity}" data-constraint="${index}"><rect x="${number(position.x - 25)}" y="${number(position.y - 23)}" width="50" height="46" rx="8"/><text class="outside-kinds" x="${number(position.x)}" y="${number(position.y - 8)}">Σ · ▥</text><text class="outside-value" x="${number(position.x)}" y="${number(position.y + 10)}">${marker}${String(value)}</text></g>`;
|
||||
}
|
||||
return `<g class="constraint outside ${constraint.type}${polarity}" data-constraint="${index}"><rect x="${number(position.x - 25)}" y="${number(position.y - 18)}" width="50" height="36" rx="8"/><text x="${number(position.x)}" y="${number(position.y)}">${constraint.type === "x-sum" ? "Σ" : "▥"}${marker}${String(value)}</text></g>`;
|
||||
}
|
||||
const a = point(
|
||||
size,
|
||||
constraint.type === "inequality" ? constraint.lesser : constraint.a,
|
||||
origin,
|
||||
);
|
||||
const b = point(
|
||||
size,
|
||||
constraint.type === "inequality" ? constraint.greater : constraint.b,
|
||||
origin,
|
||||
);
|
||||
const middle = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||
if (constraint.type === "kropki") {
|
||||
return `<g class="constraint kropki ${constraint.kind}${polarity}" data-constraint="${index}"><circle cx="${number(middle.x)}" cy="${number(middle.y)}" r="${number(CELL_SIZE * 0.11)}"/>${negated ? `<text class="pair-false-marker" x="${number(middle.x)}" y="${number(middle.y)}">×</text>` : ""}</g>`;
|
||||
}
|
||||
if (constraint.type === "xv") {
|
||||
return `<g class="constraint xv total-${String(constraint.total)}${polarity}" data-constraint="${index}"><circle cx="${number(middle.x)}" cy="${number(middle.y)}" r="${number(CELL_SIZE * 0.19)}"/><text x="${number(middle.x)}" y="${number(middle.y)}">${marker}${String(constraint.total)}</text></g>`;
|
||||
}
|
||||
const dx = a.x - b.x;
|
||||
const dy = a.y - b.y;
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / length;
|
||||
const uy = dy / length;
|
||||
const perpendicular = { x: -uy, y: ux };
|
||||
const tip = {
|
||||
x: middle.x + ux * CELL_SIZE * 0.15,
|
||||
y: middle.y + uy * CELL_SIZE * 0.15,
|
||||
};
|
||||
const back = {
|
||||
x: middle.x - ux * CELL_SIZE * 0.15,
|
||||
y: middle.y - uy * CELL_SIZE * 0.15,
|
||||
};
|
||||
return `<g class="constraint inequality${polarity}" data-constraint="${index}"><polyline points="${number(back.x + perpendicular.x * CELL_SIZE * 0.14)},${number(back.y + perpendicular.y * CELL_SIZE * 0.14)} ${number(tip.x)},${number(tip.y)} ${number(back.x - perpendicular.x * CELL_SIZE * 0.14)},${number(back.y - perpendicular.y * CELL_SIZE * 0.14)}"/><circle class="inequality-tip" cx="${number(tip.x)}" cy="${number(tip.y)}" r="${number(CELL_SIZE * 0.035)}"/></g>`;
|
||||
}
|
||||
|
||||
function renderRegionBoundaries(puzzle: NormalizedPuzzle, origin: BoardOrigin) {
|
||||
const { size, regions } = puzzle;
|
||||
const lines: string[] = [];
|
||||
const board = size * CELL_SIZE;
|
||||
lines.push(
|
||||
`<rect x="${origin.x}" y="${origin.y}" width="${board}" height="${board}"/>`,
|
||||
);
|
||||
for (let row = 0; row < size; row += 1) {
|
||||
for (let column = 0; column < size; column += 1) {
|
||||
const cell = row * size + column;
|
||||
if (column < size - 1 && regions[cell] !== regions[cell + 1]) {
|
||||
const x = origin.x + (column + 1) * CELL_SIZE;
|
||||
const y = origin.y + row * CELL_SIZE;
|
||||
lines.push(
|
||||
`<line x1="${x}" y1="${y}" x2="${x}" y2="${y + CELL_SIZE}"/>`,
|
||||
);
|
||||
}
|
||||
if (row < size - 1 && regions[cell] !== regions[cell + size]) {
|
||||
const x = origin.x + column * CELL_SIZE;
|
||||
const y = origin.y + (row + 1) * CELL_SIZE;
|
||||
lines.push(
|
||||
`<line x1="${x}" y1="${y}" x2="${x + CELL_SIZE}" y2="${y}"/>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return `<g class="region-boundaries">${lines.join("")}</g>`;
|
||||
}
|
||||
|
||||
const COLOR_FILLS = [
|
||||
"transparent",
|
||||
"#fff2a8",
|
||||
"#ffd0c7",
|
||||
"#ccefd8",
|
||||
"#cde6ff",
|
||||
"#e3d4ff",
|
||||
"#ffe0b7",
|
||||
"#cdeeed",
|
||||
"#f3d0e6",
|
||||
];
|
||||
|
||||
/** Render a standalone, print-friendly SVG without external resources. */
|
||||
export function renderPuzzleSvg(
|
||||
document: SudokuDocument,
|
||||
options: VisualExportOptions = {},
|
||||
): string {
|
||||
const normalizedDocument = normalizeSudokuDocument(document);
|
||||
const puzzle = normalizePuzzle(toDomainPuzzle(normalizedDocument));
|
||||
const includeProgress = options.includeProgress ?? true;
|
||||
const includeNotes = options.includeNotes ?? includeProgress;
|
||||
const hasOutside = puzzle.constraints.some(
|
||||
({ type }) => type === "x-sum" || type === "skyscraper",
|
||||
);
|
||||
const sideMargin = hasOutside ? OUTSIDE_MARGIN : BOARD_MARGIN;
|
||||
const boardSize = puzzle.size * CELL_SIZE;
|
||||
const width = boardSize + sideMargin * 2;
|
||||
const height = HEADER_HEIGHT + boardSize + sideMargin * 2;
|
||||
const origin: BoardOrigin = {
|
||||
x: sideMargin,
|
||||
y: sideMargin + HEADER_HEIGHT,
|
||||
};
|
||||
const title = (normalizedDocument.title ?? "Sudoku").slice(0, 256);
|
||||
const byline = normalizedDocument.author
|
||||
? `by ${normalizedDocument.author.slice(0, 256)}`
|
||||
: "Sudoku Tools";
|
||||
const globals = globalRuleLabels(puzzle);
|
||||
const values = includeProgress
|
||||
? (normalizedDocument.values ?? puzzle.givens)
|
||||
: puzzle.givens;
|
||||
const cornerMarks = includeNotes
|
||||
? (normalizedDocument.cornerMarks ?? [])
|
||||
: [];
|
||||
const centerMarks = includeNotes
|
||||
? (normalizedDocument.centerMarks ?? normalizedDocument.candidates ?? [])
|
||||
: [];
|
||||
const colors = includeProgress ? (normalizedDocument.colors ?? []) : [];
|
||||
const cellCount = puzzle.size * puzzle.size;
|
||||
if (
|
||||
values.length !== cellCount ||
|
||||
(normalizedDocument.colors !== undefined &&
|
||||
normalizedDocument.colors.length !== cellCount)
|
||||
) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_VISUAL_EXPORT",
|
||||
"Puzzle progress does not match the grid size.",
|
||||
);
|
||||
}
|
||||
|
||||
const backgrounds = Array.from({ length: cellCount }, (_, cell) => {
|
||||
const color = colors[cell] ?? 0;
|
||||
const fill = COLOR_FILLS[color] ?? "transparent";
|
||||
if (fill === "transparent") return "";
|
||||
const row = Math.floor(cell / puzzle.size);
|
||||
const column = cell % puzzle.size;
|
||||
return `<rect class="cell-color" x="${origin.x + column * CELL_SIZE}" y="${origin.y + row * CELL_SIZE}" width="${CELL_SIZE}" height="${CELL_SIZE}" fill="${fill}"/>`;
|
||||
}).join("");
|
||||
|
||||
const gridLines = Array.from({ length: puzzle.size - 1 }, (_, index) => {
|
||||
const offset = (index + 1) * CELL_SIZE;
|
||||
return `<path d="M${origin.x + offset} ${origin.y}V${origin.y + boardSize}M${origin.x} ${origin.y + offset}H${origin.x + boardSize}"/>`;
|
||||
}).join("");
|
||||
const constraints = puzzle.constraints
|
||||
.map((constraint, index) =>
|
||||
renderConstraint(constraint, index, puzzle, origin),
|
||||
)
|
||||
.join("");
|
||||
const digits = Array.from({ length: cellCount }, (_, cell) => {
|
||||
const value = puzzle.givens[cell] || values[cell] || 0;
|
||||
const center = point(puzzle.size, cell, origin);
|
||||
if (value !== 0) {
|
||||
if (!Number.isInteger(value) || value < 1 || value > puzzle.size) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_VISUAL_EXPORT",
|
||||
`Cell ${String(cell + 1)} contains an out-of-range value.`,
|
||||
);
|
||||
}
|
||||
return `<text class="cell-value ${puzzle.givens[cell] ? "given" : "progress"}" x="${number(center.x)}" y="${number(center.y)}">${escapeXml(symbolFor(value, puzzle.size))}</text>`;
|
||||
}
|
||||
const corner = cornerMarks[cell] ?? [];
|
||||
const centerNotes = centerMarks[cell] ?? [];
|
||||
const cornerText = corner
|
||||
.slice(0, puzzle.size)
|
||||
.map((digit, noteIndex) => {
|
||||
const columns = Math.ceil(Math.sqrt(puzzle.size));
|
||||
const x =
|
||||
center.x -
|
||||
CELL_SIZE * 0.39 +
|
||||
((noteIndex % columns) * (CELL_SIZE * 0.78)) /
|
||||
Math.max(1, columns - 1);
|
||||
const y =
|
||||
center.y -
|
||||
CELL_SIZE * 0.34 +
|
||||
Math.floor(noteIndex / columns) * CELL_SIZE * 0.19;
|
||||
return `<text class="corner-note" x="${number(x)}" y="${number(y)}">${escapeXml(symbolFor(digit, puzzle.size))}</text>`;
|
||||
})
|
||||
.join("");
|
||||
const centerText = centerNotes.length
|
||||
? `<text class="center-note" x="${number(center.x)}" y="${number(center.y)}">${escapeXml(centerNotes.map((digit) => symbolFor(digit, puzzle.size)).join(""))}</text>`
|
||||
: "";
|
||||
return cornerText + centerText;
|
||||
}).join("");
|
||||
|
||||
const svg = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="title description">
|
||||
<title id="title">${escapeXml(title)}</title>
|
||||
<desc id="description">${escapeXml(`${String(puzzle.size)} by ${String(puzzle.size)} Sudoku${includeProgress ? " with current progress" : ""}`)}</desc>
|
||||
<style>
|
||||
.background{fill:#fff}.heading{fill:#172033;font-family:system-ui,sans-serif}.title{font-size:28px;font-weight:750}.byline,.global-rules{fill:#5d6678;font-size:14px}.grid-lines{fill:none;stroke:#a5acb8;stroke-width:1}.region-boundaries{fill:none;stroke:#172033;stroke-width:3;stroke-linecap:square}.constraint{fill:none;stroke:#596273;stroke-width:6;stroke-linecap:round;stroke-linejoin:round}.constraint text{dominant-baseline:central;text-anchor:middle;fill:#172033;stroke:none;font-family:system-ui,sans-serif;font-weight:700}.constraint.negated{stroke:#c63f52;stroke-dasharray:10 7}.constraint.negated text{fill:#a5283b}.diagonal{stroke:#4da3d6;stroke-width:3}.cage{stroke-width:2.2;stroke-dasharray:6 5}.cage-label{font-size:14px;text-anchor:start!important}.thermo{stroke:#c3c7ce;stroke-width:20}.thermo circle{fill:#c3c7ce;stroke:none}.thermo.negated{stroke-width:13}.false-marker{font-size:22px}.arrow{stroke:#929aa7;stroke-width:5}.arrow rect{fill:#fff;stroke:#929aa7}.arrow-tip{fill:#929aa7;stroke:none}.renban{stroke:#b55b8c;stroke-width:14;opacity:.72}.palindrome{stroke:#9ba1ad;stroke-width:13}.palindrome circle{fill:#d8dbe1;stroke:none}.maximum circle{fill:#eef0f4;stroke:#596273;stroke-width:2}.maximum path{stroke-width:3}.quadruple circle{fill:#fff;stroke:#596273;stroke-width:2}.quadruple text{font-size:15px}.outside rect{fill:#fff;stroke:#788191;stroke-width:1.5}.outside text{font-size:17px}.outside .outside-kinds{font-size:12px}.outside .outside-value{font-size:16px}.kropki circle{stroke:#172033;stroke-width:2}.kropki.white circle{fill:#fff}.kropki.black circle{fill:#172033}.pair-false-marker{fill:#c63f52!important;font-size:14px}.xv circle{fill:#fff;stroke:#fff}.xv text{font-size:18px}.inequality{stroke:#172033;stroke-width:4}.inequality-tip{fill:#172033;stroke:none}.cell-value,.corner-note,.center-note{dominant-baseline:central;text-anchor:middle;font-family:system-ui,sans-serif}.cell-value{fill:#273c75;font-size:${number(CELL_SIZE * 0.58)}px}.cell-value.given{fill:#111827;font-weight:800}.cell-value.progress{font-weight:600}.corner-note{fill:#596273;font-size:${number(CELL_SIZE * 0.15)}px}.center-note{fill:#596273;font-size:${number(CELL_SIZE * 0.2)}px;letter-spacing:1px}
|
||||
</style>
|
||||
<rect class="background" width="100%" height="100%"/>
|
||||
<g class="heading"><text class="title" x="${origin.x}" y="34">${escapeXml(title)}</text><text class="byline" x="${origin.x}" y="57">${escapeXml(byline)}</text>${globals.length > 0 ? `<text class="global-rules" x="${width - sideMargin}" y="57" text-anchor="end">${escapeXml(globals.join(" · "))}</text>` : ""}</g>
|
||||
<g>${backgrounds}<g class="grid-lines">${gridLines}</g>${constraints}${renderRegionBoundaries(puzzle, origin)}${digits}</g>
|
||||
</svg>`;
|
||||
if (new TextEncoder().encode(svg).byteLength > MAX_VISUAL_EXPORT_BYTES) {
|
||||
throw new SudokuFormatError(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The rendered SVG is too large to export safely.",
|
||||
);
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
|
||||
function svgDimensions(svg: string): { width: number; height: number } {
|
||||
const match = /<svg[^>]+width="(\d+)"[^>]+height="(\d+)"/u.exec(svg);
|
||||
if (match === null) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_VISUAL_EXPORT",
|
||||
"The generated SVG has no dimensions.",
|
||||
);
|
||||
}
|
||||
return { width: Number(match[1]), height: Number(match[2]) };
|
||||
}
|
||||
|
||||
async function loadSvgImage(svg: string): Promise<CanvasImageSource> {
|
||||
const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
|
||||
if (typeof createImageBitmap === "function") {
|
||||
try {
|
||||
return await createImageBitmap(blob);
|
||||
} catch {
|
||||
// Safari and some hardened browsers cannot decode SVG through ImageBitmap.
|
||||
}
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
return await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () =>
|
||||
reject(new Error("The SVG image could not be decoded."));
|
||||
image.src = url;
|
||||
});
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function rasterizeSvg(
|
||||
svg: string,
|
||||
scale: number,
|
||||
): Promise<HTMLCanvasElement> {
|
||||
if (!Number.isFinite(scale) || scale < 0.5 || scale > 4) {
|
||||
throw new RangeError("rasterScale must be from 0.5 to 4.");
|
||||
}
|
||||
const dimensions = svgDimensions(svg);
|
||||
const boundedScale = Math.min(
|
||||
scale,
|
||||
MAX_VISUAL_EXPORT_DIMENSION / Math.max(dimensions.width, dimensions.height),
|
||||
);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, Math.round(dimensions.width * boundedScale));
|
||||
canvas.height = Math.max(1, Math.round(dimensions.height * boundedScale));
|
||||
const context = canvas.getContext("2d");
|
||||
if (context === null) {
|
||||
throw new SudokuFormatError(
|
||||
"UNSUPPORTED_BROWSER",
|
||||
"This browser cannot create a canvas for visual export.",
|
||||
);
|
||||
}
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
const image = await loadSvgImage(svg);
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
if ("close" in image && typeof image.close === "function") image.close();
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function canvasBlob(
|
||||
canvas: HTMLCanvasElement,
|
||||
type: "image/png" | "image/jpeg",
|
||||
quality?: number,
|
||||
): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) =>
|
||||
blob === null
|
||||
? reject(new Error(`The browser could not encode ${type}.`))
|
||||
: resolve(blob),
|
||||
type,
|
||||
quality,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function renderPuzzlePng(
|
||||
document: SudokuDocument,
|
||||
options: VisualExportOptions = {},
|
||||
): Promise<Blob> {
|
||||
const canvas = await rasterizeSvg(
|
||||
renderPuzzleSvg(document, options),
|
||||
options.rasterScale ?? 2,
|
||||
);
|
||||
const blob = await canvasBlob(canvas, "image/png");
|
||||
if (blob.size > MAX_VISUAL_EXPORT_BYTES) {
|
||||
throw new SudokuFormatError(
|
||||
"LIMIT_EXCEEDED",
|
||||
"The rendered PNG is too large to export safely.",
|
||||
);
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
|
||||
function ascii(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
function concatenate(parts: readonly Uint8Array[]): Uint8Array {
|
||||
const length = parts.reduce((sum, part) => sum + part.byteLength, 0);
|
||||
const output = new Uint8Array(length);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
output.set(part, offset);
|
||||
offset += part.byteLength;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Build a single-page PDF around browser-generated JPEG bytes. */
|
||||
export function buildJpegPdf(
|
||||
jpeg: Uint8Array,
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
): Uint8Array {
|
||||
if (
|
||||
jpeg.byteLength < 4 ||
|
||||
jpeg.byteLength > MAX_VISUAL_EXPORT_BYTES ||
|
||||
jpeg[0] !== 0xff ||
|
||||
jpeg[1] !== 0xd8 ||
|
||||
jpeg.at(-2) !== 0xff ||
|
||||
jpeg.at(-1) !== 0xd9
|
||||
) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_VISUAL_EXPORT",
|
||||
"The PDF renderer did not receive a bounded JPEG image.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(imageWidth) ||
|
||||
!Number.isInteger(imageHeight) ||
|
||||
imageWidth < 1 ||
|
||||
imageHeight < 1 ||
|
||||
imageWidth > MAX_VISUAL_EXPORT_DIMENSION ||
|
||||
imageHeight > MAX_VISUAL_EXPORT_DIMENSION
|
||||
) {
|
||||
throw new SudokuFormatError(
|
||||
"INVALID_VISUAL_EXPORT",
|
||||
"The PDF image dimensions are invalid.",
|
||||
);
|
||||
}
|
||||
const pageWidth = 595;
|
||||
const pageHeight = 842;
|
||||
const maximumWidth = pageWidth - 64;
|
||||
const maximumHeight = pageHeight - 64;
|
||||
const scale = Math.min(
|
||||
maximumWidth / imageWidth,
|
||||
maximumHeight / imageHeight,
|
||||
);
|
||||
const drawWidth = imageWidth * scale;
|
||||
const drawHeight = imageHeight * scale;
|
||||
const x = (pageWidth - drawWidth) / 2;
|
||||
const y = (pageHeight - drawHeight) / 2;
|
||||
const content = `q\n${number(drawWidth)} 0 0 ${number(drawHeight)} ${number(x)} ${number(y)} cm\n/Im0 Do\nQ\n`;
|
||||
const objects: Uint8Array[] = [
|
||||
ascii("<< /Type /Catalog /Pages 2 0 R >>"),
|
||||
ascii("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"),
|
||||
ascii(
|
||||
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>`,
|
||||
),
|
||||
concatenate([
|
||||
ascii(
|
||||
`<< /Type /XObject /Subtype /Image /Width ${String(imageWidth)} /Height ${String(imageHeight)} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${String(jpeg.byteLength)} >>\nstream\n`,
|
||||
),
|
||||
jpeg,
|
||||
ascii("\nendstream"),
|
||||
]),
|
||||
ascii(
|
||||
`<< /Length ${String(ascii(content).byteLength)} >>\nstream\n${content}endstream`,
|
||||
),
|
||||
];
|
||||
const parts: Uint8Array[] = [
|
||||
new Uint8Array([
|
||||
...ascii("%PDF-1.4\n%"),
|
||||
0xe2,
|
||||
0xe3,
|
||||
0xcf,
|
||||
0xd3,
|
||||
...ascii("\n"),
|
||||
]),
|
||||
];
|
||||
const offsets = [0];
|
||||
let byteOffset = parts[0]!.byteLength;
|
||||
objects.forEach((object, index) => {
|
||||
offsets.push(byteOffset);
|
||||
const wrapped = concatenate([
|
||||
ascii(`${String(index + 1)} 0 obj\n`),
|
||||
object,
|
||||
ascii("\nendobj\n"),
|
||||
]);
|
||||
parts.push(wrapped);
|
||||
byteOffset += wrapped.byteLength;
|
||||
});
|
||||
const xrefOffset = byteOffset;
|
||||
const xref = [
|
||||
`xref\n0 ${String(objects.length + 1)}\n`,
|
||||
"0000000000 65535 f \n",
|
||||
...offsets
|
||||
.slice(1)
|
||||
.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`),
|
||||
`trailer\n<< /Size ${String(objects.length + 1)} /Root 1 0 R >>\nstartxref\n${String(xrefOffset)}\n%%EOF\n`,
|
||||
].join("");
|
||||
parts.push(ascii(xref));
|
||||
return concatenate(parts);
|
||||
}
|
||||
|
||||
export async function renderPuzzlePdf(
|
||||
document: SudokuDocument,
|
||||
options: VisualExportOptions = {},
|
||||
): Promise<Blob> {
|
||||
const canvas = await rasterizeSvg(
|
||||
renderPuzzleSvg(document, options),
|
||||
options.rasterScale ?? 2,
|
||||
);
|
||||
const jpeg = new Uint8Array(
|
||||
await (await canvasBlob(canvas, "image/jpeg", 0.94)).arrayBuffer(),
|
||||
);
|
||||
const pdf = buildJpegPdf(jpeg, canvas.width, canvas.height);
|
||||
const buffer = new ArrayBuffer(pdf.byteLength);
|
||||
new Uint8Array(buffer).set(pdf);
|
||||
return new Blob([buffer], { type: "application/pdf" });
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import {
|
||||
compilePuzzle,
|
||||
type CompiledPuzzle,
|
||||
type NormalizedPuzzle,
|
||||
type SudokuUnit,
|
||||
type UnitKind,
|
||||
} from "../domain";
|
||||
|
||||
export interface CandidateNode {
|
||||
readonly cell: number;
|
||||
readonly value: number;
|
||||
}
|
||||
|
||||
export type CandidateLinkKind = "strong" | "weak";
|
||||
|
||||
export interface CandidateLinkContext {
|
||||
readonly kind: "cell" | "house";
|
||||
readonly label: string;
|
||||
readonly unitKind?: UnitKind;
|
||||
readonly unitIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A graph edge between two candidates. Strong edges mean that at least one
|
||||
* endpoint must be true; weak edges mean that both endpoints cannot be true.
|
||||
* A strong Sudoku link is also weak, but is rendered once as the more useful
|
||||
* strong relationship.
|
||||
*/
|
||||
export interface CandidateLink {
|
||||
readonly id: string;
|
||||
readonly kind: CandidateLinkKind;
|
||||
readonly a: CandidateNode;
|
||||
readonly b: CandidateNode;
|
||||
readonly contexts: readonly CandidateLinkContext[];
|
||||
}
|
||||
|
||||
export interface CandidatePosition {
|
||||
readonly value: number;
|
||||
readonly cells: readonly number[];
|
||||
readonly linkKind: CandidateLinkKind | "none";
|
||||
}
|
||||
|
||||
export interface HouseCandidateInspection {
|
||||
readonly unitIndex: number;
|
||||
readonly kind: UnitKind;
|
||||
readonly index: number;
|
||||
readonly label: string;
|
||||
readonly cells: readonly number[];
|
||||
readonly missingValues: readonly number[];
|
||||
readonly positions: readonly CandidatePosition[];
|
||||
}
|
||||
|
||||
export interface CellCandidateInspection {
|
||||
readonly cell: number;
|
||||
readonly values: readonly number[];
|
||||
readonly houseLabels: readonly string[];
|
||||
}
|
||||
|
||||
export interface CandidateOverlay {
|
||||
readonly activeValues: readonly number[];
|
||||
readonly candidateCells: readonly number[];
|
||||
readonly links: readonly CandidateLink[];
|
||||
}
|
||||
|
||||
export interface CandidateLinkOptions {
|
||||
/** Only derive house links from these compiled unit indices. */
|
||||
readonly unitIndices?: readonly number[];
|
||||
/** Only retain links whose two endpoints use one of these values. */
|
||||
readonly values?: readonly number[];
|
||||
/** Cells from which same-cell links are derived. Defaults to every cell. */
|
||||
readonly cellIndices?: readonly number[];
|
||||
readonly includeCellLinks?: boolean;
|
||||
}
|
||||
|
||||
function nodeKey(node: CandidateNode): string {
|
||||
return `${String(node.cell)}:${String(node.value)}`;
|
||||
}
|
||||
|
||||
function orderedNodes(
|
||||
first: CandidateNode,
|
||||
second: CandidateNode,
|
||||
): readonly [CandidateNode, CandidateNode] {
|
||||
return nodeKey(first) < nodeKey(second) ? [first, second] : [second, first];
|
||||
}
|
||||
|
||||
function linkKey(a: CandidateNode, b: CandidateNode): string {
|
||||
const [first, second] = orderedNodes(a, b);
|
||||
return `${nodeKey(first)}-${nodeKey(second)}`;
|
||||
}
|
||||
|
||||
function addLink(
|
||||
links: Map<
|
||||
string,
|
||||
{
|
||||
kind: CandidateLinkKind;
|
||||
a: CandidateNode;
|
||||
b: CandidateNode;
|
||||
contexts: CandidateLinkContext[];
|
||||
}
|
||||
>,
|
||||
kind: CandidateLinkKind,
|
||||
first: CandidateNode,
|
||||
second: CandidateNode,
|
||||
context: CandidateLinkContext,
|
||||
): void {
|
||||
const [a, b] = orderedNodes(first, second);
|
||||
const key = linkKey(a, b);
|
||||
const existing = links.get(key);
|
||||
if (existing === undefined) {
|
||||
links.set(key, { kind, a, b, contexts: [context] });
|
||||
return;
|
||||
}
|
||||
if (kind === "strong") existing.kind = "strong";
|
||||
if (!existing.contexts.some(({ label }) => label === context.label)) {
|
||||
existing.contexts.push(context);
|
||||
}
|
||||
}
|
||||
|
||||
function candidateValues(mask: number, size: number): number[] {
|
||||
const result: number[] = [];
|
||||
for (let value = 1; value <= size; value += 1) {
|
||||
if ((mask & (1 << (value - 1))) !== 0) result.push(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function candidateValuesFromMask(mask: number, size: number): number[] {
|
||||
if (!Number.isInteger(size) || size < 1 || size > 30) return [];
|
||||
return candidateValues(Number.isInteger(mask) ? mask : 0, size);
|
||||
}
|
||||
|
||||
export function houseLabel(unit: Pick<SudokuUnit, "kind" | "index">): string {
|
||||
switch (unit.kind) {
|
||||
case "row":
|
||||
return `Row ${String(unit.index + 1)}`;
|
||||
case "column":
|
||||
return `Column ${String(unit.index + 1)}`;
|
||||
case "region":
|
||||
return `Region ${String(unit.index + 1)}`;
|
||||
case "diagonal":
|
||||
return unit.index === 0 ? "Main diagonal" : "Anti-diagonal";
|
||||
}
|
||||
}
|
||||
|
||||
/** All houses touching at least one of the supplied cells, in compile order. */
|
||||
export function candidateUnitIndicesForCells(
|
||||
puzzle: NormalizedPuzzle,
|
||||
cells: readonly number[],
|
||||
): number[] {
|
||||
const compiled = compilePuzzle(puzzle);
|
||||
const indices = new Set<number>();
|
||||
for (const cell of cells) {
|
||||
for (const unitIndex of compiled.unitsByCell[cell] ?? []) {
|
||||
indices.add(unitIndex);
|
||||
}
|
||||
}
|
||||
return [...indices].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function inspectCandidateCells(
|
||||
puzzle: NormalizedPuzzle,
|
||||
candidateMasks: readonly number[],
|
||||
cells: readonly number[],
|
||||
): CellCandidateInspection[] {
|
||||
const compiled = compilePuzzle(puzzle);
|
||||
return [...new Set(cells)]
|
||||
.filter(
|
||||
(cell) => Number.isInteger(cell) && cell >= 0 && cell < puzzle.size ** 2,
|
||||
)
|
||||
.sort((a, b) => a - b)
|
||||
.map((cell) => ({
|
||||
cell,
|
||||
values: candidateValues(candidateMasks[cell] ?? 0, puzzle.size),
|
||||
houseLabels: (compiled.unitsByCell[cell] ?? []).map((unitIndex) =>
|
||||
houseLabel(compiled.units[unitIndex]!),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
export function inspectCandidateHouse(
|
||||
puzzle: NormalizedPuzzle,
|
||||
values: readonly number[],
|
||||
candidateMasks: readonly number[],
|
||||
unitIndex: number,
|
||||
): HouseCandidateInspection | undefined {
|
||||
const compiled = compilePuzzle(puzzle);
|
||||
return inspectCandidateHouseWithCompiled(
|
||||
compiled,
|
||||
values,
|
||||
candidateMasks,
|
||||
unitIndex,
|
||||
);
|
||||
}
|
||||
|
||||
function inspectCandidateHouseWithCompiled(
|
||||
compiled: CompiledPuzzle,
|
||||
values: readonly number[],
|
||||
candidateMasks: readonly number[],
|
||||
unitIndex: number,
|
||||
): HouseCandidateInspection | undefined {
|
||||
const { puzzle } = compiled;
|
||||
const unit = compiled.units[unitIndex];
|
||||
if (unit === undefined) return undefined;
|
||||
const placed = new Set(
|
||||
unit.cells
|
||||
.map((cell) => values[cell] ?? 0)
|
||||
.filter((value) => value >= 1 && value <= puzzle.size),
|
||||
);
|
||||
const missingValues = Array.from(
|
||||
{ length: puzzle.size },
|
||||
(_, index) => index + 1,
|
||||
).filter((value) => !placed.has(value));
|
||||
const positions = missingValues.map((value) => {
|
||||
const cells = unit.cells.filter(
|
||||
(cell) =>
|
||||
(values[cell] ?? 0) === 0 &&
|
||||
((candidateMasks[cell] ?? 0) & (1 << (value - 1))) !== 0,
|
||||
);
|
||||
return {
|
||||
value,
|
||||
cells,
|
||||
linkKind:
|
||||
cells.length === 2
|
||||
? ("strong" as const)
|
||||
: cells.length > 2
|
||||
? ("weak" as const)
|
||||
: ("none" as const),
|
||||
};
|
||||
});
|
||||
return {
|
||||
unitIndex,
|
||||
kind: unit.kind,
|
||||
index: unit.index,
|
||||
label: houseLabel(unit),
|
||||
cells: unit.cells,
|
||||
missingValues,
|
||||
positions,
|
||||
};
|
||||
}
|
||||
|
||||
export function inspectCandidateHouses(
|
||||
puzzle: NormalizedPuzzle,
|
||||
values: readonly number[],
|
||||
candidateMasks: readonly number[],
|
||||
unitIndices?: readonly number[],
|
||||
): HouseCandidateInspection[] {
|
||||
const compiled = compilePuzzle(puzzle);
|
||||
const indices = unitIndices ?? compiled.units.map((_, index) => index);
|
||||
return [...new Set(indices)]
|
||||
.map((unitIndex) =>
|
||||
inspectCandidateHouseWithCompiled(
|
||||
compiled,
|
||||
values,
|
||||
candidateMasks,
|
||||
unitIndex,
|
||||
),
|
||||
)
|
||||
.filter((inspection): inspection is HouseCandidateInspection =>
|
||||
Boolean(inspection),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the standard candidate-link graph from houses and cells.
|
||||
*
|
||||
* - two positions for a digit in a house form a strong link;
|
||||
* - three or more positions form pairwise weak links;
|
||||
* - two candidates in one cell form a strong link;
|
||||
* - three or more candidates in one cell form pairwise weak links.
|
||||
*/
|
||||
export function deriveCandidateLinks(
|
||||
puzzle: NormalizedPuzzle,
|
||||
candidateMasks: readonly number[],
|
||||
options: CandidateLinkOptions = {},
|
||||
): CandidateLink[] {
|
||||
const compiled = compilePuzzle(puzzle);
|
||||
const activeValues =
|
||||
options.values === undefined
|
||||
? undefined
|
||||
: new Set(
|
||||
options.values.filter(
|
||||
(value) =>
|
||||
Number.isInteger(value) && value >= 1 && value <= puzzle.size,
|
||||
),
|
||||
);
|
||||
const links = new Map<
|
||||
string,
|
||||
{
|
||||
kind: CandidateLinkKind;
|
||||
a: CandidateNode;
|
||||
b: CandidateNode;
|
||||
contexts: CandidateLinkContext[];
|
||||
}
|
||||
>();
|
||||
const unitIndices =
|
||||
options.unitIndices ?? compiled.units.map((_, index) => index);
|
||||
|
||||
for (const unitIndex of new Set(unitIndices)) {
|
||||
const unit = compiled.units[unitIndex];
|
||||
if (unit === undefined) continue;
|
||||
for (let value = 1; value <= puzzle.size; value += 1) {
|
||||
if (activeValues !== undefined && !activeValues.has(value)) continue;
|
||||
const nodes = unit.cells
|
||||
.filter(
|
||||
(cell) => ((candidateMasks[cell] ?? 0) & (1 << (value - 1))) !== 0,
|
||||
)
|
||||
.map((cell) => ({ cell, value }));
|
||||
if (nodes.length < 2) continue;
|
||||
const kind: CandidateLinkKind = nodes.length === 2 ? "strong" : "weak";
|
||||
for (let a = 0; a < nodes.length; a += 1) {
|
||||
for (let b = a + 1; b < nodes.length; b += 1) {
|
||||
addLink(links, kind, nodes[a]!, nodes[b]!, {
|
||||
kind: "house",
|
||||
label: houseLabel(unit),
|
||||
unitKind: unit.kind,
|
||||
unitIndex,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.includeCellLinks !== false) {
|
||||
const cellIndices =
|
||||
options.cellIndices ??
|
||||
Array.from({ length: puzzle.size ** 2 }, (_, cell) => cell);
|
||||
for (const cell of new Set(cellIndices)) {
|
||||
if (!Number.isInteger(cell) || cell < 0 || cell >= puzzle.size ** 2)
|
||||
continue;
|
||||
const allValues = candidateValues(candidateMasks[cell] ?? 0, puzzle.size);
|
||||
if (allValues.length < 2) continue;
|
||||
const kind: CandidateLinkKind =
|
||||
allValues.length === 2 ? "strong" : "weak";
|
||||
const visibleValues =
|
||||
activeValues === undefined
|
||||
? allValues
|
||||
: allValues.filter((value) => activeValues.has(value));
|
||||
for (let a = 0; a < visibleValues.length; a += 1) {
|
||||
for (let b = a + 1; b < visibleValues.length; b += 1) {
|
||||
addLink(
|
||||
links,
|
||||
kind,
|
||||
{ cell, value: visibleValues[a]! },
|
||||
{ cell, value: visibleValues[b]! },
|
||||
{ kind: "cell", label: `Cell ${cellLabel(cell, puzzle.size)}` },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...links.entries()]
|
||||
.map(([id, link]) => ({ id, ...link }))
|
||||
.sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === "strong" ? -1 : 1;
|
||||
return a.id.localeCompare(b.id, undefined, { numeric: true });
|
||||
});
|
||||
}
|
||||
|
||||
export function candidateCellsForValues(
|
||||
candidateMasks: readonly number[],
|
||||
values: readonly number[],
|
||||
): number[] {
|
||||
const combinedMask = values.reduce(
|
||||
(mask, value) =>
|
||||
Number.isInteger(value) && value >= 1 && value <= 30
|
||||
? mask | (1 << (value - 1))
|
||||
: mask,
|
||||
0,
|
||||
);
|
||||
if (combinedMask === 0) return [];
|
||||
return candidateMasks.flatMap((mask, cell) =>
|
||||
(mask & combinedMask) !== 0 ? [cell] : [],
|
||||
);
|
||||
}
|
||||
|
||||
export function cellLabel(cell: number, size: number): string {
|
||||
return `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from "./candidates";
|
||||
export * from "./killer";
|
||||
export * from "./relations";
|
||||
export * from "./residual";
|
||||
export * from "./sumLab";
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
export const MAX_SUM_LAB_DIGIT = 25;
|
||||
export const MAX_SUM_LAB_COMBINATIONS = 10_000;
|
||||
export const MAX_SUM_LAB_ASSIGNMENTS = 50_000;
|
||||
export const MAX_SUM_LAB_SEARCH_NODES = 500_000;
|
||||
|
||||
export interface SumLabOptions {
|
||||
/** Number of cells whose values add up to the target. */
|
||||
readonly cellCount: number;
|
||||
readonly target: number;
|
||||
readonly minimumDigit?: number;
|
||||
readonly maximumDigit?: number;
|
||||
readonly allowRepeats?: boolean;
|
||||
/** Every listed digit must occur at least once. */
|
||||
readonly requiredDigits?: readonly number[];
|
||||
readonly excludedDigits?: readonly number[];
|
||||
/** One bit mask per cell, with digit 1 represented by the least-significant bit. */
|
||||
readonly candidateMasks?: readonly number[];
|
||||
/** Canonical keys returned by `sumCombinationKey`. */
|
||||
readonly eliminatedKeys?: ReadonlySet<string> | readonly string[];
|
||||
readonly maxCombinations?: number;
|
||||
readonly maxAssignments?: number;
|
||||
readonly maxSearchNodes?: number;
|
||||
}
|
||||
|
||||
export interface SumLabCombination {
|
||||
readonly key: string;
|
||||
/** Sorted digits. Repeated values are retained. */
|
||||
readonly digits: readonly number[];
|
||||
readonly eliminated: boolean;
|
||||
/** Number of candidate-compatible cell orderings retained by this analysis. */
|
||||
readonly assignmentCount: number;
|
||||
}
|
||||
|
||||
export interface SumLabAnalysis {
|
||||
/** All candidate-compatible combinations, including manually eliminated ones. */
|
||||
readonly combinations: readonly SumLabCombination[];
|
||||
readonly activeCombinations: readonly SumLabCombination[];
|
||||
readonly eliminatedCombinations: readonly SumLabCombination[];
|
||||
/** Candidate-compatible cell-ordered assignments for active combinations. */
|
||||
readonly assignments: readonly (readonly number[])[];
|
||||
readonly possibleDigits: readonly number[];
|
||||
/** Digits present in every active combination. */
|
||||
readonly necessaryDigits: readonly number[];
|
||||
readonly possibleByCell: readonly (readonly number[])[];
|
||||
readonly exploredNodes: number;
|
||||
readonly truncated: boolean;
|
||||
readonly truncationReason?: "combinations" | "assignments" | "search";
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
value: number,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
if (!Number.isInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(
|
||||
`${label} must be an integer from ${minimum} to ${maximum}.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function uniqueSortedDigits(
|
||||
values: readonly number[],
|
||||
label: string,
|
||||
minimumDigit: number,
|
||||
maximumDigit: number,
|
||||
): number[] {
|
||||
const result = [...new Set(values)];
|
||||
for (const value of result) {
|
||||
if (
|
||||
!Number.isInteger(value) ||
|
||||
value < minimumDigit ||
|
||||
value > maximumDigit
|
||||
) {
|
||||
throw new RangeError(
|
||||
`${label} must only contain integers from ${minimumDigit} to ${maximumDigit}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return result.sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function intersect(
|
||||
left: ReadonlySet<number>,
|
||||
right: ReadonlySet<number>,
|
||||
): Set<number> {
|
||||
return new Set([...left].filter((value) => right.has(value)));
|
||||
}
|
||||
|
||||
function emptyAnalysis(cellCount: number): SumLabAnalysis {
|
||||
return {
|
||||
combinations: [],
|
||||
activeCombinations: [],
|
||||
eliminatedCombinations: [],
|
||||
assignments: [],
|
||||
possibleDigits: [],
|
||||
necessaryDigits: [],
|
||||
possibleByCell: Array.from({ length: cellCount }, () => []),
|
||||
exploredNodes: 0,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable identifier for a sorted sum combination. It is intentionally human
|
||||
* readable so callers can safely retain eliminations while rendering results.
|
||||
*/
|
||||
export function sumCombinationKey(digits: readonly number[]): string {
|
||||
return digits.join(",");
|
||||
}
|
||||
|
||||
function digitIsInMask(mask: number, digit: number): boolean {
|
||||
return (mask & (2 ** (digit - 1))) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate generalized Sudoku sums without permutations. Results are ordered
|
||||
* lexicographically and bounded by independent combination, assignment and
|
||||
* search-node limits. Candidate masks are only used when a positional cell
|
||||
* selection was supplied.
|
||||
*/
|
||||
export function analyzeSumLab(options: SumLabOptions): SumLabAnalysis {
|
||||
const minimumDigit = boundedInteger(
|
||||
options.minimumDigit ?? 1,
|
||||
"minimumDigit",
|
||||
1,
|
||||
MAX_SUM_LAB_DIGIT,
|
||||
);
|
||||
const maximumDigit = boundedInteger(
|
||||
options.maximumDigit ?? 9,
|
||||
"maximumDigit",
|
||||
minimumDigit,
|
||||
MAX_SUM_LAB_DIGIT,
|
||||
);
|
||||
const cellCount = boundedInteger(options.cellCount, "cellCount", 1, 25);
|
||||
const target = boundedInteger(
|
||||
options.target,
|
||||
"target",
|
||||
0,
|
||||
maximumDigit * cellCount,
|
||||
);
|
||||
const maxCombinations = boundedInteger(
|
||||
options.maxCombinations ?? 5_000,
|
||||
"maxCombinations",
|
||||
1,
|
||||
MAX_SUM_LAB_COMBINATIONS,
|
||||
);
|
||||
const maxAssignments = boundedInteger(
|
||||
options.maxAssignments ?? 20_000,
|
||||
"maxAssignments",
|
||||
1,
|
||||
MAX_SUM_LAB_ASSIGNMENTS,
|
||||
);
|
||||
const maxSearchNodes = boundedInteger(
|
||||
options.maxSearchNodes ?? 200_000,
|
||||
"maxSearchNodes",
|
||||
1,
|
||||
MAX_SUM_LAB_SEARCH_NODES,
|
||||
);
|
||||
const required = uniqueSortedDigits(
|
||||
options.requiredDigits ?? [],
|
||||
"requiredDigits",
|
||||
minimumDigit,
|
||||
maximumDigit,
|
||||
);
|
||||
const excluded = new Set(
|
||||
uniqueSortedDigits(
|
||||
options.excludedDigits ?? [],
|
||||
"excludedDigits",
|
||||
minimumDigit,
|
||||
maximumDigit,
|
||||
),
|
||||
);
|
||||
const digits = Array.from(
|
||||
{ length: maximumDigit - minimumDigit + 1 },
|
||||
(_, index) => minimumDigit + index,
|
||||
).filter((digit) => !excluded.has(digit));
|
||||
const allowRepeats = options.allowRepeats ?? false;
|
||||
const eliminatedKeys = new Set(options.eliminatedKeys ?? []);
|
||||
|
||||
if (
|
||||
digits.length === 0 ||
|
||||
required.some((digit) => excluded.has(digit)) ||
|
||||
required.length > cellCount ||
|
||||
(!allowRepeats && cellCount > digits.length)
|
||||
) {
|
||||
return emptyAnalysis(cellCount);
|
||||
}
|
||||
|
||||
let candidateMasks: readonly number[] | undefined;
|
||||
if (options.candidateMasks !== undefined) {
|
||||
if (options.candidateMasks.length !== cellCount) {
|
||||
throw new RangeError(
|
||||
`candidateMasks must contain exactly ${cellCount} cell entries.`,
|
||||
);
|
||||
}
|
||||
for (const mask of options.candidateMasks) {
|
||||
if (!Number.isSafeInteger(mask) || mask < 0) {
|
||||
throw new RangeError("candidate masks must be non-negative integers.");
|
||||
}
|
||||
}
|
||||
candidateMasks = options.candidateMasks;
|
||||
}
|
||||
|
||||
const rawCombinations: {
|
||||
digits: number[];
|
||||
assignments: number[][];
|
||||
}[] = [];
|
||||
let exploredNodes = 0;
|
||||
let retainedAssignments = 0;
|
||||
let truncationReason: SumLabAnalysis["truncationReason"];
|
||||
|
||||
const assignmentsFor = (combination: readonly number[]): number[][] => {
|
||||
if (candidateMasks === undefined) return [[...combination]];
|
||||
const result: number[][] = [];
|
||||
const remaining = new Map<number, number>();
|
||||
for (const digit of combination) {
|
||||
remaining.set(digit, (remaining.get(digit) ?? 0) + 1);
|
||||
}
|
||||
const assignment = Array<number>(cellCount).fill(0);
|
||||
|
||||
const visit = (position: number): void => {
|
||||
if (truncationReason !== undefined) return;
|
||||
exploredNodes += 1;
|
||||
if (exploredNodes > maxSearchNodes) {
|
||||
truncationReason = "search";
|
||||
return;
|
||||
}
|
||||
if (position === cellCount) {
|
||||
if (retainedAssignments + result.length >= maxAssignments) {
|
||||
truncationReason = "assignments";
|
||||
return;
|
||||
}
|
||||
result.push([...assignment]);
|
||||
return;
|
||||
}
|
||||
const mask = candidateMasks[position] ?? 0;
|
||||
for (const digit of [...remaining.keys()].sort(
|
||||
(left, right) => left - right,
|
||||
)) {
|
||||
const count = remaining.get(digit) ?? 0;
|
||||
if (count === 0 || !digitIsInMask(mask, digit)) continue;
|
||||
assignment[position] = digit;
|
||||
remaining.set(digit, count - 1);
|
||||
visit(position + 1);
|
||||
remaining.set(digit, count);
|
||||
if (truncationReason !== undefined) return;
|
||||
}
|
||||
};
|
||||
visit(0);
|
||||
return result;
|
||||
};
|
||||
|
||||
const current: number[] = [];
|
||||
const enumerate = (
|
||||
startIndex: number,
|
||||
remainingCells: number,
|
||||
remainingSum: number,
|
||||
): void => {
|
||||
if (truncationReason !== undefined) return;
|
||||
exploredNodes += 1;
|
||||
if (exploredNodes > maxSearchNodes) {
|
||||
truncationReason = "search";
|
||||
return;
|
||||
}
|
||||
if (remainingCells === 0) {
|
||||
if (
|
||||
remainingSum !== 0 ||
|
||||
!required.every((digit) => current.includes(digit))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const assignments = assignmentsFor(current);
|
||||
if (assignments.length === 0) return;
|
||||
if (rawCombinations.length >= maxCombinations) {
|
||||
truncationReason = "combinations";
|
||||
return;
|
||||
}
|
||||
rawCombinations.push({ digits: [...current], assignments });
|
||||
retainedAssignments += assignments.length;
|
||||
return;
|
||||
}
|
||||
if (startIndex >= digits.length) return;
|
||||
|
||||
if (allowRepeats) {
|
||||
const minimum = (digits[startIndex] ?? 0) * remainingCells;
|
||||
const maximum = (digits.at(-1) ?? 0) * remainingCells;
|
||||
if (remainingSum < minimum || remainingSum > maximum) return;
|
||||
} else {
|
||||
if (digits.length - startIndex < remainingCells) return;
|
||||
let minimum = 0;
|
||||
let maximum = 0;
|
||||
for (let offset = 0; offset < remainingCells; offset += 1) {
|
||||
minimum += digits[startIndex + offset] ?? 0;
|
||||
maximum += digits[digits.length - 1 - offset] ?? 0;
|
||||
}
|
||||
if (remainingSum < minimum || remainingSum > maximum) return;
|
||||
}
|
||||
|
||||
for (let index = startIndex; index < digits.length; index += 1) {
|
||||
const digit = digits[index];
|
||||
if (digit === undefined || digit > remainingSum) break;
|
||||
current.push(digit);
|
||||
enumerate(
|
||||
allowRepeats ? index : index + 1,
|
||||
remainingCells - 1,
|
||||
remainingSum - digit,
|
||||
);
|
||||
current.pop();
|
||||
if (truncationReason !== undefined) return;
|
||||
}
|
||||
};
|
||||
enumerate(0, cellCount, target);
|
||||
|
||||
const combinations: SumLabCombination[] = [];
|
||||
const activeCombinations: SumLabCombination[] = [];
|
||||
const eliminatedCombinations: SumLabCombination[] = [];
|
||||
const assignments: number[][] = [];
|
||||
const possibleByCell = Array.from(
|
||||
{ length: cellCount },
|
||||
() => new Set<number>(),
|
||||
);
|
||||
const possibleDigits = new Set<number>();
|
||||
let necessaryDigits: Set<number> | undefined;
|
||||
|
||||
for (const raw of rawCombinations) {
|
||||
const key = sumCombinationKey(raw.digits);
|
||||
const eliminated = eliminatedKeys.has(key);
|
||||
const combination: SumLabCombination = {
|
||||
key,
|
||||
digits: raw.digits,
|
||||
eliminated,
|
||||
assignmentCount: raw.assignments.length,
|
||||
};
|
||||
combinations.push(combination);
|
||||
if (eliminated) {
|
||||
eliminatedCombinations.push(combination);
|
||||
continue;
|
||||
}
|
||||
activeCombinations.push(combination);
|
||||
const digitSet = new Set(raw.digits);
|
||||
necessaryDigits =
|
||||
necessaryDigits === undefined
|
||||
? digitSet
|
||||
: intersect(necessaryDigits, digitSet);
|
||||
for (const digit of raw.digits) possibleDigits.add(digit);
|
||||
for (const assignment of raw.assignments) {
|
||||
assignments.push(assignment);
|
||||
for (const [position, digit] of assignment.entries()) {
|
||||
possibleByCell[position]?.add(digit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
combinations,
|
||||
activeCombinations,
|
||||
eliminatedCombinations,
|
||||
assignments,
|
||||
possibleDigits: [...possibleDigits].sort((left, right) => left - right),
|
||||
necessaryDigits: [...(necessaryDigits ?? [])].sort(
|
||||
(left, right) => left - right,
|
||||
),
|
||||
possibleByCell: possibleByCell.map((entry) =>
|
||||
[...entry].sort((left, right) => left - right),
|
||||
),
|
||||
exploredNodes,
|
||||
truncated: truncationReason !== undefined,
|
||||
...(truncationReason === undefined ? {} : { truncationReason }),
|
||||
};
|
||||
}
|
||||
@@ -111,6 +111,13 @@ export function solveExact(
|
||||
"timeoutMs",
|
||||
);
|
||||
const compiled = compilePuzzle(normalized);
|
||||
const preferredSolution =
|
||||
normalized.solution !== undefined &&
|
||||
board.every(
|
||||
(value, cell) => value === 0 || value === normalized.solution?.[cell],
|
||||
)
|
||||
? normalized.solution
|
||||
: undefined;
|
||||
const random: RandomSource | undefined =
|
||||
options.seed === undefined ? undefined : seededRandom(options.seed);
|
||||
const solutions: number[][] = [];
|
||||
@@ -157,6 +164,15 @@ export function solveExact(
|
||||
const chosen = selection[0];
|
||||
let choices = selection[1];
|
||||
if (random !== undefined) choices = shuffled(choices, random);
|
||||
else {
|
||||
const preferred = preferredSolution?.[chosen];
|
||||
if (preferred !== undefined && choices.includes(preferred)) {
|
||||
choices = [
|
||||
preferred,
|
||||
...choices.filter((value) => value !== preferred),
|
||||
];
|
||||
}
|
||||
}
|
||||
for (const value of choices) {
|
||||
board[chosen] = value;
|
||||
search();
|
||||
|
||||
@@ -553,7 +553,8 @@ function findXyzWing(state: LogicalState): LogicalStep | undefined {
|
||||
function findKillerReduction(state: LogicalState): LogicalStep | undefined {
|
||||
const size = state.compiled.puzzle.size;
|
||||
for (const constraint of state.compiled.puzzle.constraints) {
|
||||
if (constraint.type !== "killer-cage") continue;
|
||||
if (constraint.type !== "killer-cage" || constraint.negated === true)
|
||||
continue;
|
||||
const empty = constraint.cells.filter((cell) => state.values[cell] === 0);
|
||||
if (empty.length === 0) continue;
|
||||
const assigned = constraint.cells
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type DifficultyOptions,
|
||||
} from "./difficulty";
|
||||
import { solveExact } from "./exact";
|
||||
import type { LogicalTechnique } from "./logical";
|
||||
import {
|
||||
generateClassic,
|
||||
minimizePuzzle,
|
||||
@@ -104,6 +105,27 @@ export type GeneratorVariant = (typeof GENERATOR_VARIANTS)[number]["id"];
|
||||
export type GenerationDifficultyTarget =
|
||||
"beginner" | "easy" | "medium" | "hard" | "expert";
|
||||
|
||||
/** Human-style techniques which can be requested as generation evidence. */
|
||||
export const PRACTICE_TECHNIQUES = [
|
||||
"naked-single",
|
||||
"hidden-single",
|
||||
"naked-pair",
|
||||
"hidden-pair",
|
||||
"pointing",
|
||||
"claiming",
|
||||
"naked-triple",
|
||||
"hidden-triple",
|
||||
"naked-quad",
|
||||
"hidden-quad",
|
||||
"x-wing",
|
||||
"xy-wing",
|
||||
"xyz-wing",
|
||||
"swordfish",
|
||||
"killer-cage",
|
||||
] as const satisfies readonly LogicalTechnique[];
|
||||
|
||||
export type PracticeTechnique = (typeof PRACTICE_TECHNIQUES)[number];
|
||||
|
||||
export interface GenerateVariantOptions {
|
||||
readonly variant?: GeneratorVariant;
|
||||
readonly size?: number;
|
||||
@@ -120,6 +142,10 @@ export interface GenerateVariantOptions {
|
||||
readonly solveMaxNodes?: number;
|
||||
readonly solveTimeoutMs?: number;
|
||||
readonly difficulty?: DifficultyOptions;
|
||||
/** Require the independently rated logical path to use this technique. */
|
||||
readonly requiredTechnique?: PracticeTechnique;
|
||||
/** Bounded deterministic attempts used to find the requested technique. */
|
||||
readonly maxTechniqueAttempts?: number;
|
||||
}
|
||||
|
||||
export interface GeneratedVariantPuzzle {
|
||||
@@ -128,6 +154,8 @@ export interface GeneratedVariantPuzzle {
|
||||
readonly variant: GeneratorVariant;
|
||||
readonly seed: string | number;
|
||||
readonly generatedConstraintCount: number;
|
||||
readonly generationAttempts: number;
|
||||
readonly requestedTechnique?: PracticeTechnique;
|
||||
}
|
||||
|
||||
const DEFAULT_CONSTRAINT_COUNTS: Readonly<
|
||||
@@ -557,7 +585,7 @@ function fullSolution(
|
||||
* Worker: every exact search has explicit node/time caps and worker termination
|
||||
* can cancel the complete operation.
|
||||
*/
|
||||
export function generateVariant(
|
||||
function generateVariantAttempt(
|
||||
options: GenerateVariantOptions = {},
|
||||
): GeneratedVariantPuzzle {
|
||||
const variant = options.variant ?? "classic";
|
||||
@@ -676,5 +704,61 @@ export function generateVariant(
|
||||
variant,
|
||||
seed,
|
||||
generatedConstraintCount: constraints.length,
|
||||
generationAttempts: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates one uniquely checked puzzle, or deterministically mines several
|
||||
* candidates until the requested human technique appears in the logical path.
|
||||
*/
|
||||
export function generateVariant(
|
||||
options: GenerateVariantOptions = {},
|
||||
): GeneratedVariantPuzzle {
|
||||
const requiredTechnique = options.requiredTechnique;
|
||||
if (
|
||||
requiredTechnique !== undefined &&
|
||||
!(PRACTICE_TECHNIQUES as readonly string[]).includes(requiredTechnique)
|
||||
) {
|
||||
throw new RangeError(
|
||||
`Unsupported practice technique: ${String(requiredTechnique)}.`,
|
||||
);
|
||||
}
|
||||
if (requiredTechnique === "killer-cage" && options.variant !== "killer") {
|
||||
throw new RangeError(
|
||||
"Killer-cage practice requires the Killer generator variant.",
|
||||
);
|
||||
}
|
||||
|
||||
const maxAttempts = boundedInteger(
|
||||
options.maxTechniqueAttempts,
|
||||
requiredTechnique === undefined ? 1 : 10,
|
||||
1,
|
||||
32,
|
||||
"maxTechniqueAttempts",
|
||||
);
|
||||
const baseSeed = options.seed ?? "sudoku-tools";
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
const seed =
|
||||
attempt === 0
|
||||
? baseSeed
|
||||
: `${String(baseSeed)}:practice:${String(requiredTechnique)}:${String(attempt + 1)}`;
|
||||
const generated = generateVariantAttempt({ ...options, seed });
|
||||
if (
|
||||
requiredTechnique === undefined ||
|
||||
(generated.difficulty.techniqueCounts[requiredTechnique] ?? 0) > 0
|
||||
) {
|
||||
return {
|
||||
...generated,
|
||||
generationAttempts: attempt + 1,
|
||||
...(requiredTechnique === undefined
|
||||
? {}
|
||||
: { requestedTechnique: requiredTechnique }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No uniquely checked puzzle using ${requiredTechnique?.replaceAll("-", " ")} was found in ${String(maxAttempts)} deterministic attempts. Try a different seed, difficulty profile, or a larger attempt limit.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import { maskValues, symbolFor, type EntryMode } from "./session";
|
||||
|
||||
export const AID_MEMOIRE_VERSION = 1 as const;
|
||||
export const MAX_AID_MEMOIRE_CELLS = 36;
|
||||
export const MAX_AID_MEMOIRE_COLUMNS = 36;
|
||||
export const MAX_AID_MEMOIRE_LABEL_LENGTH = 40;
|
||||
|
||||
export interface AidMemoireCell {
|
||||
readonly label: string;
|
||||
readonly value: number;
|
||||
readonly cornerMarks: number;
|
||||
readonly centerMarks: number;
|
||||
readonly color: number;
|
||||
}
|
||||
|
||||
export interface AidMemoireState {
|
||||
readonly version: typeof AID_MEMOIRE_VERSION;
|
||||
readonly enabled: boolean;
|
||||
readonly columns: number;
|
||||
readonly cells: readonly AidMemoireCell[];
|
||||
}
|
||||
|
||||
export interface PortableAidMemoireCell {
|
||||
readonly label: string;
|
||||
readonly value: number;
|
||||
readonly cornerMarks: readonly number[];
|
||||
readonly centerMarks: readonly number[];
|
||||
readonly color: number;
|
||||
}
|
||||
|
||||
export interface PortableAidMemoire {
|
||||
readonly version: typeof AID_MEMOIRE_VERSION;
|
||||
readonly enabled: boolean;
|
||||
readonly columns: number;
|
||||
readonly cells: readonly PortableAidMemoireCell[];
|
||||
}
|
||||
|
||||
function integer(
|
||||
value: unknown,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
if (
|
||||
!Number.isInteger(value) ||
|
||||
(value as number) < minimum ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
throw new TypeError(
|
||||
`${label} must be an integer from ${String(minimum)} to ${String(maximum)}.`,
|
||||
);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function defaultLabel(index: number): string {
|
||||
return String(index + 1);
|
||||
}
|
||||
|
||||
function emptyCell(index: number): AidMemoireCell {
|
||||
return {
|
||||
label: defaultLabel(index),
|
||||
value: 0,
|
||||
cornerMarks: 0,
|
||||
centerMarks: 0,
|
||||
color: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneCell(cell: AidMemoireCell): AidMemoireCell {
|
||||
return { ...cell };
|
||||
}
|
||||
|
||||
function normalizedCount(size: number, count?: number): number {
|
||||
const requested = Number.isFinite(count)
|
||||
? Math.trunc(count as number)
|
||||
: Math.min(size, 16);
|
||||
return Math.max(1, Math.min(MAX_AID_MEMOIRE_CELLS, requested));
|
||||
}
|
||||
|
||||
export function createAidMemoire(
|
||||
size: number,
|
||||
options: {
|
||||
readonly enabled?: boolean;
|
||||
readonly cellCount?: number;
|
||||
readonly columns?: number;
|
||||
} = {},
|
||||
): AidMemoireState {
|
||||
const count = normalizedCount(size, options.cellCount);
|
||||
const columns = Math.max(
|
||||
1,
|
||||
Math.min(MAX_AID_MEMOIRE_COLUMNS, count, options.columns ?? count),
|
||||
);
|
||||
return {
|
||||
version: AID_MEMOIRE_VERSION,
|
||||
enabled: options.enabled ?? false,
|
||||
columns,
|
||||
cells: Array.from({ length: count }, (_, index) => emptyCell(index)),
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneAidMemoire(state: AidMemoireState): AidMemoireState {
|
||||
return { ...state, cells: state.cells.map(cloneCell) };
|
||||
}
|
||||
|
||||
export function setAidMemoireEnabled(
|
||||
state: AidMemoireState,
|
||||
enabled: boolean,
|
||||
): AidMemoireState {
|
||||
return { ...state, enabled };
|
||||
}
|
||||
|
||||
export function configureAidMemoire(
|
||||
state: AidMemoireState,
|
||||
size: number,
|
||||
cellCount: number,
|
||||
columns: number,
|
||||
): AidMemoireState {
|
||||
const count = normalizedCount(size, cellCount);
|
||||
const nextColumns = Math.max(
|
||||
1,
|
||||
Math.min(MAX_AID_MEMOIRE_COLUMNS, count, Math.trunc(columns) || 1),
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
columns: nextColumns,
|
||||
cells: Array.from({ length: count }, (_, index) =>
|
||||
state.cells[index] ? cloneCell(state.cells[index]) : emptyCell(index),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function compactAidMemoireColumns(cellCount: number): number {
|
||||
const count = Number.isFinite(cellCount)
|
||||
? Math.max(1, Math.min(MAX_AID_MEMOIRE_CELLS, Math.trunc(cellCount)))
|
||||
: 1;
|
||||
return Math.ceil(Math.sqrt(count));
|
||||
}
|
||||
|
||||
export function labelAidMemoireCell(
|
||||
state: AidMemoireState,
|
||||
cell: number,
|
||||
label: string,
|
||||
): AidMemoireState {
|
||||
if (cell < 0 || cell >= state.cells.length) return state;
|
||||
const cells = state.cells.map(cloneCell);
|
||||
cells[cell] = {
|
||||
...cells[cell]!,
|
||||
label: label.slice(0, MAX_AID_MEMOIRE_LABEL_LENGTH),
|
||||
};
|
||||
return { ...state, cells };
|
||||
}
|
||||
|
||||
function toggleMask(mask: number, value: number): number {
|
||||
const bit = 1 << (value - 1);
|
||||
return mask & bit ? mask & ~bit : mask | bit;
|
||||
}
|
||||
|
||||
export function enterAidMemoireCell(
|
||||
state: AidMemoireState,
|
||||
cell: number,
|
||||
mode: EntryMode,
|
||||
value: number,
|
||||
size: number,
|
||||
): AidMemoireState {
|
||||
if (cell < 0 || cell >= state.cells.length) return state;
|
||||
if (!Number.isInteger(value)) return state;
|
||||
if (mode !== "color" && (value < 1 || value > size)) return state;
|
||||
if (mode === "color" && (value < 1 || value > 8)) return state;
|
||||
const cells = state.cells.map(cloneCell);
|
||||
const current = cells[cell]!;
|
||||
if (mode === "value") {
|
||||
cells[cell] = {
|
||||
...current,
|
||||
value: current.value === value ? 0 : value,
|
||||
cornerMarks: 0,
|
||||
centerMarks: 0,
|
||||
};
|
||||
} else if (mode === "corner") {
|
||||
if (current.value !== 0) return state;
|
||||
cells[cell] = {
|
||||
...current,
|
||||
cornerMarks: toggleMask(current.cornerMarks, value),
|
||||
};
|
||||
} else if (mode === "center") {
|
||||
if (current.value !== 0) return state;
|
||||
cells[cell] = {
|
||||
...current,
|
||||
centerMarks: toggleMask(current.centerMarks, value),
|
||||
};
|
||||
} else {
|
||||
cells[cell] = {
|
||||
...current,
|
||||
color: current.color === value ? 0 : value,
|
||||
};
|
||||
}
|
||||
return { ...state, cells };
|
||||
}
|
||||
|
||||
export function eraseAidMemoireCell(
|
||||
state: AidMemoireState,
|
||||
cell: number,
|
||||
mode: EntryMode,
|
||||
): AidMemoireState {
|
||||
if (cell < 0 || cell >= state.cells.length) return state;
|
||||
const cells = state.cells.map(cloneCell);
|
||||
const current = cells[cell]!;
|
||||
cells[cell] = {
|
||||
...current,
|
||||
...(mode === "value" ? { value: 0 } : {}),
|
||||
...(mode === "corner" ? { cornerMarks: 0 } : {}),
|
||||
...(mode === "center" ? { centerMarks: 0 } : {}),
|
||||
...(mode === "color" ? { color: 0 } : {}),
|
||||
};
|
||||
return { ...state, cells };
|
||||
}
|
||||
|
||||
export function clearAidMemoireEntries(
|
||||
state: AidMemoireState,
|
||||
): AidMemoireState {
|
||||
return {
|
||||
...state,
|
||||
cells: state.cells.map((cell) => ({
|
||||
...cell,
|
||||
value: 0,
|
||||
cornerMarks: 0,
|
||||
centerMarks: 0,
|
||||
color: 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function resetAidMemoire(
|
||||
state: AidMemoireState,
|
||||
size: number,
|
||||
): AidMemoireState {
|
||||
return createAidMemoire(size, { enabled: state.enabled });
|
||||
}
|
||||
|
||||
export function aidMemoireCellDescription(
|
||||
cell: AidMemoireCell,
|
||||
index: number,
|
||||
size: number,
|
||||
): string {
|
||||
const parts = [
|
||||
`Aid-mémoire cell ${String(index + 1)}`,
|
||||
cell.label ? `label ${cell.label}` : "unlabelled",
|
||||
];
|
||||
if (cell.value) parts.push(`value ${symbolFor(cell.value, size)}`);
|
||||
else parts.push("empty");
|
||||
const corner = maskValues(cell.cornerMarks, size);
|
||||
const center = maskValues(cell.centerMarks, size);
|
||||
if (corner.length) {
|
||||
parts.push(
|
||||
`corner marks ${corner.map((value) => symbolFor(value, size)).join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (center.length) {
|
||||
parts.push(
|
||||
`centre marks ${center.map((value) => symbolFor(value, size)).join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (cell.color) parts.push(`colour ${String(cell.color)}`);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function portableMarks(value: unknown, label: string, size: number): number[] {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || value.length > size) {
|
||||
throw new TypeError(
|
||||
`${label} must contain at most ${String(size)} digits.`,
|
||||
);
|
||||
}
|
||||
const values = value.map((entry, index) =>
|
||||
integer(entry, `${label}[${String(index)}]`, 1, size),
|
||||
);
|
||||
return [...new Set(values)].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function normalizePortableAidMemoire(
|
||||
value: unknown,
|
||||
size: number,
|
||||
): PortableAidMemoire {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new TypeError("aidMemoire must be an object.");
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.version !== AID_MEMOIRE_VERSION) {
|
||||
throw new TypeError("aidMemoire has an unsupported version.");
|
||||
}
|
||||
if (typeof record.enabled !== "boolean") {
|
||||
throw new TypeError("aidMemoire.enabled must be true or false.");
|
||||
}
|
||||
if (
|
||||
!Array.isArray(record.cells) ||
|
||||
record.cells.length < 1 ||
|
||||
record.cells.length > MAX_AID_MEMOIRE_CELLS
|
||||
) {
|
||||
throw new TypeError(
|
||||
`aidMemoire.cells must contain 1 to ${String(MAX_AID_MEMOIRE_CELLS)} entries.`,
|
||||
);
|
||||
}
|
||||
const columns = integer(
|
||||
record.columns,
|
||||
"aidMemoire.columns",
|
||||
1,
|
||||
Math.min(MAX_AID_MEMOIRE_COLUMNS, record.cells.length),
|
||||
);
|
||||
const cells = record.cells.map((rawCell, index): PortableAidMemoireCell => {
|
||||
if (
|
||||
typeof rawCell !== "object" ||
|
||||
rawCell === null ||
|
||||
Array.isArray(rawCell)
|
||||
) {
|
||||
throw new TypeError(
|
||||
`aidMemoire.cells[${String(index)}] must be an object.`,
|
||||
);
|
||||
}
|
||||
const cell = rawCell as Record<string, unknown>;
|
||||
if (
|
||||
cell.label !== undefined &&
|
||||
(typeof cell.label !== "string" ||
|
||||
cell.label.length > MAX_AID_MEMOIRE_LABEL_LENGTH)
|
||||
) {
|
||||
throw new TypeError(
|
||||
`aidMemoire.cells[${String(index)}].label is invalid.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
label: (cell.label as string | undefined) ?? defaultLabel(index),
|
||||
value: integer(
|
||||
cell.value ?? 0,
|
||||
`aidMemoire.cells[${String(index)}].value`,
|
||||
0,
|
||||
size,
|
||||
),
|
||||
cornerMarks: portableMarks(
|
||||
cell.cornerMarks,
|
||||
`aidMemoire.cells[${String(index)}].cornerMarks`,
|
||||
size,
|
||||
),
|
||||
centerMarks: portableMarks(
|
||||
cell.centerMarks,
|
||||
`aidMemoire.cells[${String(index)}].centerMarks`,
|
||||
size,
|
||||
),
|
||||
color: integer(
|
||||
cell.color ?? 0,
|
||||
`aidMemoire.cells[${String(index)}].color`,
|
||||
0,
|
||||
8,
|
||||
),
|
||||
};
|
||||
});
|
||||
return {
|
||||
version: AID_MEMOIRE_VERSION,
|
||||
enabled: record.enabled,
|
||||
columns,
|
||||
cells,
|
||||
};
|
||||
}
|
||||
|
||||
export function aidMemoireToPortable(
|
||||
state: AidMemoireState,
|
||||
size: number,
|
||||
): PortableAidMemoire {
|
||||
return normalizePortableAidMemoire(
|
||||
{
|
||||
version: AID_MEMOIRE_VERSION,
|
||||
enabled: state.enabled,
|
||||
columns: state.columns,
|
||||
cells: state.cells.map((cell) => ({
|
||||
label: cell.label,
|
||||
value: cell.value,
|
||||
cornerMarks: maskValues(cell.cornerMarks, size),
|
||||
centerMarks: maskValues(cell.centerMarks, size),
|
||||
color: cell.color,
|
||||
})),
|
||||
},
|
||||
size,
|
||||
);
|
||||
}
|
||||
|
||||
export function aidMemoireFromPortable(
|
||||
value: unknown,
|
||||
size: number,
|
||||
): AidMemoireState {
|
||||
if (value === undefined) return createAidMemoire(size);
|
||||
const portable = normalizePortableAidMemoire(value, size);
|
||||
const mask = (values: readonly number[]) =>
|
||||
values.reduce((result, digit) => result | (1 << (digit - 1)), 0);
|
||||
return {
|
||||
version: AID_MEMOIRE_VERSION,
|
||||
enabled: portable.enabled,
|
||||
columns: portable.columns,
|
||||
cells: portable.cells.map((cell) => ({
|
||||
label: cell.label,
|
||||
value: cell.value,
|
||||
cornerMarks: mask(cell.cornerMarks),
|
||||
centerMarks: mask(cell.centerMarks),
|
||||
color: cell.color,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export type GridNavigationKey =
|
||||
| "ArrowUp"
|
||||
| "ArrowDown"
|
||||
| "ArrowLeft"
|
||||
| "ArrowRight"
|
||||
| "Home"
|
||||
| "End"
|
||||
| "PageUp"
|
||||
| "PageDown";
|
||||
|
||||
/**
|
||||
* Resolve WAI-ARIA grid navigation without wrapping at an edge. Home/End move
|
||||
* within a row; Ctrl/Command+Home/End move to the first/last cell. Page keys
|
||||
* retain the column while moving to the first/last row.
|
||||
*/
|
||||
export function navigateGridCell(
|
||||
activeCell: number,
|
||||
size: number,
|
||||
key: string,
|
||||
command = false,
|
||||
): number | null {
|
||||
if (!Number.isInteger(size) || size < 1 || size > 64) {
|
||||
throw new RangeError("size must be an integer from 1 to 64");
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(activeCell) ||
|
||||
activeCell < 0 ||
|
||||
activeCell >= size * size
|
||||
) {
|
||||
throw new RangeError("activeCell is outside the grid");
|
||||
}
|
||||
const row = Math.floor(activeCell / size);
|
||||
const column = activeCell % size;
|
||||
switch (key as GridNavigationKey) {
|
||||
case "ArrowUp":
|
||||
return Math.max(0, row - 1) * size + column;
|
||||
case "ArrowDown":
|
||||
return Math.min(size - 1, row + 1) * size + column;
|
||||
case "ArrowLeft":
|
||||
return row * size + Math.max(0, column - 1);
|
||||
case "ArrowRight":
|
||||
return row * size + Math.min(size - 1, column + 1);
|
||||
case "Home":
|
||||
return command ? 0 : row * size;
|
||||
case "End":
|
||||
return command ? size * size - 1 : row * size + size - 1;
|
||||
case "PageUp":
|
||||
return column;
|
||||
case "PageDown":
|
||||
return (size - 1) * size + column;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import {
|
||||
snapshotSession,
|
||||
type PlaySession,
|
||||
type PlaySnapshot,
|
||||
} from "./session";
|
||||
import {
|
||||
cloneAidMemoire,
|
||||
createAidMemoire,
|
||||
type AidMemoireState,
|
||||
} from "./aidMemoire";
|
||||
|
||||
export type HypothesisStatus = "active" | "kept" | "discarded";
|
||||
|
||||
export interface GameplayState extends PlaySnapshot {
|
||||
readonly elapsedSeconds: number;
|
||||
readonly aidMemoire?: AidMemoireState;
|
||||
}
|
||||
|
||||
export interface GameplayMoment {
|
||||
readonly id: string;
|
||||
readonly sequence: number;
|
||||
readonly branchId: string;
|
||||
readonly label: string;
|
||||
readonly state: GameplayState;
|
||||
}
|
||||
|
||||
export interface HypothesisBranch {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly parentBranchId: string;
|
||||
readonly baseMomentId: string;
|
||||
readonly baseState: GameplayState;
|
||||
readonly status: HypothesisStatus;
|
||||
}
|
||||
|
||||
export interface NamedSavepoint {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly momentId: string;
|
||||
readonly branchId: string;
|
||||
readonly state: GameplayState;
|
||||
}
|
||||
|
||||
export interface GameplayHistory {
|
||||
readonly moments: readonly GameplayMoment[];
|
||||
readonly branches: readonly HypothesisBranch[];
|
||||
readonly savepoints: readonly NamedSavepoint[];
|
||||
readonly activeBranchId: string;
|
||||
readonly currentMomentId: string;
|
||||
readonly nextSequence: number;
|
||||
}
|
||||
|
||||
export interface HistoryTransition {
|
||||
readonly history: GameplayHistory;
|
||||
readonly state: GameplayState;
|
||||
}
|
||||
|
||||
export const MAIN_BRANCH_ID = "main";
|
||||
export const MAX_GAMEPLAY_MOMENTS = 500;
|
||||
|
||||
function cloneState(state: GameplayState): GameplayState {
|
||||
return {
|
||||
values: [...state.values],
|
||||
cornerMarks: [...state.cornerMarks],
|
||||
centerMarks: [...state.centerMarks],
|
||||
colors: [...state.colors],
|
||||
elapsedSeconds: state.elapsedSeconds,
|
||||
...(state.aidMemoire === undefined
|
||||
? {}
|
||||
: { aidMemoire: cloneAidMemoire(state.aidMemoire) }),
|
||||
};
|
||||
}
|
||||
|
||||
function requireName(value: string, kind: string): string {
|
||||
const name = value.trim();
|
||||
if (!name) throw new Error(`${kind} name cannot be empty.`);
|
||||
return name.slice(0, 80);
|
||||
}
|
||||
|
||||
function appendMoment(
|
||||
history: GameplayHistory,
|
||||
state: GameplayState,
|
||||
label: string,
|
||||
branchId = history.activeBranchId,
|
||||
): GameplayHistory {
|
||||
const sequence = history.nextSequence;
|
||||
const moment: GameplayMoment = {
|
||||
id: `moment-${String(sequence)}`,
|
||||
sequence,
|
||||
branchId,
|
||||
label,
|
||||
state: cloneState(state),
|
||||
};
|
||||
return {
|
||||
...history,
|
||||
moments: [...history.moments, moment].slice(-MAX_GAMEPLAY_MOMENTS),
|
||||
activeBranchId: branchId,
|
||||
currentMomentId: moment.id,
|
||||
nextSequence: sequence + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function captureGameplayState(
|
||||
session: PlaySession,
|
||||
aidMemoire?: AidMemoireState,
|
||||
): GameplayState {
|
||||
return {
|
||||
...snapshotSession(session),
|
||||
elapsedSeconds: session.elapsedSeconds,
|
||||
...(aidMemoire === undefined
|
||||
? {}
|
||||
: { aidMemoire: cloneAidMemoire(aidMemoire) }),
|
||||
};
|
||||
}
|
||||
|
||||
export function sessionFromGameplayState(
|
||||
state: GameplayState,
|
||||
paused = false,
|
||||
): PlaySession {
|
||||
return {
|
||||
values: [...state.values],
|
||||
cornerMarks: [...state.cornerMarks],
|
||||
centerMarks: [...state.centerMarks],
|
||||
colors: [...state.colors],
|
||||
elapsedSeconds: state.elapsedSeconds,
|
||||
paused,
|
||||
};
|
||||
}
|
||||
|
||||
export function aidMemoireFromGameplayState(
|
||||
state: GameplayState,
|
||||
size: number,
|
||||
): AidMemoireState {
|
||||
return state.aidMemoire === undefined
|
||||
? createAidMemoire(size)
|
||||
: cloneAidMemoire(state.aidMemoire);
|
||||
}
|
||||
|
||||
export function createGameplayHistory(
|
||||
session: PlaySession,
|
||||
aidMemoire?: AidMemoireState,
|
||||
): GameplayHistory {
|
||||
const initial: GameplayMoment = {
|
||||
id: "moment-0",
|
||||
sequence: 0,
|
||||
branchId: MAIN_BRANCH_ID,
|
||||
label: "Puzzle opened",
|
||||
state: captureGameplayState(session, aidMemoire),
|
||||
};
|
||||
return {
|
||||
moments: [initial],
|
||||
branches: [],
|
||||
savepoints: [],
|
||||
activeBranchId: MAIN_BRANCH_ID,
|
||||
currentMomentId: initial.id,
|
||||
nextSequence: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function recordGameplayMoment(
|
||||
history: GameplayHistory,
|
||||
session: PlaySession,
|
||||
label: string,
|
||||
aidMemoire?: AidMemoireState,
|
||||
): GameplayHistory {
|
||||
const trimmedLabel = label.trim() || "Grid updated";
|
||||
return appendMoment(
|
||||
history,
|
||||
captureGameplayState(session, aidMemoire),
|
||||
trimmedLabel,
|
||||
);
|
||||
}
|
||||
|
||||
export function gameplayMoment(
|
||||
history: GameplayHistory,
|
||||
momentId: string,
|
||||
): GameplayMoment | undefined {
|
||||
return history.moments.find((moment) => moment.id === momentId);
|
||||
}
|
||||
|
||||
export function activeHypothesis(
|
||||
history: GameplayHistory,
|
||||
): HypothesisBranch | undefined {
|
||||
return history.branches.find(
|
||||
(branch) =>
|
||||
branch.id === history.activeBranchId && branch.status === "active",
|
||||
);
|
||||
}
|
||||
|
||||
export function beginHypothesis(
|
||||
history: GameplayHistory,
|
||||
session: PlaySession,
|
||||
nameValue: string,
|
||||
fromMomentId = history.currentMomentId,
|
||||
aidMemoire?: AidMemoireState,
|
||||
): HistoryTransition {
|
||||
if (activeHypothesis(history) !== undefined) {
|
||||
throw new Error("Finish the active hypothesis before starting another.");
|
||||
}
|
||||
const name = requireName(nameValue, "Hypothesis");
|
||||
const source = gameplayMoment(history, fromMomentId);
|
||||
if (source === undefined)
|
||||
throw new Error("The replay moment is unavailable.");
|
||||
|
||||
const baseState =
|
||||
fromMomentId === history.currentMomentId
|
||||
? captureGameplayState(session, aidMemoire)
|
||||
: cloneState(source.state);
|
||||
const sequence = history.nextSequence;
|
||||
const branchId = `branch-${String(sequence)}`;
|
||||
const branch: HypothesisBranch = {
|
||||
id: branchId,
|
||||
name,
|
||||
parentBranchId: history.activeBranchId,
|
||||
baseMomentId: source.id,
|
||||
baseState: cloneState(baseState),
|
||||
status: "active",
|
||||
};
|
||||
const withBranch: GameplayHistory = {
|
||||
...history,
|
||||
branches: [...history.branches, branch],
|
||||
activeBranchId: branchId,
|
||||
currentMomentId: source.id,
|
||||
};
|
||||
return {
|
||||
history: appendMoment(
|
||||
withBranch,
|
||||
baseState,
|
||||
`Started hypothesis “${name}”`,
|
||||
branchId,
|
||||
),
|
||||
state: cloneState(baseState),
|
||||
};
|
||||
}
|
||||
|
||||
export function finishHypothesis(
|
||||
history: GameplayHistory,
|
||||
session: PlaySession,
|
||||
decision: "keep" | "discard",
|
||||
aidMemoire?: AidMemoireState,
|
||||
): HistoryTransition {
|
||||
const branch = activeHypothesis(history);
|
||||
if (branch === undefined) throw new Error("No hypothesis is active.");
|
||||
const state =
|
||||
decision === "keep"
|
||||
? captureGameplayState(session, aidMemoire)
|
||||
: cloneState(branch.baseState);
|
||||
const status: HypothesisStatus = decision === "keep" ? "kept" : "discarded";
|
||||
const updated: GameplayHistory = {
|
||||
...history,
|
||||
branches: history.branches.map((candidate) =>
|
||||
candidate.id === branch.id ? { ...candidate, status } : candidate,
|
||||
),
|
||||
activeBranchId: branch.parentBranchId,
|
||||
currentMomentId: branch.baseMomentId,
|
||||
};
|
||||
const verb = decision === "keep" ? "Kept" : "Discarded";
|
||||
return {
|
||||
history: appendMoment(
|
||||
updated,
|
||||
state,
|
||||
`${verb} hypothesis “${branch.name}”`,
|
||||
branch.parentBranchId,
|
||||
),
|
||||
state: cloneState(state),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSavepoint(
|
||||
history: GameplayHistory,
|
||||
session: PlaySession,
|
||||
nameValue: string,
|
||||
aidMemoire?: AidMemoireState,
|
||||
): GameplayHistory {
|
||||
const name = requireName(nameValue, "Savepoint");
|
||||
const state = captureGameplayState(session, aidMemoire);
|
||||
const withMoment = appendMoment(history, state, `Saved “${name}”`);
|
||||
const savepoint: NamedSavepoint = {
|
||||
id: `savepoint-${String(withMoment.nextSequence)}`,
|
||||
name,
|
||||
momentId: withMoment.currentMomentId,
|
||||
branchId: withMoment.activeBranchId,
|
||||
state: cloneState(state),
|
||||
};
|
||||
return {
|
||||
...withMoment,
|
||||
savepoints: [...withMoment.savepoints, savepoint],
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteSavepoint(
|
||||
history: GameplayHistory,
|
||||
savepointId: string,
|
||||
): GameplayHistory {
|
||||
return {
|
||||
...history,
|
||||
savepoints: history.savepoints.filter(
|
||||
(savepoint) => savepoint.id !== savepointId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function restoreSavepoint(
|
||||
history: GameplayHistory,
|
||||
savepointId: string,
|
||||
): HistoryTransition {
|
||||
const savepoint = history.savepoints.find(
|
||||
(candidate) => candidate.id === savepointId,
|
||||
);
|
||||
if (savepoint === undefined) throw new Error("The savepoint is unavailable.");
|
||||
const state = cloneState(savepoint.state);
|
||||
return {
|
||||
history: appendMoment(history, state, `Restored “${savepoint.name}”`),
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
export function describeGameplayChange(
|
||||
before: PlaySession,
|
||||
after: PlaySession,
|
||||
size: number,
|
||||
): string {
|
||||
const changedValues: number[] = [];
|
||||
const changedNotes: number[] = [];
|
||||
const changedColors: number[] = [];
|
||||
for (let cell = 0; cell < after.values.length; cell += 1) {
|
||||
if (before.values[cell] !== after.values[cell]) changedValues.push(cell);
|
||||
if (
|
||||
before.cornerMarks[cell] !== after.cornerMarks[cell] ||
|
||||
before.centerMarks[cell] !== after.centerMarks[cell]
|
||||
) {
|
||||
changedNotes.push(cell);
|
||||
}
|
||||
if (before.colors[cell] !== after.colors[cell]) changedColors.push(cell);
|
||||
}
|
||||
const cells = new Set([...changedValues, ...changedNotes, ...changedColors]);
|
||||
if (cells.size !== 1) {
|
||||
return cells.size === 0
|
||||
? "Grid updated"
|
||||
: `Updated ${String(cells.size)} cells`;
|
||||
}
|
||||
const cell = [...cells][0]!;
|
||||
const label = `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
|
||||
if (changedValues.length) {
|
||||
const value = after.values[cell] ?? 0;
|
||||
return value === 0
|
||||
? `Cleared ${label}`
|
||||
: `Set ${label} to ${String(value)}`;
|
||||
}
|
||||
if (changedNotes.length) return `Changed notes in ${label}`;
|
||||
return `Changed colour in ${label}`;
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
cloneSudokuDocument,
|
||||
normalizeSudokuDocument,
|
||||
} from "../formats";
|
||||
import {
|
||||
normalizePortableAidMemoire,
|
||||
type PortableAidMemoire,
|
||||
} from "../state/aidMemoire";
|
||||
import {
|
||||
PROJECT_RECORD_SCHEMA,
|
||||
PROJECT_RECORD_VERSION,
|
||||
@@ -126,6 +130,19 @@ function progress(value: unknown, size: number): SudokuProgress | undefined {
|
||||
if (value.completed !== undefined && typeof value.completed !== "boolean") {
|
||||
return storageError("INVALID_PROJECT", "completed must be true or false.");
|
||||
}
|
||||
let aidMemoire: PortableAidMemoire | undefined;
|
||||
if (value.aidMemoire !== undefined) {
|
||||
try {
|
||||
aidMemoire = normalizePortableAidMemoire(value.aidMemoire, size);
|
||||
} catch (error) {
|
||||
return storageError(
|
||||
"INVALID_PROJECT",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Progress aid-mémoire data is invalid.",
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
values,
|
||||
@@ -137,6 +154,7 @@ function progress(value: unknown, size: number): SudokuProgress | undefined {
|
||||
? {}
|
||||
: { elapsedMs: value.elapsedMs as number }),
|
||||
...(value.completed === undefined ? {} : { completed: value.completed }),
|
||||
...(aidMemoire === undefined ? {} : { aidMemoire }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -274,6 +292,14 @@ export function cloneProjectRecord(
|
||||
...entry,
|
||||
]),
|
||||
}),
|
||||
...(normalized.progress.aidMemoire === undefined
|
||||
? {}
|
||||
: {
|
||||
aidMemoire: normalizePortableAidMemoire(
|
||||
normalized.progress.aidMemoire,
|
||||
normalized.puzzle.size,
|
||||
),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SudokuDocument } from "../formats";
|
||||
import type { PortableAidMemoire, SudokuDocument } from "../formats";
|
||||
|
||||
export const PROJECT_RECORD_SCHEMA =
|
||||
"de.add-ideas.sudoku-tools.project" as const;
|
||||
@@ -14,6 +14,7 @@ export interface SudokuProgress {
|
||||
readonly candidates?: readonly (readonly number[])[];
|
||||
readonly elapsedMs?: number;
|
||||
readonly completed?: boolean;
|
||||
readonly aidMemoire?: PortableAidMemoire;
|
||||
}
|
||||
|
||||
export interface SudokuProjectRecord {
|
||||
|
||||
+858
-12
@@ -644,6 +644,17 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
|
||||
filter: saturate(0.6);
|
||||
}
|
||||
|
||||
.board-surface.is-replay {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-accent) 58%,
|
||||
var(--toolbox-border)
|
||||
);
|
||||
box-shadow:
|
||||
var(--toolbox-shadow),
|
||||
0 0 0 3px color-mix(in srgb, var(--toolbox-accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.digit-completion {
|
||||
width: min(100%, 43rem);
|
||||
display: grid;
|
||||
@@ -738,6 +749,205 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
|
||||
color-mix(in srgb, var(--toolbox-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
/* Optional aid-mémoire scratch cells */
|
||||
|
||||
.aid-memoire {
|
||||
width: min(100%, 45rem);
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
margin-inline: auto;
|
||||
padding: clamp(0.72rem, 1.5vw, 0.95rem);
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.78rem;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-accent-soft) 22%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
}
|
||||
|
||||
.aid-memoire__heading h2 {
|
||||
margin-top: 0.15rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.aid-memoire__heading p.muted {
|
||||
max-width: 58ch;
|
||||
margin-top: 0.28rem;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.aid-memoire__scroller {
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
padding: 0.18rem;
|
||||
overscroll-behavior-inline: contain;
|
||||
scrollbar-color: var(--toolbox-border) transparent;
|
||||
}
|
||||
|
||||
.aid-memoire__grid {
|
||||
width: max-content;
|
||||
min-width: min(100%, calc(var(--aid-columns) * 3.25rem));
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--aid-columns), minmax(3rem, 1fr));
|
||||
overflow: hidden;
|
||||
border: 1.5px solid var(--sudoku-line-strong);
|
||||
border-radius: 0.42rem;
|
||||
background: var(--sudoku-line);
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.aid-memoire__row {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.aid-memoire__cell {
|
||||
--cell-fill: var(--toolbox-surface);
|
||||
position: relative;
|
||||
width: auto;
|
||||
min-width: 3rem !important;
|
||||
min-height: 3.35rem !important;
|
||||
overflow: hidden;
|
||||
padding: 0 !important;
|
||||
border: 0.5px solid var(--sudoku-line) !important;
|
||||
border-radius: 0 !important;
|
||||
outline-offset: -3px !important;
|
||||
background: var(--cell-fill) !important;
|
||||
box-shadow: none !important;
|
||||
color: var(--toolbox-accent) !important;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.aid-memoire__cell[class*="has-color-"] {
|
||||
--cell-fill: color-mix(
|
||||
in srgb,
|
||||
var(--sudoku-mark-color) 24%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
}
|
||||
|
||||
.aid-memoire__cell.is-selected {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-accent) 20%,
|
||||
var(--cell-fill)
|
||||
) !important;
|
||||
}
|
||||
|
||||
.aid-memoire__cell.is-selected::after,
|
||||
.aid-memoire__cell:focus-visible::after {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
inset: 2px;
|
||||
border: 2px solid var(--toolbox-accent);
|
||||
border-radius: 2px;
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.aid-memoire__cell:focus-visible {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.aid-memoire__cell:focus-visible::after {
|
||||
border-color: var(--toolbox-focus);
|
||||
}
|
||||
|
||||
.aid-memoire__label,
|
||||
.aid-memoire__value,
|
||||
.aid-memoire__corner,
|
||||
.aid-memoire__centre {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.aid-memoire__label {
|
||||
top: 0.17rem;
|
||||
left: 0.23rem;
|
||||
max-width: calc(100% - 0.46rem);
|
||||
overflow: hidden;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.52rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.aid-memoire__value {
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding-top: 0.34rem;
|
||||
font-size: 1.38rem;
|
||||
font-weight: 680;
|
||||
}
|
||||
|
||||
.aid-memoire__corner {
|
||||
top: 0.92rem;
|
||||
right: 0.22rem;
|
||||
left: 0.22rem;
|
||||
overflow: hidden;
|
||||
color: color-mix(in srgb, var(--toolbox-text) 77%, var(--toolbox-muted));
|
||||
font-size: 0.54rem;
|
||||
font-weight: 720;
|
||||
line-height: 1;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.aid-memoire__centre {
|
||||
right: 0.2rem;
|
||||
bottom: 0.36rem;
|
||||
left: 0.2rem;
|
||||
overflow: hidden;
|
||||
color: color-mix(in srgb, var(--toolbox-text) 72%, var(--toolbox-muted));
|
||||
font-size: 0.62rem;
|
||||
font-weight: 680;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.aid-memoire__modes {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.aid-memoire__selected {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10rem, 0.55fr) minmax(13rem, 1fr);
|
||||
align-items: end;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.aid-memoire__selected .muted {
|
||||
padding-bottom: 0.2rem;
|
||||
font-size: 0.73rem;
|
||||
}
|
||||
|
||||
.aid-memoire__settings {
|
||||
border-top: 1px solid var(--toolbox-border);
|
||||
padding-top: 0.58rem;
|
||||
}
|
||||
|
||||
.aid-memoire__settings summary {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.aid-memoire__settings-body {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
padding-top: 0.7rem;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
padding: clamp(0.85rem, 1.7vw, 1.2rem);
|
||||
}
|
||||
@@ -896,6 +1106,162 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.history-dialog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(17rem, 0.72fr) minmax(22rem, 1.28fr);
|
||||
align-items: start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.history-section h3 {
|
||||
margin-top: 0.18rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.history-name-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.history-name-form label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hypothesis-active {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
padding: 0.72rem;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--toolbox-accent) 38%, var(--toolbox-border));
|
||||
border-radius: 0.62rem;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-accent-soft) 58%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
}
|
||||
|
||||
.hypothesis-active > div:first-child,
|
||||
.savepoint-list li > div:first-child {
|
||||
display: grid;
|
||||
gap: 0.16rem;
|
||||
}
|
||||
|
||||
.hypothesis-active span,
|
||||
.savepoint-list span,
|
||||
.history-step-copy > span {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.73rem;
|
||||
}
|
||||
|
||||
.branch-list,
|
||||
.savepoint-list,
|
||||
.history-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.branch-list,
|
||||
.savepoint-list {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.branch-list li,
|
||||
.savepoint-list li {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.55rem;
|
||||
padding: 0.5rem 0.58rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.55rem;
|
||||
background: var(--toolbox-surface);
|
||||
}
|
||||
|
||||
.branch-status {
|
||||
padding: 0.18rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--toolbox-surface-soft);
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 760;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.branch-status--active,
|
||||
.branch-status--kept {
|
||||
background: var(--toolbox-accent-soft);
|
||||
color: var(--toolbox-accent);
|
||||
}
|
||||
|
||||
.branch-status--discarded {
|
||||
color: var(--toolbox-danger);
|
||||
}
|
||||
|
||||
.history-replay {
|
||||
max-height: min(67vh, 46rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.history-replay-controls {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
min-height: 8rem;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.history-list button {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 2.2rem minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.48rem 0.58rem;
|
||||
border-color: var(--toolbox-border);
|
||||
border-radius: 0.58rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.history-list button.is-current {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent-soft);
|
||||
}
|
||||
|
||||
.history-step-number {
|
||||
color: var(--toolbox-accent);
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 760;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.history-step-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.history-step-copy strong,
|
||||
.history-step-copy span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.generator-form {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
@@ -1011,11 +1377,16 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.sudoku-board-frame.has-outside-clues {
|
||||
--board-pad: clamp(1.75rem, 4.8vw, 2.55rem);
|
||||
width: min(100%, 48rem);
|
||||
}
|
||||
|
||||
.sudoku-board {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--sudoku-size), minmax(0, 1fr));
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: repeat(var(--sudoku-size), minmax(0, 1fr));
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
@@ -1026,6 +1397,12 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.sudoku-row {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--sudoku-size), minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sudoku-cell {
|
||||
--cell-fill: var(--toolbox-surface);
|
||||
position: relative;
|
||||
@@ -1067,6 +1444,14 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
|
||||
) !important;
|
||||
}
|
||||
|
||||
.sudoku-cell.is-candidate-highlighted {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-focus) 18%,
|
||||
var(--cell-fill)
|
||||
) !important;
|
||||
}
|
||||
|
||||
.sudoku-cell.is-selected {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
@@ -1189,7 +1574,51 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
|
||||
shape-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
.constraint-layer :where(path, polyline, line, circle) {
|
||||
.candidate-link-layer {
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
top: var(--board-pad);
|
||||
left: var(--board-pad);
|
||||
width: calc(100% - var(--board-pad) - var(--board-pad));
|
||||
height: calc(100% - var(--board-pad) - var(--board-pad));
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
shape-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
.candidate-overlay-link {
|
||||
vector-effect: non-scaling-stroke;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.candidate-overlay-link--strong {
|
||||
stroke: color-mix(in srgb, var(--toolbox-accent) 88%, var(--toolbox-text));
|
||||
stroke-width: clamp(2.4px, 0.38cqi, 4px);
|
||||
}
|
||||
|
||||
.candidate-overlay-link--weak {
|
||||
stroke: color-mix(in srgb, var(--sudoku-warning) 82%, var(--toolbox-text));
|
||||
stroke-dasharray: 4 3.5;
|
||||
stroke-width: clamp(1.5px, 0.25cqi, 2.6px);
|
||||
}
|
||||
|
||||
.candidate-overlay-node circle {
|
||||
vector-effect: non-scaling-stroke;
|
||||
fill: color-mix(in srgb, var(--toolbox-surface) 90%, var(--toolbox-accent));
|
||||
stroke: color-mix(in srgb, var(--toolbox-accent) 82%, var(--toolbox-text));
|
||||
stroke-width: clamp(1.2px, 0.19cqi, 2px);
|
||||
}
|
||||
|
||||
.candidate-overlay-node text {
|
||||
dominant-baseline: central;
|
||||
fill: var(--toolbox-text);
|
||||
font-family: var(--toolbox-font);
|
||||
font-size: 0.13px;
|
||||
font-weight: 850;
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.constraint-layer :where(path, polyline, line, circle, rect) {
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
@@ -1328,6 +1757,102 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
|
||||
stroke-width: clamp(0.8px, 0.12cqi, 1.2px);
|
||||
}
|
||||
|
||||
.constraint-maximum circle {
|
||||
fill: color-mix(in srgb, var(--toolbox-muted) 38%, var(--toolbox-surface));
|
||||
stroke: color-mix(in srgb, var(--toolbox-text) 62%, var(--toolbox-muted));
|
||||
stroke-width: clamp(1.2px, 0.2cqi, 2px);
|
||||
}
|
||||
|
||||
.constraint-maximum path {
|
||||
fill: none;
|
||||
stroke: color-mix(in srgb, var(--toolbox-text) 84%, var(--toolbox-accent));
|
||||
stroke-linejoin: round;
|
||||
stroke-width: clamp(1.4px, 0.23cqi, 2.3px);
|
||||
}
|
||||
|
||||
.constraint-quadruple circle {
|
||||
fill: color-mix(in srgb, var(--toolbox-surface) 94%, var(--toolbox-accent));
|
||||
stroke: color-mix(in srgb, var(--toolbox-text) 80%, var(--toolbox-muted));
|
||||
stroke-width: clamp(1.2px, 0.2cqi, 2px);
|
||||
}
|
||||
|
||||
.constraint-quadruple text,
|
||||
.constraint-outside text {
|
||||
dominant-baseline: central;
|
||||
fill: var(--toolbox-text);
|
||||
font-family: var(--toolbox-font);
|
||||
font-weight: 820;
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.constraint-quadruple text {
|
||||
font-size: 0.145px;
|
||||
letter-spacing: -0.018px;
|
||||
}
|
||||
|
||||
.constraint-outside rect {
|
||||
fill: color-mix(in srgb, var(--toolbox-surface) 90%, var(--toolbox-accent));
|
||||
stroke: color-mix(in srgb, var(--toolbox-border) 74%, var(--toolbox-text));
|
||||
stroke-width: clamp(1px, 0.16cqi, 1.7px);
|
||||
}
|
||||
|
||||
.constraint-outside text {
|
||||
font-size: 0.22px;
|
||||
}
|
||||
|
||||
.constraint-outside .outside-clue-kinds {
|
||||
font-size: 0.115px;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.018px;
|
||||
}
|
||||
|
||||
.constraint-outside .outside-clue-value {
|
||||
font-size: 0.205px;
|
||||
}
|
||||
|
||||
.constraint-skyscraper rect {
|
||||
fill: color-mix(in srgb, var(--sudoku-info) 10%, var(--toolbox-surface));
|
||||
}
|
||||
|
||||
.constraint-layer .is-negated :where(path, polyline, line, circle, rect),
|
||||
.constraint-layer :where(path, polyline, line, circle, rect).is-negated {
|
||||
stroke: var(--toolbox-danger) !important;
|
||||
stroke-dasharray: 4 2.5;
|
||||
}
|
||||
|
||||
.constraint-layer .is-negated text {
|
||||
fill: var(--toolbox-danger) !important;
|
||||
}
|
||||
|
||||
.constraint-thermo.is-negated polyline {
|
||||
stroke-dasharray: 14px 12px;
|
||||
stroke-linecap: butt;
|
||||
}
|
||||
|
||||
.constraint-false-mark {
|
||||
dominant-baseline: central;
|
||||
fill: var(--toolbox-danger);
|
||||
font-family: var(--toolbox-font);
|
||||
font-size: 0.22px;
|
||||
font-weight: 900;
|
||||
paint-order: stroke;
|
||||
stroke: var(--toolbox-surface);
|
||||
stroke-width: 0.055px;
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.constraint-layer circle.is-negated {
|
||||
fill: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-danger) 18%,
|
||||
var(--toolbox-surface)
|
||||
) !important;
|
||||
}
|
||||
|
||||
.constraint-layer .constraint-kropki--black.is-negated {
|
||||
fill: color-mix(in srgb, var(--toolbox-danger) 52%, black) !important;
|
||||
}
|
||||
|
||||
/* Number and colour entry */
|
||||
|
||||
.number-pad {
|
||||
@@ -1454,8 +1979,32 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.constraint-actions,
|
||||
.constraint-batch-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.constraint-actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.constraint-batch-actions .text-button,
|
||||
.constraint-actions .text-button {
|
||||
min-height: 1.9rem;
|
||||
padding: 0.24rem 0.42rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.outside-clue-fields {
|
||||
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
|
||||
}
|
||||
|
||||
.subtabs {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
}
|
||||
|
||||
.helper-controls {
|
||||
@@ -1545,6 +2094,42 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.sum-lab-guidance {
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.sum-combination-cloud {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.sum-combination {
|
||||
min-height: 2rem;
|
||||
padding: 0.28rem 0.48rem;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--toolbox-accent) 18%, var(--toolbox-border));
|
||||
border-radius: 0.42rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-accent);
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.sum-combination:hover,
|
||||
.sum-combination:focus-visible {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent-soft);
|
||||
}
|
||||
|
||||
.sum-combination.is-eliminated {
|
||||
border-color: var(--toolbox-border);
|
||||
background: color-mix(in srgb, var(--toolbox-surface-soft) 72%, transparent);
|
||||
color: var(--toolbox-muted);
|
||||
opacity: 0.68;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.residual-equation {
|
||||
color: var(--toolbox-muted);
|
||||
font-family:
|
||||
@@ -1558,6 +2143,226 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
|
||||
color: var(--toolbox-accent);
|
||||
}
|
||||
|
||||
/* Candidate inspection */
|
||||
|
||||
.candidate-lab {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.candidate-lab h3 {
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.candidate-lab h4 {
|
||||
margin: 0;
|
||||
color: var(--toolbox-text);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.candidate-filter {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.7rem;
|
||||
}
|
||||
|
||||
.candidate-filter legend {
|
||||
padding-inline: 0.25rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.candidate-filter__digits {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(2.15rem, 1fr));
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.candidate-filter__digits--wide {
|
||||
grid-template-columns: repeat(auto-fit, minmax(2rem, 1fr));
|
||||
}
|
||||
|
||||
.candidate-filter__digits button {
|
||||
min-width: 0;
|
||||
min-height: 2.3rem;
|
||||
padding: 0.3rem;
|
||||
}
|
||||
|
||||
.candidate-lab__controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(12rem, 1.5fr) repeat(3, minmax(8rem, 1fr));
|
||||
align-items: end;
|
||||
gap: 0.55rem 0.8rem;
|
||||
}
|
||||
|
||||
.candidate-inspection {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.candidate-cell-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.candidate-cell-list article {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.18rem 0.55rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.58rem;
|
||||
background: var(--toolbox-surface);
|
||||
}
|
||||
|
||||
.candidate-cell-list article > strong {
|
||||
color: var(--toolbox-accent);
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.candidate-cell-list article > span {
|
||||
justify-self: end;
|
||||
color: var(--toolbox-text);
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.candidate-cell-list article > small {
|
||||
grid-column: 1 / -1;
|
||||
overflow: hidden;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.67rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.candidate-house-list {
|
||||
max-height: 18rem;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.4rem;
|
||||
overflow: auto;
|
||||
scrollbar-color: var(--toolbox-border) transparent;
|
||||
}
|
||||
|
||||
.candidate-house-list details {
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.58rem;
|
||||
background: var(--toolbox-surface);
|
||||
}
|
||||
|
||||
.candidate-house-list summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
color: var(--toolbox-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.candidate-house-list summary span {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.candidate-house-list dl {
|
||||
display: grid;
|
||||
gap: 0.22rem;
|
||||
padding: 0 0.65rem 0.65rem;
|
||||
}
|
||||
|
||||
.candidate-house-list dl > div {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5rem 1fr;
|
||||
gap: 0.45rem;
|
||||
padding: 0.3rem 0.4rem;
|
||||
border-radius: 0.38rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.candidate-house-list dt {
|
||||
color: var(--toolbox-accent);
|
||||
font-weight: 820;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.candidate-house-list dd {
|
||||
margin: 0;
|
||||
color: var(--toolbox-muted);
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.candidate-house-list [data-link-kind="strong"] dd {
|
||||
color: var(--toolbox-accent);
|
||||
}
|
||||
|
||||
.candidate-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.candidate-link-list {
|
||||
max-height: 18rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||
align-content: start;
|
||||
gap: 0.35rem;
|
||||
overflow: auto;
|
||||
scrollbar-color: var(--toolbox-border) transparent;
|
||||
}
|
||||
|
||||
.candidate-link-list .candidate-link {
|
||||
min-width: 0;
|
||||
min-height: 3.2rem;
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 0.12rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-left-width: 3px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.candidate-link > span {
|
||||
overflow: hidden;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.73rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.candidate-link > small {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 620;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.candidate-link--strong {
|
||||
border-left-color: var(--toolbox-accent) !important;
|
||||
}
|
||||
|
||||
.candidate-link--weak {
|
||||
border-left-color: var(--sudoku-warning) !important;
|
||||
}
|
||||
|
||||
.analysis-summary,
|
||||
.logical-results {
|
||||
display: grid;
|
||||
@@ -1999,6 +2804,10 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
|
||||
.helper-controls {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.candidate-lab__controls {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
@@ -2039,7 +2848,24 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
|
||||
|
||||
.subtabs,
|
||||
.import-export-grid,
|
||||
.help-grid {
|
||||
.help-grid,
|
||||
.history-dialog-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.history-name-form {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.history-replay {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.history-replay-controls {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.aid-memoire__selected {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@@ -2054,7 +2880,8 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
|
||||
}
|
||||
|
||||
.field-grid,
|
||||
.helper-controls {
|
||||
.helper-controls,
|
||||
.candidate-lab__controls {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@@ -2138,6 +2965,22 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
|
||||
border-radius: 0.55rem;
|
||||
}
|
||||
|
||||
.sudoku-board-frame.has-outside-clues {
|
||||
--board-pad: 1.45rem;
|
||||
}
|
||||
|
||||
.constraint-outside text {
|
||||
font-size: 0.26px;
|
||||
}
|
||||
|
||||
.constraint-outside .outside-clue-kinds {
|
||||
font-size: 0.14px;
|
||||
}
|
||||
|
||||
.constraint-outside .outside-clue-value {
|
||||
font-size: 0.255px;
|
||||
}
|
||||
|
||||
.mode-switcher {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -2197,6 +3040,7 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
|
||||
}
|
||||
|
||||
.constraint-layer :where(path, polyline, line, circle),
|
||||
.candidate-link-layer :where(line, circle),
|
||||
.region-boundaries path {
|
||||
stroke: CanvasText;
|
||||
}
|
||||
@@ -2238,6 +3082,10 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.candidate-link-layer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.toolbox-shell__main,
|
||||
.sudoku-workbench,
|
||||
.workbench-grid,
|
||||
@@ -2291,6 +3139,11 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.sudoku-board-frame.has-outside-clues {
|
||||
--board-pad: 0.85cm;
|
||||
width: min(100%, 19.7cm);
|
||||
}
|
||||
|
||||
.sudoku-cell,
|
||||
.sudoku-cell.is-selected,
|
||||
.sudoku-cell.has-conflict,
|
||||
@@ -2305,13 +3158,6 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.constraint-layer {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.board-meta,
|
||||
.rule-card {
|
||||
width: min(100%, 18cm);
|
||||
|
||||
@@ -116,6 +116,12 @@ test("replaces setter cages and generates a rated variant", async ({
|
||||
test("uses distinct numbered sum badges and directional inequalities", async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtimeErrors: string[] = [];
|
||||
page.on("pageerror", (error) => runtimeErrors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") runtimeErrors.push(message.text());
|
||||
});
|
||||
|
||||
await page.goto("/deep/nested/sudoku/");
|
||||
const examples = page.getByLabel("Open built-in puzzle");
|
||||
|
||||
@@ -136,4 +142,109 @@ test("uses distinct numbered sum badges and directional inequalities", async ({
|
||||
await expect(
|
||||
page.locator(".constraint-inequality-tip").first(),
|
||||
).toBeVisible();
|
||||
|
||||
await examples.selectOption("truth-and-lies");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Truth and lies" }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".constraint-outside")).toHaveCount(4);
|
||||
await expect(page.locator(".constraint-quadruple")).toHaveCount(2);
|
||||
await expect(page.locator(".constraint-maximum")).toHaveCount(2);
|
||||
await expect(page.locator(".constraint-outside.is-negated")).toHaveCount(2);
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const outsideBox = await page
|
||||
.locator(".constraint-outside")
|
||||
.first()
|
||||
.boundingBox();
|
||||
const maximumBox = await page
|
||||
.locator(".constraint-maximum")
|
||||
.first()
|
||||
.boundingBox();
|
||||
expect(outsideBox?.width ?? 0).toBeGreaterThan(18);
|
||||
expect(outsideBox?.height ?? 0).toBeGreaterThan(12);
|
||||
expect(maximumBox?.width ?? 0).toBeGreaterThan(12);
|
||||
expect(maximumBox?.height ?? 0).toBeGreaterThan(12);
|
||||
await expect(
|
||||
page.locator(".constraint-thermo .constraint-false-mark").first(),
|
||||
).toBeVisible();
|
||||
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps navigation, scratch work, branches and analysis tools local", async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtimeErrors: string[] = [];
|
||||
page.on("pageerror", (error) => runtimeErrors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") runtimeErrors.push(message.text());
|
||||
});
|
||||
|
||||
await page.goto("/deep/nested/sudoku/");
|
||||
const grid = page.getByRole("grid", { name: "9 by 9 Sudoku grid" });
|
||||
const first = grid.getByRole("gridcell", {
|
||||
name: "Row 1, column 1, 4",
|
||||
});
|
||||
await first.focus();
|
||||
await first.press("End");
|
||||
await expect(
|
||||
grid.getByRole("gridcell", { name: /Row 1, column 9,/u }),
|
||||
).toBeFocused();
|
||||
await page.keyboard.press("PageDown");
|
||||
await expect(
|
||||
grid.getByRole("gridcell", { name: /Row 9, column 9,/u }),
|
||||
).toBeFocused();
|
||||
await page.keyboard.press("Control+Home");
|
||||
await expect(first).toBeFocused();
|
||||
|
||||
await page.getByLabel("Show aid-mémoire scratch cells").check();
|
||||
const scratch = page.getByRole("grid", {
|
||||
name: "9 aid-mémoire scratch cells",
|
||||
});
|
||||
const scratchCell = scratch.getByRole("gridcell").first();
|
||||
await scratchCell.click();
|
||||
await page
|
||||
.getByRole("group", { name: "Digits" })
|
||||
.getByRole("button", { name: "2", exact: true })
|
||||
.click();
|
||||
await expect(scratch.getByRole("gridcell").first()).toHaveAccessibleName(
|
||||
/value 2/u,
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "History & branches" }).click();
|
||||
await page.getByLabel("Savepoint name").fill("Before test branch");
|
||||
await page.getByRole("button", { name: "Save current grid" }).click();
|
||||
await expect(
|
||||
page.getByText("Before test branch", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByLabel("Hypothesis name").fill("Try a two");
|
||||
await page.getByRole("button", { name: "Start hypothesis" }).click();
|
||||
|
||||
const empty = grid.getByRole("gridcell", {
|
||||
name: "Row 1, column 3, empty",
|
||||
});
|
||||
await empty.click();
|
||||
await page
|
||||
.getByRole("group", { name: "Digits" })
|
||||
.getByRole("button", { name: "2", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
grid.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Hypothesis: Try a two" }).click();
|
||||
await page.getByRole("button", { name: "Discard and return" }).click();
|
||||
await expect(empty).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Helpers" }).click();
|
||||
await page.getByRole("tab", { name: "Sum Lab" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Generalized Sum Lab" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("tab", { name: "Candidate links" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Links and houses" }),
|
||||
).toBeVisible();
|
||||
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState } from "react";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AidMemoire } from "../../src/components/AidMemoire";
|
||||
import {
|
||||
configureAidMemoire,
|
||||
createAidMemoire,
|
||||
type AidMemoireState,
|
||||
} from "../../src/state/aidMemoire";
|
||||
import type { EntryMode } from "../../src/state/session";
|
||||
|
||||
function Harness({
|
||||
initial,
|
||||
readOnly = false,
|
||||
}: {
|
||||
initial?: AidMemoireState;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const [state, setState] = useState(
|
||||
initial ?? createAidMemoire(9, { enabled: true }),
|
||||
);
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [active, setActive] = useState(false);
|
||||
const [mode, setMode] = useState<EntryMode>("value");
|
||||
return (
|
||||
<AidMemoire
|
||||
size={9}
|
||||
state={state}
|
||||
selectedCell={selected}
|
||||
active={active}
|
||||
readOnly={readOnly}
|
||||
mode={mode}
|
||||
onStateChange={setState}
|
||||
onSelect={(cell) => {
|
||||
setSelected(cell);
|
||||
setActive(true);
|
||||
}}
|
||||
onMode={setMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("aid-mémoire component", () => {
|
||||
it("renders normal-like cells with keyboard entry and full descriptions", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
const grid = screen.getByRole("grid", {
|
||||
name: "9 aid-mémoire scratch cells",
|
||||
});
|
||||
expect(within(grid).getAllByRole("row")).toHaveLength(1);
|
||||
const first = within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, empty",
|
||||
});
|
||||
expect(first).toHaveAttribute("aria-colindex", "1");
|
||||
await user.click(first);
|
||||
await user.keyboard("4");
|
||||
expect(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 4",
|
||||
}),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
const second = within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 2, label 2, empty",
|
||||
});
|
||||
second.focus();
|
||||
await user.keyboard("c7");
|
||||
expect(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 2, label 2, empty, centre marks 7",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Centre" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses row-aware WAI grid navigation without horizontal wrapping", () => {
|
||||
const initial = configureAidMemoire(
|
||||
createAidMemoire(9, { enabled: true }),
|
||||
9,
|
||||
5,
|
||||
2,
|
||||
);
|
||||
render(<Harness initial={initial} />);
|
||||
const cells = screen.getAllByRole("gridcell");
|
||||
const rows = screen.getAllByRole("row");
|
||||
expect(rows[0]).toHaveAttribute("aria-rowindex", "1");
|
||||
expect(rows[2]).toHaveAttribute("aria-rowindex", "3");
|
||||
expect(cells[2]).toHaveAttribute("aria-colindex", "1");
|
||||
|
||||
cells[1]!.focus();
|
||||
fireEvent.keyDown(cells[1]!, { key: "ArrowRight" });
|
||||
expect(document.activeElement).toBe(cells[1]);
|
||||
fireEvent.keyDown(cells[1]!, { key: "Home" });
|
||||
expect(document.activeElement).toBe(cells[0]);
|
||||
fireEvent.keyDown(cells[0]!, { key: "End", ctrlKey: true });
|
||||
expect(document.activeElement).toBe(cells[4]);
|
||||
fireEvent.keyDown(cells[4]!, { key: "PageUp" });
|
||||
expect(document.activeElement).toBe(cells[0]);
|
||||
fireEvent.keyDown(cells[0]!, { key: "PageDown" });
|
||||
expect(document.activeElement).toBe(cells[4]);
|
||||
});
|
||||
|
||||
it("keeps read-only replay cells navigable without allowing edits", () => {
|
||||
const initial = configureAidMemoire(
|
||||
createAidMemoire(9, { enabled: true }),
|
||||
9,
|
||||
4,
|
||||
2,
|
||||
);
|
||||
render(<Harness initial={initial} readOnly />);
|
||||
const cells = screen.getAllByRole("gridcell");
|
||||
|
||||
cells[0]!.focus();
|
||||
fireEvent.keyDown(cells[0]!, { key: "ArrowDown" });
|
||||
expect(document.activeElement).toBe(cells[2]);
|
||||
fireEvent.keyDown(cells[2]!, { key: "5" });
|
||||
expect(cells[2]).toHaveAccessibleName("Aid-mémoire cell 3, label 3, empty");
|
||||
});
|
||||
|
||||
it("edits labels, reflows, clears entries and resets with confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
render(<Harness />);
|
||||
|
||||
const label = screen.getByLabelText("Selected cell label");
|
||||
await user.clear(label);
|
||||
await user.type(label, "Pseudo A");
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label Pseudo A, empty",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText("Configure scratch layout"));
|
||||
await user.click(screen.getByRole("button", { name: "Compact grid" }));
|
||||
expect(screen.getByLabelText("Columns")).toHaveValue(3);
|
||||
await user.click(screen.getByRole("button", { name: "Clear entries" }));
|
||||
await user.click(screen.getByRole("button", { name: "Reset all" }));
|
||||
expect(window.confirm).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByLabelText("Columns")).toHaveValue(9);
|
||||
expect(screen.getByLabelText("Selected cell label")).toHaveValue("1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { Workbench } from "../../src/components/Workbench";
|
||||
|
||||
class WorkerStub {
|
||||
addEventListener() {}
|
||||
postMessage() {}
|
||||
terminate() {}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal("Worker", WorkerStub);
|
||||
});
|
||||
|
||||
function mainDigit(name: string) {
|
||||
return within(screen.getByRole("group", { name: "Digits" })).getByRole(
|
||||
"button",
|
||||
{ name },
|
||||
);
|
||||
}
|
||||
|
||||
describe("aid-mémoire Workbench integration", () => {
|
||||
it("routes the shared keypad, undo and redo to a selected scratch cell", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
const toggle = screen.getByLabelText("Show aid-mémoire scratch cells");
|
||||
await user.click(toggle);
|
||||
|
||||
const grid = screen.getByRole("grid", {
|
||||
name: "9 aid-mémoire scratch cells",
|
||||
});
|
||||
await user.click(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, empty",
|
||||
}),
|
||||
);
|
||||
await user.click(mainDigit("5"));
|
||||
expect(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 5",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Undo" }));
|
||||
expect(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, empty",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Redo" }));
|
||||
expect(
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 5",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(toggle);
|
||||
expect(
|
||||
screen.queryByRole("grid", { name: "9 aid-mémoire scratch cells" }),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(toggle);
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 5",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores scratch labels and entries from the local Library", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
await user.click(screen.getByLabelText("Show aid-mémoire scratch cells"));
|
||||
await user.clear(screen.getByLabelText("Selected cell label"));
|
||||
await user.type(screen.getByLabelText("Selected cell label"), "Pseudo A");
|
||||
await user.click(mainDigit("6"));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Library" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save current" }));
|
||||
expect(
|
||||
await screen.findByText("Current puzzle and progress saved locally."),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
|
||||
await user.click(mainDigit("7"));
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label Pseudo A, value 7",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Library" }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: /A first classic/u }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label Pseudo A, value 6",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores scratch state with a savepoint and reverses that restore", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
await user.click(screen.getByLabelText("Show aid-mémoire scratch cells"));
|
||||
await user.click(mainDigit("5"));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
await user.type(screen.getByLabelText("Savepoint name"), "Scratch five");
|
||||
await user.click(screen.getByRole("button", { name: "Save current grid" }));
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
|
||||
await user.click(mainDigit("6"));
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 6",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Restore" }));
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 5",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Undo" }));
|
||||
expect(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Aid-mémoire cell 1, label 1, value 6",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CandidateLab } from "../../src/components/CandidateLab";
|
||||
import { createEmptyPuzzle, normalizePuzzle } from "../../src/domain";
|
||||
import type { CandidateOverlay } from "../../src/helpers";
|
||||
|
||||
function mask(...values: number[]): number {
|
||||
return values.reduce((result, value) => result | (1 << (value - 1)), 0);
|
||||
}
|
||||
|
||||
const puzzle = normalizePuzzle(createEmptyPuzzle(4));
|
||||
|
||||
describe("candidate lab", () => {
|
||||
it("inspects selected cells, filters digits and focuses a link overlay", async () => {
|
||||
const user = userEvent.setup();
|
||||
const overlays: (CandidateOverlay | undefined)[] = [];
|
||||
const candidateMasks = new Array<number>(16).fill(0);
|
||||
candidateMasks[0] = mask(1, 2);
|
||||
candidateMasks[1] = mask(1, 2);
|
||||
|
||||
render(
|
||||
<CandidateLab
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
candidateMasks={candidateMasks}
|
||||
selectedCells={[0]}
|
||||
onOverlayChange={(overlay) => overlays.push(overlay)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const selected = screen.getByRole("heading", {
|
||||
name: "Selected cells",
|
||||
}).parentElement!;
|
||||
expect(within(selected).getByText("r1c1")).toBeInTheDocument();
|
||||
expect(within(selected).getByText("1 2")).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Filter candidate 1" }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(overlays.at(-1)?.activeValues).toEqual([1]);
|
||||
expect(overlays.at(-1)?.candidateCells).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
const link = screen.getByRole("button", { name: /r1c1\(1\).*r1c2\(1\)/u });
|
||||
await user.click(link);
|
||||
await waitFor(() => expect(overlays.at(-1)?.links).toHaveLength(1));
|
||||
expect(link).toHaveAttribute("aria-pressed", "true");
|
||||
await user.click(screen.getByRole("button", { name: "Show all" }));
|
||||
expect(link).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("reveals weak links on demand within a chosen house", async () => {
|
||||
const user = userEvent.setup();
|
||||
const candidateMasks = new Array<number>(16).fill(0);
|
||||
candidateMasks[0] = mask(3);
|
||||
candidateMasks[1] = mask(3);
|
||||
candidateMasks[2] = mask(3);
|
||||
|
||||
render(
|
||||
<CandidateLab
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
candidateMasks={candidateMasks}
|
||||
selectedCells={[0]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Candidate house scope"),
|
||||
"unit:0",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Filter candidate 3" }),
|
||||
);
|
||||
expect(screen.getByText(/No links match this scope/u)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("checkbox", { name: "Weak links" }));
|
||||
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: /weak · Row 1/u }),
|
||||
).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("clears the board overlay when it unmounts", async () => {
|
||||
const onOverlayChange = vi.fn();
|
||||
const { unmount } = render(
|
||||
<CandidateLab
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
candidateMasks={new Array<number>(16).fill(0)}
|
||||
selectedCells={[0]}
|
||||
onOverlayChange={onOverlayChange}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(onOverlayChange).toHaveBeenCalled());
|
||||
unmount();
|
||||
expect(onOverlayChange).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { Workbench } from "../../src/components/Workbench";
|
||||
|
||||
class WorkerStub {
|
||||
addEventListener() {}
|
||||
postMessage() {}
|
||||
terminate() {}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal("Worker", WorkerStub);
|
||||
});
|
||||
|
||||
function digitButton(name: string) {
|
||||
return within(screen.getByRole("group", { name: "Digits" })).getByRole(
|
||||
"button",
|
||||
{ name },
|
||||
);
|
||||
}
|
||||
|
||||
describe("gameplay history integration", () => {
|
||||
it("restores savepoints and preserves discarded hypotheses for replay", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
|
||||
fireEvent.pointerDown(
|
||||
screen.getByRole("gridcell", {
|
||||
name: "Row 1, column 3, empty",
|
||||
}),
|
||||
{ buttons: 1 },
|
||||
);
|
||||
await user.click(digitButton("2"));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "Branches, savepoints and replay",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await user.type(screen.getByLabelText("Savepoint name"), "After two");
|
||||
await user.click(screen.getByRole("button", { name: "Save current grid" }));
|
||||
expect(screen.getByText("After two")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
|
||||
await user.click(digitButton("3"));
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 3, 3" }),
|
||||
).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Restore" }));
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
await user.type(screen.getByLabelText("Hypothesis name"), "Try three");
|
||||
await user.click(screen.getByRole("button", { name: "Start hypothesis" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Hypothesis: Try three" }),
|
||||
).toBeInTheDocument();
|
||||
await user.click(digitButton("3"));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Hypothesis: Try three" }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Discard and return" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "History & branches" }),
|
||||
);
|
||||
expect(screen.getByText("discarded")).toBeInTheDocument();
|
||||
await user.click(
|
||||
within(screen.getByRole("list", { name: "Solve history" })).getAllByRole(
|
||||
"button",
|
||||
{ name: /Set r1c3 to 3/u },
|
||||
)[0]!,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Set r1c3 to 3" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 3, 3" }),
|
||||
).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Return to live grid" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("gridcell", { name: "Row 1, column 3, 2" }),
|
||||
).toBeInTheDocument();
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -59,4 +59,42 @@ describe("Sudoku generator workspace", () => {
|
||||
);
|
||||
expect(onRate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("submits a bounded evidence-backed practice target", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onGenerate = vi.fn();
|
||||
render(
|
||||
<GeneratorWorkspace
|
||||
busy={false}
|
||||
onGenerate={onGenerate}
|
||||
onRate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Practice technique"),
|
||||
"x-wing",
|
||||
);
|
||||
const attempts = screen.getByLabelText("Mining attempts");
|
||||
await user.clear(attempts);
|
||||
await user.type(attempts, "7");
|
||||
await user.clear(screen.getByLabelText("Seed"));
|
||||
await user.type(screen.getByLabelText("Seed"), "x-wing-training");
|
||||
await user.click(screen.getByRole("button", { name: "Generate Classic" }));
|
||||
|
||||
expect(onGenerate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requiredTechnique: "x-wing",
|
||||
maxTechniqueAttempts: 7,
|
||||
seed: "x-wing-training",
|
||||
}),
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Variant"), "killer");
|
||||
expect(
|
||||
within(screen.getByLabelText("Practice technique")).getByRole("option", {
|
||||
name: "Killer Cage",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HelpersWorkspace } from "../../src/components/HelpersWorkspace";
|
||||
import { createEmptyPuzzle, normalizePuzzle } from "../../src/domain";
|
||||
|
||||
function mask(...digits: number[]): number {
|
||||
return digits.reduce((value, digit) => value | (2 ** (digit - 1)), 0);
|
||||
}
|
||||
|
||||
describe("Sum Lab helper", () => {
|
||||
const puzzle = normalizePuzzle(createEmptyPuzzle(9));
|
||||
|
||||
it("eliminates and restores combinations without changing the inputs", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<HelpersWorkspace
|
||||
size={9}
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
selectedCells={[]}
|
||||
candidateMasks={Array<number>(81).fill(0)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Generalized Sum Lab" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("4", { selector: "strong" })).toBeInTheDocument();
|
||||
|
||||
const combination = screen.getByRole("button", {
|
||||
name: "1 + 9; eliminate",
|
||||
});
|
||||
await user.click(combination);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "1 + 9; restore" }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
expect(
|
||||
screen.getByText("3", { selector: ".metric-row strong" }).parentElement,
|
||||
).toHaveTextContent("3 active of 4");
|
||||
expect(screen.getByRole("button", { name: "Restore all" })).toBeEnabled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Restore all" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "1 + 9; eliminate" }),
|
||||
).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("filters through selected board-cell candidate masks", async () => {
|
||||
const user = userEvent.setup();
|
||||
const candidateMasks = Array<number>(81).fill(0);
|
||||
candidateMasks[0] = mask(1, 2);
|
||||
candidateMasks[1] = mask(8, 9);
|
||||
render(
|
||||
<HelpersWorkspace
|
||||
size={9}
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
selectedCells={[0, 1]}
|
||||
candidateMasks={candidateMasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
|
||||
await user.click(
|
||||
screen.getByRole("checkbox", {
|
||||
name: "Use 2 board cells and candidates",
|
||||
}),
|
||||
);
|
||||
|
||||
const positions = screen.getByRole("list");
|
||||
expect(within(positions).getByText("r1c1: 1 2")).toBeInTheDocument();
|
||||
expect(within(positions).getByText("r1c2: 8 9")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "3 + 7; eliminate" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resets digit bounds when the puzzle size changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<HelpersWorkspace
|
||||
key={9}
|
||||
size={9}
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
selectedCells={[]}
|
||||
candidateMasks={Array<number>(81).fill(0)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
|
||||
expect(
|
||||
screen.getByRole("spinbutton", { name: "Maximum digit" }),
|
||||
).toHaveValue(9);
|
||||
|
||||
const smallerPuzzle = normalizePuzzle(createEmptyPuzzle(4));
|
||||
rerender(
|
||||
<HelpersWorkspace
|
||||
key={4}
|
||||
size={4}
|
||||
puzzle={smallerPuzzle}
|
||||
values={smallerPuzzle.givens}
|
||||
selectedCells={[]}
|
||||
candidateMasks={Array<number>(16).fill(0)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Sum Lab" }));
|
||||
expect(
|
||||
screen.getByRole("spinbutton", { name: "Maximum digit" }),
|
||||
).toHaveValue(4);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ImportExportDialog } from "../../src/components/ImportExportDialog";
|
||||
import { createEmptyPuzzle } from "../../src/domain";
|
||||
import type { PuzzleDefinition } from "../../src/domain/types";
|
||||
import { fromDomainPuzzle } from "../../src/formats";
|
||||
import type { PortableAidMemoire } from "../../src/state/aidMemoire";
|
||||
import { createSession } from "../../src/state/session";
|
||||
|
||||
const TEST_AID_MEMOIRE: PortableAidMemoire = {
|
||||
version: 1,
|
||||
enabled: true,
|
||||
columns: 1,
|
||||
cells: [
|
||||
{
|
||||
label: "A",
|
||||
value: 2,
|
||||
cornerMarks: [],
|
||||
centerMarks: [],
|
||||
color: 3,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function localScl(overrides: Record<string, unknown> = {}) {
|
||||
const cells = Array.from({ length: 4 }, () =>
|
||||
Array.from({ length: 4 }, () => ({})),
|
||||
);
|
||||
cells[0]![0] = { value: 1 };
|
||||
return JSON.stringify({
|
||||
id: "synthetic-local-test",
|
||||
cells,
|
||||
metadata: { title: "Synthetic local puzzle" },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function renderDialog(
|
||||
options: {
|
||||
readonly puzzle?: PuzzleDefinition;
|
||||
readonly aidMemoire?: PortableAidMemoire;
|
||||
} = {},
|
||||
) {
|
||||
const puzzle = options.puzzle ?? createEmptyPuzzle(4);
|
||||
const onClose = vi.fn();
|
||||
const onImport = vi.fn();
|
||||
render(
|
||||
<ImportExportDialog
|
||||
open
|
||||
puzzle={puzzle}
|
||||
session={createSession(puzzle.givens)}
|
||||
aidMemoire={options.aidMemoire}
|
||||
onClose={onClose}
|
||||
onImport={onImport}
|
||||
/>,
|
||||
);
|
||||
return { onClose, onImport };
|
||||
}
|
||||
|
||||
describe("ImportExportDialog interoperability", () => {
|
||||
it("reports a detected compatible format before importing it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onClose, onImport } = renderDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste 81 characters/u), {
|
||||
target: { value: localScl() },
|
||||
});
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Check compatibility" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/SudokuPad\/CTC JSON: 4×4, 1 givens and 0 constraints/u,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Import locally" }));
|
||||
expect(onImport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ size: 4, givens: expect.any(Array) }),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports unsupported constructs and never fetches short IDs", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
renderDialog();
|
||||
const input = screen.getByPlaceholderText(/Paste 81 characters/u);
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: localScl({ overlays: [{ text: "visual only" }] }) },
|
||||
});
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Check compatibility" }),
|
||||
);
|
||||
expect(await screen.findByText(/visual overlays/u)).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: "https://sudokupad.app/serverOnly42" },
|
||||
});
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Check compatibility" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(/server-hosted SudokuPad puzzle ID/u),
|
||||
).toBeInTheDocument();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("downloads the current puzzle as a self-contained SVG", async () => {
|
||||
const user = userEvent.setup();
|
||||
let downloaded: Blob | undefined;
|
||||
vi.mocked(URL.createObjectURL).mockImplementation((value) => {
|
||||
downloaded = value as Blob;
|
||||
return "blob:visual-export-test";
|
||||
});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
renderDialog();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Download SVG" }));
|
||||
expect(
|
||||
await screen.findByText("SVG rendered and downloaded locally."),
|
||||
).toBeInTheDocument();
|
||||
expect(downloaded?.type).toContain("image/svg+xml");
|
||||
expect(await downloaded?.text()).toContain("<svg");
|
||||
});
|
||||
|
||||
it("round-trips aid-mémoire progress through project import and export", async () => {
|
||||
const user = userEvent.setup();
|
||||
const puzzle = createEmptyPuzzle(4);
|
||||
const { onImport } = renderDialog({
|
||||
puzzle,
|
||||
aidMemoire: TEST_AID_MEMOIRE,
|
||||
});
|
||||
let downloaded: Blob | undefined;
|
||||
vi.mocked(URL.createObjectURL).mockImplementation((value) => {
|
||||
downloaded = value as Blob;
|
||||
return "blob:project-export-test";
|
||||
});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Download project JSON" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText("Project JSON downloaded locally."),
|
||||
).toBeInTheDocument();
|
||||
expect(JSON.parse((await downloaded?.text()) ?? "{}").aidMemoire).toEqual(
|
||||
TEST_AID_MEMOIRE,
|
||||
);
|
||||
|
||||
const importedDocument = {
|
||||
...fromDomainPuzzle(puzzle),
|
||||
aidMemoire: TEST_AID_MEMOIRE,
|
||||
};
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste 81 characters/u), {
|
||||
target: { value: JSON.stringify(importedDocument) },
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Import locally" }));
|
||||
expect(onImport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ size: 4 }),
|
||||
expect.objectContaining({ aidMemoire: TEST_AID_MEMOIRE }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an f-puzzles export incompatibility without throwing", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog({
|
||||
puzzle: {
|
||||
...createEmptyPuzzle(4),
|
||||
constraints: [{ type: "xv", a: 0, b: 1, total: 5, negated: true }],
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Download f-puzzles JSON" }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(/cannot preserve an individually false clue/u),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SolveWorkspace } from "../../src/components/SolveWorkspace";
|
||||
import type { ExactSolveResult } from "../../src/solver";
|
||||
|
||||
const limited: ExactSolveResult = {
|
||||
solutions: [],
|
||||
count: 0,
|
||||
truncated: true,
|
||||
limitReason: "timeout",
|
||||
nodes: 100,
|
||||
elapsedMs: 10_000,
|
||||
};
|
||||
|
||||
describe("solve result reporting", () => {
|
||||
it("does not report zero solutions at a safety limit as unsatisfiable", () => {
|
||||
const props = {
|
||||
size: 9,
|
||||
busy: false,
|
||||
onLogical: vi.fn(),
|
||||
onExact: vi.fn(),
|
||||
onApplyValues: vi.fn(),
|
||||
onFocusCells: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(<SolveWorkspace {...props} exact={limited} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/solvability is not established/u),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("No completion satisfies every supported rule."),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<SolveWorkspace
|
||||
{...props}
|
||||
exact={{ ...limited, truncated: false, limitReason: undefined }}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("No completion satisfies every supported rule."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { normalizePuzzle } from "../../src/domain";
|
||||
import { SudokuBoard } from "../../src/components/SudokuBoard";
|
||||
@@ -41,4 +41,178 @@ describe("Sudoku board constraint visuals", () => {
|
||||
container.querySelector(".constraint-inequality-tip"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders false local clues and combines dual-purpose outside labels", () => {
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: new Array<number>(16).fill(0),
|
||||
constraints: [
|
||||
{ type: "maximum", cell: 5, negated: true },
|
||||
{ type: "thermo", cells: [8, 9], negated: true },
|
||||
{
|
||||
type: "quadruple",
|
||||
cells: [0, 1, 4, 5],
|
||||
digits: [1, 3],
|
||||
negated: true,
|
||||
},
|
||||
{
|
||||
type: "x-sum",
|
||||
side: "top",
|
||||
index: 0,
|
||||
sum: 42,
|
||||
negated: true,
|
||||
},
|
||||
{
|
||||
type: "skyscraper",
|
||||
side: "top",
|
||||
index: 0,
|
||||
count: 42,
|
||||
negated: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<SudokuBoard
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
selected={new Set([0])}
|
||||
activeCell={0}
|
||||
onCellPointerDown={vi.fn()}
|
||||
onCellPointerEnter={vi.fn()}
|
||||
onKeyDown={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".sudoku-board-frame")).toHaveClass(
|
||||
"has-outside-clues",
|
||||
);
|
||||
expect(container.querySelectorAll(".constraint-outside")).toHaveLength(1);
|
||||
expect(container.querySelector(".outside-clue-kinds")).toHaveTextContent(
|
||||
"Σ · ▥",
|
||||
);
|
||||
expect(container.querySelector(".outside-clue-value")).toHaveTextContent(
|
||||
"≠42",
|
||||
);
|
||||
expect(container.querySelectorAll(".is-negated")).toHaveLength(4);
|
||||
expect(
|
||||
container.querySelector(".constraint-thermo.is-negated polyline"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector(".constraint-thermo .constraint-false-mark"),
|
||||
).toHaveTextContent("≠");
|
||||
|
||||
const board = screen.getByRole("grid", { name: "4 by 4 Sudoku grid" });
|
||||
expect(board).toHaveAccessibleDescription(
|
||||
expect.stringContaining("False clue: X-sum 42 from the top, column 1."),
|
||||
);
|
||||
expect(board).toHaveAccessibleDescription(
|
||||
expect.stringContaining("False clue: maximum at row 2, column 2."),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders candidate filters and strong/weak graph overlays", () => {
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: new Array<number>(16).fill(0),
|
||||
});
|
||||
const { container } = render(
|
||||
<SudokuBoard
|
||||
puzzle={puzzle}
|
||||
values={puzzle.givens}
|
||||
selected={new Set([0])}
|
||||
activeCell={0}
|
||||
candidateOverlay={{
|
||||
activeValues: [1],
|
||||
candidateCells: [0, 1],
|
||||
links: [
|
||||
{
|
||||
id: "strong",
|
||||
kind: "strong",
|
||||
a: { cell: 0, value: 1 },
|
||||
b: { cell: 1, value: 1 },
|
||||
contexts: [{ kind: "house", label: "Row 1" }],
|
||||
},
|
||||
{
|
||||
id: "weak",
|
||||
kind: "weak",
|
||||
a: { cell: 0, value: 2 },
|
||||
b: { cell: 0, value: 3 },
|
||||
contexts: [{ kind: "cell", label: "Cell r1c1" }],
|
||||
},
|
||||
],
|
||||
}}
|
||||
onCellPointerDown={vi.fn()}
|
||||
onCellPointerEnter={vi.fn()}
|
||||
onKeyDown={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelectorAll(".is-candidate-highlighted"),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
container.querySelectorAll(".candidate-overlay-link--strong"),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
container.querySelectorAll(".candidate-overlay-link--weak"),
|
||||
).toHaveLength(1);
|
||||
expect(container.querySelectorAll(".candidate-overlay-node")).toHaveLength(
|
||||
4,
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes real row semantics and detailed per-cell state", () => {
|
||||
const puzzle = normalizePuzzle({
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
constraints: [{ type: "killer-cage", cells: [0, 1], sum: 3 }],
|
||||
});
|
||||
render(
|
||||
<SudokuBoard
|
||||
puzzle={puzzle}
|
||||
values={[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}
|
||||
cornerMarks={[0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}
|
||||
centerMarks={new Array<number>(16).fill(0)}
|
||||
candidates={[0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}
|
||||
colors={[0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}
|
||||
selected={new Set([1])}
|
||||
conflicts={new Set([1])}
|
||||
activeCell={1}
|
||||
showCandidates
|
||||
onCellPointerDown={vi.fn()}
|
||||
onCellPointerEnter={vi.fn()}
|
||||
onKeyDown={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const board = screen.getByRole("grid", { name: "4 by 4 Sudoku grid" });
|
||||
expect(board).toHaveAttribute("aria-rowcount", "4");
|
||||
expect(board).toHaveAttribute("aria-colcount", "4");
|
||||
expect(board).toHaveAccessibleDescription(
|
||||
expect.stringContaining("Home and End move to the first and last cell"),
|
||||
);
|
||||
expect(screen.getAllByRole("row")).toHaveLength(4);
|
||||
const given = screen.getByRole("gridcell", {
|
||||
name: "Row 1, column 1, 1",
|
||||
});
|
||||
expect(given).toHaveAttribute("aria-readonly", "true");
|
||||
expect(given).toHaveAccessibleDescription(
|
||||
expect.stringMatching(/region 1.*given digit.*killer cage 3/i),
|
||||
);
|
||||
const annotated = screen.getByRole("gridcell", {
|
||||
name: "Row 1, column 2, empty",
|
||||
});
|
||||
expect(annotated).toHaveAccessibleDescription(
|
||||
expect.stringMatching(
|
||||
/region 1.*corner notes 1.*automatic candidates 1, 2.*colour 2.*conflict/i,
|
||||
),
|
||||
);
|
||||
expect(annotated).toHaveAttribute("aria-colindex", "2");
|
||||
expect(annotated).toHaveAttribute("aria-invalid", "true");
|
||||
expect(annotated).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,34 @@ describe("Sudoku workbench", () => {
|
||||
expect(undo).toBeDisabled();
|
||||
});
|
||||
|
||||
it("supports non-wrapping WAI-ARIA grid navigation", () => {
|
||||
render(<Workbench />);
|
||||
|
||||
const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" });
|
||||
const cell = (row: number, column: number) =>
|
||||
within(grid).getByRole("gridcell", {
|
||||
name: new RegExp(`^Row ${String(row)}, column ${String(column)},`, "u"),
|
||||
});
|
||||
|
||||
cell(1, 1).focus();
|
||||
fireEvent.keyDown(cell(1, 1), { key: "ArrowLeft" });
|
||||
expect(cell(1, 1)).toHaveAttribute("tabindex", "0");
|
||||
|
||||
fireEvent.keyDown(cell(1, 1), { key: "End" });
|
||||
expect(cell(1, 9)).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.keyDown(cell(1, 9), { key: "ArrowRight" });
|
||||
expect(cell(1, 9)).toHaveAttribute("tabindex", "0");
|
||||
|
||||
fireEvent.keyDown(cell(1, 9), { key: "ArrowDown" });
|
||||
expect(cell(2, 9)).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.keyDown(cell(2, 9), { key: "Home" });
|
||||
expect(cell(2, 1)).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.keyDown(cell(2, 1), { key: "PageDown" });
|
||||
expect(cell(9, 1)).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.keyDown(cell(9, 1), { key: "Home", ctrlKey: true });
|
||||
expect(cell(1, 1)).toHaveAttribute("tabindex", "0");
|
||||
});
|
||||
|
||||
it("shows digit progress and toggles matching-digit highlights separately from selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
@@ -124,6 +152,30 @@ describe("Sudoku workbench", () => {
|
||||
screen.getByRole("button", { name: "Remove selected cage" }),
|
||||
);
|
||||
expect(screen.queryByText("4 cage · 2 cells")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByLabelText("Cage digits may repeat"));
|
||||
await user.click(
|
||||
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
|
||||
);
|
||||
await user.clear(cageSum);
|
||||
await user.type(cageSum, "2");
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Add / replace cage" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByText("false · 2 cage · 2 cells · repeats allowed"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByLabelText("Cage digits may repeat"));
|
||||
await user.clear(cageSum);
|
||||
await user.type(cageSum, "3");
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Add / replace cage" }),
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("false · 2 cage · 2 cells · repeats allowed"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("false · 3 cage · 2 cells")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the expanded built-in variant examples", async () => {
|
||||
@@ -157,6 +209,66 @@ describe("Sudoku workbench", () => {
|
||||
expect(
|
||||
container.querySelector(".constraint-xv--10 text"),
|
||||
).toHaveTextContent("10");
|
||||
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText("Open built-in puzzle"),
|
||||
"truth-and-lies",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Truth and lies" }),
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelectorAll(".constraint-outside")).toHaveLength(4);
|
||||
expect(container.querySelectorAll(".constraint-quadruple")).toHaveLength(2);
|
||||
expect(container.querySelectorAll(".constraint-maximum")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sets and batch-toggles false local and outside clues", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<Workbench />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "New blank" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add quadruple" }),
|
||||
).toBeDisabled();
|
||||
await user.clear(screen.getByLabelText("Quadruple digits"));
|
||||
await user.type(screen.getByLabelText("Quadruple digits"), "1");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add quadruple" }),
|
||||
).toBeDisabled();
|
||||
await user.click(
|
||||
screen.getByLabelText("Newly added clues must be false (Wrogn mode)"),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Maximum cell" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Add / replace outside clue" }),
|
||||
);
|
||||
|
||||
expect(screen.getByText("false · maximum · r1c1")).toBeInTheDocument();
|
||||
expect(screen.getByText("false · Σ3 · top 1")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "Require true for false · maximum · r1c1",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "Remove false · Σ3 · top 1",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelectorAll(".is-negated")).toHaveLength(2);
|
||||
|
||||
await user.clear(screen.getByLabelText("Clue"));
|
||||
await user.type(screen.getByLabelText("Clue"), "6562");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add / replace outside clue" }),
|
||||
).toBeDisabled();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Make all clues true" }),
|
||||
);
|
||||
expect(screen.getByText("maximum · r1c1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Σ3 · top 1")).toBeInTheDocument();
|
||||
expect(container.querySelectorAll(".is-negated")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("switches between setting, solving, playing and helper workspaces", async () => {
|
||||
@@ -206,6 +318,38 @@ describe("Sudoku workbench", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("projects candidate filters and links onto the board only while inspecting", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<Workbench />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Helpers" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Candidate links" }));
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Links and houses" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("checkbox", { name: "Weak links" }));
|
||||
expect(
|
||||
container.querySelector(".candidate-link-layer"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Filter candidate 1" }),
|
||||
);
|
||||
expect(
|
||||
container.querySelectorAll(".sudoku-cell.is-candidate-highlighted")
|
||||
.length,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Play" }));
|
||||
expect(
|
||||
container.querySelector(".candidate-link-layer"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelectorAll(".sudoku-cell.is-candidate-highlighted"),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("makes the import boundary and self-contained local sharing explicit", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fetchSpy = vi.fn();
|
||||
|
||||
@@ -11,11 +11,16 @@ import {
|
||||
} from "../../src/solver";
|
||||
|
||||
describe("bundled original samples", () => {
|
||||
it.each(SAMPLE_PUZZLES)("ships $title as a unique puzzle", (puzzle) => {
|
||||
const fastUniquenessSamples = SAMPLE_CATALOG.map(({ puzzle }) => puzzle);
|
||||
|
||||
it.each(fastUniquenessSamples)(
|
||||
"ships $title as a unique puzzle",
|
||||
(puzzle) => {
|
||||
const result = solveExact(puzzle, { maxSolutions: 2 });
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("solves the generated classic with the supported logical techniques", () => {
|
||||
expect(solveLogically(CLASSIC_SAMPLE).status).toBe("solved");
|
||||
@@ -50,6 +55,10 @@ describe("bundled original samples", () => {
|
||||
"inequality",
|
||||
"renban",
|
||||
"palindrome",
|
||||
"x-sum",
|
||||
"skyscraper",
|
||||
"quadruple",
|
||||
"maximum",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
candidatesForCell,
|
||||
classicRegions,
|
||||
compilePuzzle,
|
||||
constraintIsFeasible,
|
||||
findConflicts,
|
||||
normalizePuzzle,
|
||||
validatePuzzle,
|
||||
type PuzzleDefinition,
|
||||
type VariantConstraint,
|
||||
} from "../../src/domain";
|
||||
|
||||
const solved4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const;
|
||||
|
||||
function puzzle4(constraints: readonly VariantConstraint[]): PuzzleDefinition {
|
||||
return {
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: new Array<number>(16).fill(0),
|
||||
regions: classicRegions(4),
|
||||
constraints,
|
||||
};
|
||||
}
|
||||
|
||||
describe("outside and local clue constraints", () => {
|
||||
it("strictly validates and clones every new bounded shape", () => {
|
||||
const constraints: VariantConstraint[] = [
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 6 },
|
||||
{ type: "skyscraper", side: "bottom", index: 1, count: 2 },
|
||||
{ type: "quadruple", cells: [1, 2, 5, 6], digits: [2, 2, 4] },
|
||||
{ type: "maximum", cell: 5 },
|
||||
];
|
||||
const definition = puzzle4(constraints);
|
||||
expect(validatePuzzle(definition)).toEqual({ valid: true, issues: [] });
|
||||
|
||||
const normalized = normalizePuzzle(definition);
|
||||
expect(normalized.constraints).toEqual(constraints);
|
||||
expect(normalized.constraints).not.toBe(constraints);
|
||||
expect(normalized.constraints[2]).not.toBe(constraints[2]);
|
||||
|
||||
const malformed = [
|
||||
{ type: "x-sum", side: "near", index: 0, sum: 6 },
|
||||
{ type: "x-sum", side: "left", index: 4, sum: 6 },
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 2 },
|
||||
{ type: "skyscraper", side: "top", index: 0, count: 0 },
|
||||
{ type: "quadruple", cells: [0, 1], digits: [1, 2, 3] },
|
||||
{ type: "quadruple", cells: [0, 2, 4, 6], digits: [1, 2] },
|
||||
{ type: "maximum", cell: 16 },
|
||||
{
|
||||
type: "maximum",
|
||||
cell: 5,
|
||||
negated: "yes",
|
||||
},
|
||||
];
|
||||
for (const constraint of malformed) {
|
||||
expect(validatePuzzle(puzzle4([constraint as never])).valid).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows bounded impossible numbers only for false numeric clues", () => {
|
||||
expect(
|
||||
validatePuzzle(
|
||||
puzzle4([
|
||||
{
|
||||
type: "killer-cage",
|
||||
cells: [0, 1, 2],
|
||||
sum: 42,
|
||||
negated: true,
|
||||
},
|
||||
]),
|
||||
).valid,
|
||||
).toBe(true);
|
||||
expect(
|
||||
validatePuzzle(
|
||||
puzzle4([{ type: "killer-cage", cells: [0, 1, 2], sum: 42 }]),
|
||||
).valid,
|
||||
).toBe(false);
|
||||
|
||||
for (const constraint of [
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 42 },
|
||||
{ type: "skyscraper", side: "top", index: 0, count: 42 },
|
||||
] as const) {
|
||||
expect(
|
||||
validatePuzzle(puzzle4([{ ...constraint, negated: true }])).valid,
|
||||
).toBe(true);
|
||||
expect(validatePuzzle(puzzle4([constraint])).valid).toBe(false);
|
||||
}
|
||||
|
||||
expect(
|
||||
validatePuzzle(
|
||||
puzzle4([
|
||||
{
|
||||
type: "x-sum",
|
||||
side: "left",
|
||||
index: 0,
|
||||
sum: 257,
|
||||
negated: true,
|
||||
},
|
||||
]),
|
||||
).valid,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("attaches outside clues to their full lines and local clues to every affected cell", () => {
|
||||
const compiled = compilePuzzle(
|
||||
normalizePuzzle(
|
||||
puzzle4([
|
||||
{ type: "x-sum", side: "top", index: 1, sum: 6 },
|
||||
{ type: "skyscraper", side: "right", index: 2, count: 2 },
|
||||
{ type: "quadruple", cells: [1, 2, 5, 6], digits: [2] },
|
||||
{ type: "maximum", cell: 5 },
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
expect(compiled.constraintsByCell[13]).toContain(0);
|
||||
expect(compiled.constraintsByCell[8]).toContain(1);
|
||||
expect(compiled.constraintsByCell[6]).toContain(2);
|
||||
for (const cell of [5, 1, 9, 4, 6]) {
|
||||
expect(compiled.constraintsByCell[cell]).toContain(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the first digit and exact distinct subset sums to prune X-sums", () => {
|
||||
const constraint = {
|
||||
type: "x-sum",
|
||||
side: "left",
|
||||
index: 0,
|
||||
sum: 6,
|
||||
} as const;
|
||||
const values = new Array<number>(16).fill(0);
|
||||
values[1] = 4;
|
||||
expect(candidatesForCell(puzzle4([constraint]), values, 0)).toEqual([2]);
|
||||
|
||||
const reusedOutsidePrefix = new Array<number>(16).fill(0);
|
||||
reusedOutsidePrefix.splice(0, 4, 3, 1, 0, 2);
|
||||
expect(constraintIsFeasible(constraint, reusedOutsidePrefix, 4)).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 1 },
|
||||
solved4,
|
||||
4,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "x-sum", side: "bottom", index: 0, sum: 6 },
|
||||
solved4,
|
||||
4,
|
||||
),
|
||||
).toBe(true);
|
||||
const conflict = findConflicts(
|
||||
puzzle4([{ type: "x-sum", side: "left", index: 0, sum: 6 }]),
|
||||
solved4,
|
||||
).find(({ kind }) => kind === "constraint");
|
||||
expect(conflict?.cells).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it("enforces exact skyscraper visibility and safely prunes partial lines", () => {
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "skyscraper", side: "left", index: 0, count: 4 },
|
||||
solved4,
|
||||
4,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "skyscraper", side: "right", index: 0, count: 1 },
|
||||
solved4,
|
||||
4,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "skyscraper", side: "left", index: 0, count: 3 },
|
||||
solved4,
|
||||
4,
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
candidatesForCell(
|
||||
puzzle4([{ type: "skyscraper", side: "left", index: 0, count: 1 }]),
|
||||
new Array<number>(16).fill(0),
|
||||
0,
|
||||
),
|
||||
).toEqual([4]);
|
||||
const impossiblePrefix = new Array<number>(16).fill(0);
|
||||
impossiblePrefix[0] = 2;
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "skyscraper", side: "left", index: 0, count: 4 },
|
||||
impossiblePrefix,
|
||||
4,
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "skyscraper", side: "top", index: 0, count: 8 },
|
||||
new Array<number>(16 * 16).fill(0),
|
||||
16,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("honours quadruple multiplicity in candidates and completed boards", () => {
|
||||
const constraint = {
|
||||
type: "quadruple",
|
||||
cells: [1, 2, 5, 6],
|
||||
digits: [2, 2, 4],
|
||||
} as const;
|
||||
const values = new Array<number>(16).fill(0);
|
||||
values[1] = 2;
|
||||
values[2] = 3;
|
||||
values[5] = 4;
|
||||
expect(candidatesForCell(puzzle4([constraint]), values, 6)).toEqual([2]);
|
||||
|
||||
const wrong = [...values];
|
||||
wrong[6] = 1;
|
||||
expect(constraintIsFeasible(constraint, wrong, 4)).toBe(false);
|
||||
const right = [...values];
|
||||
right[6] = 2;
|
||||
expect(constraintIsFeasible(constraint, right, 4)).toBe(true);
|
||||
});
|
||||
|
||||
it("prunes false clues as soon as their positive statement is fixed", () => {
|
||||
const xSumValues = new Array<number>(16).fill(0);
|
||||
xSumValues.splice(0, 3, 3, 1, 2);
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 6, negated: true },
|
||||
xSumValues,
|
||||
4,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 7, negated: true },
|
||||
xSumValues,
|
||||
4,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const quadrupleValues = new Array<number>(16).fill(0);
|
||||
quadrupleValues[0] = 1;
|
||||
quadrupleValues[1] = 2;
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{
|
||||
type: "quadruple",
|
||||
cells: [0, 1, 4, 5],
|
||||
digits: [1, 2],
|
||||
negated: true,
|
||||
},
|
||||
quadrupleValues,
|
||||
4,
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const maximumValues = new Array<number>(16).fill(0);
|
||||
maximumValues[5] = 4;
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{ type: "maximum", cell: 5, negated: true },
|
||||
maximumValues,
|
||||
4,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires a maximum cell to exceed every orthogonal neighbour", () => {
|
||||
const values = new Array<number>(16).fill(0);
|
||||
values[1] = 4;
|
||||
expect(
|
||||
candidatesForCell(puzzle4([{ type: "maximum", cell: 5 }]), values, 5),
|
||||
).toEqual([]);
|
||||
|
||||
expect(constraintIsFeasible({ type: "maximum", cell: 5 }, solved4, 4)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(constraintIsFeasible({ type: "maximum", cell: 6 }, solved4, 4)).toBe(
|
||||
false,
|
||||
);
|
||||
const conflict = findConflicts(
|
||||
puzzle4([{ type: "maximum", cell: 6 }]),
|
||||
solved4,
|
||||
).find(({ kind }) => kind === "constraint");
|
||||
expect(conflict?.cells).toEqual([6, 2, 10, 5, 7]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("false-clue semantics", () => {
|
||||
it("validates and clones polarity for every local clue type", () => {
|
||||
const constraints: VariantConstraint[] = [
|
||||
{
|
||||
type: "killer-cage",
|
||||
cells: [0, 6],
|
||||
sum: 42,
|
||||
negated: true,
|
||||
},
|
||||
{ type: "thermo", cells: [0, 1], negated: true },
|
||||
{ type: "arrow", bulb: [0], line: [1], negated: true },
|
||||
{ type: "kropki", a: 0, b: 1, kind: "white", negated: true },
|
||||
{ type: "xv", a: 0, b: 1, total: 5, negated: true },
|
||||
{ type: "inequality", lesser: 0, greater: 1, negated: true },
|
||||
{ type: "renban", cells: [0, 1], negated: true },
|
||||
{ type: "palindrome", cells: [0, 1], negated: true },
|
||||
{
|
||||
type: "x-sum",
|
||||
side: "left",
|
||||
index: 0,
|
||||
sum: 2,
|
||||
negated: true,
|
||||
},
|
||||
{
|
||||
type: "skyscraper",
|
||||
side: "left",
|
||||
index: 0,
|
||||
count: 1,
|
||||
negated: true,
|
||||
},
|
||||
{
|
||||
type: "quadruple",
|
||||
cells: [0, 1, 4, 5],
|
||||
digits: [1, 1],
|
||||
negated: true,
|
||||
},
|
||||
{ type: "maximum", cell: 5, negated: true },
|
||||
];
|
||||
|
||||
const definition = puzzle4(constraints);
|
||||
expect(validatePuzzle(definition)).toEqual({ valid: true, issues: [] });
|
||||
const normalized = normalizePuzzle(definition);
|
||||
expect(normalized.constraints).toEqual(constraints);
|
||||
expect(normalized.constraints).not.toBe(constraints);
|
||||
expect(normalized.constraints[0]).not.toBe(constraints[0]);
|
||||
});
|
||||
|
||||
it("does not turn a false cage's no-repeat rule into an unconditional peer", () => {
|
||||
const falseCage = compilePuzzle(
|
||||
normalizePuzzle(
|
||||
puzzle4([
|
||||
{
|
||||
type: "killer-cage",
|
||||
cells: [0, 6],
|
||||
sum: 3,
|
||||
negated: true,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
const ordinaryCage = compilePuzzle(
|
||||
normalizePuzzle(
|
||||
puzzle4([{ type: "killer-cage", cells: [0, 6], sum: 3 }]),
|
||||
),
|
||||
);
|
||||
|
||||
expect(falseCage.peers[0]?.has(6)).toBe(false);
|
||||
expect(ordinaryCage.peers[0]?.has(6)).toBe(true);
|
||||
|
||||
const repeated = new Array<number>(16).fill(0);
|
||||
repeated[0] = 1;
|
||||
repeated[6] = 1;
|
||||
expect(
|
||||
constraintIsFeasible(
|
||||
{
|
||||
type: "killer-cage",
|
||||
cells: [0, 6],
|
||||
sum: 3,
|
||||
negated: true,
|
||||
},
|
||||
repeated,
|
||||
4,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("inverts completed dots, XV, thermometers and cages", () => {
|
||||
const cases: Array<
|
||||
readonly [VariantConstraint, readonly number[], boolean]
|
||||
> = [
|
||||
[
|
||||
{ type: "kropki", a: 0, b: 1, kind: "white", negated: true },
|
||||
[1, 2],
|
||||
false,
|
||||
],
|
||||
[
|
||||
{ type: "kropki", a: 0, b: 1, kind: "white", negated: true },
|
||||
[1, 3],
|
||||
true,
|
||||
],
|
||||
[{ type: "xv", a: 0, b: 1, total: 5, negated: true }, [1, 4], false],
|
||||
[{ type: "xv", a: 0, b: 1, total: 5, negated: true }, [1, 3], true],
|
||||
[{ type: "thermo", cells: [0, 1, 2], negated: true }, [1, 2, 3], false],
|
||||
[{ type: "thermo", cells: [0, 1, 2], negated: true }, [1, 3, 2], true],
|
||||
[
|
||||
{ type: "killer-cage", cells: [0, 1], sum: 3, negated: true },
|
||||
[1, 2],
|
||||
false,
|
||||
],
|
||||
[
|
||||
{ type: "killer-cage", cells: [0, 1], sum: 4, negated: true },
|
||||
[1, 2],
|
||||
true,
|
||||
],
|
||||
];
|
||||
for (const [constraint, relevant, expected] of cases) {
|
||||
const values = new Array<number>(16).fill(0);
|
||||
relevant.forEach((value, cell) => {
|
||||
values[cell] = value;
|
||||
});
|
||||
expect(
|
||||
constraintIsFeasible(constraint, values, 4),
|
||||
JSON.stringify(constraint),
|
||||
).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("inverts each completed outside and local clue", () => {
|
||||
const cases: Array<readonly [VariantConstraint, boolean]> = [
|
||||
[{ type: "x-sum", side: "left", index: 0, sum: 1, negated: true }, false],
|
||||
[{ type: "x-sum", side: "left", index: 0, sum: 6, negated: true }, true],
|
||||
[
|
||||
{
|
||||
type: "skyscraper",
|
||||
side: "left",
|
||||
index: 0,
|
||||
count: 4,
|
||||
negated: true,
|
||||
},
|
||||
false,
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "skyscraper",
|
||||
side: "left",
|
||||
index: 0,
|
||||
count: 3,
|
||||
negated: true,
|
||||
},
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "quadruple",
|
||||
cells: [1, 2, 5, 6],
|
||||
digits: [1, 2, 3, 4],
|
||||
negated: true,
|
||||
},
|
||||
false,
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "quadruple",
|
||||
cells: [1, 2, 5, 6],
|
||||
digits: [2, 2, 4],
|
||||
negated: true,
|
||||
},
|
||||
true,
|
||||
],
|
||||
[{ type: "maximum", cell: 5, negated: true }, false],
|
||||
[{ type: "maximum", cell: 6, negated: true }, true],
|
||||
];
|
||||
for (const [constraint, expected] of cases) {
|
||||
expect(constraintIsFeasible(constraint, solved4, 4)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps partial false clues feasible and prunes a completed true pair", () => {
|
||||
const falseDot = {
|
||||
type: "kropki",
|
||||
a: 0,
|
||||
b: 1,
|
||||
kind: "white",
|
||||
negated: true,
|
||||
} as const;
|
||||
const partial = new Array<number>(16).fill(0);
|
||||
partial[0] = 1;
|
||||
expect(constraintIsFeasible(falseDot, partial, 4)).toBe(true);
|
||||
expect(candidatesForCell(puzzle4([falseDot]), partial, 1)).toEqual([3, 4]);
|
||||
|
||||
const falseThermo = {
|
||||
type: "thermo",
|
||||
cells: [0, 1, 2],
|
||||
negated: true,
|
||||
} as const;
|
||||
partial[1] = 2;
|
||||
expect(constraintIsFeasible(falseThermo, partial, 4)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports a true false-clue as the contradiction", () => {
|
||||
const conflict = findConflicts(
|
||||
puzzle4([{ type: "xv", a: 0, b: 3, total: 5, negated: true }]),
|
||||
solved4,
|
||||
).find(({ kind }) => kind === "constraint");
|
||||
|
||||
expect(conflict?.message).toBe("xv clue is true but must be false.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
SUDOKU_DOCUMENT_SCHEMA,
|
||||
cloneSudokuDocument,
|
||||
normalizeSudokuDocument,
|
||||
parseSudokuDocument,
|
||||
serializeSudokuDocument,
|
||||
type SudokuDocument,
|
||||
} from "../../src/formats";
|
||||
import {
|
||||
aidMemoireToPortable,
|
||||
createAidMemoire,
|
||||
enterAidMemoireCell,
|
||||
labelAidMemoireCell,
|
||||
MAX_AID_MEMOIRE_CELLS,
|
||||
} from "../../src/state/aidMemoire";
|
||||
|
||||
function baseDocument(): SudokuDocument {
|
||||
return {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: 1,
|
||||
size: 9,
|
||||
givens: Array<number>(81).fill(0),
|
||||
constraints: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("portable aid-mémoire document data", () => {
|
||||
it("normalizes, serializes and deeply clones scratch cells", () => {
|
||||
let state = createAidMemoire(9, {
|
||||
enabled: true,
|
||||
cellCount: 3,
|
||||
columns: 2,
|
||||
});
|
||||
state = labelAidMemoireCell(state, 0, "Prime");
|
||||
state = enterAidMemoireCell(state, 0, "corner", 2, 9);
|
||||
state = enterAidMemoireCell(state, 0, "center", 7, 9);
|
||||
state = enterAidMemoireCell(state, 0, "color", 4, 9);
|
||||
const source: SudokuDocument = {
|
||||
...baseDocument(),
|
||||
aidMemoire: aidMemoireToPortable(state, 9),
|
||||
};
|
||||
|
||||
const parsed = parseSudokuDocument(serializeSudokuDocument(source));
|
||||
expect(parsed.aidMemoire).toEqual(source.aidMemoire);
|
||||
const clone = cloneSudokuDocument(parsed);
|
||||
(clone.aidMemoire!.cells[0]!.cornerMarks as number[])[0] = 9;
|
||||
expect(parsed.aidMemoire?.cells[0]?.cornerMarks).toEqual([2]);
|
||||
});
|
||||
|
||||
it("rejects unbounded layouts and out-of-range scratch symbols", () => {
|
||||
expect(() =>
|
||||
normalizeSudokuDocument({
|
||||
...baseDocument(),
|
||||
aidMemoire: {
|
||||
version: 1,
|
||||
enabled: true,
|
||||
columns: 1,
|
||||
cells: Array.from({ length: MAX_AID_MEMOIRE_CELLS + 1 }, () => ({})),
|
||||
},
|
||||
}),
|
||||
).toThrow(/aidMemoire.cells must contain 1 to 36/u);
|
||||
expect(() =>
|
||||
normalizeSudokuDocument({
|
||||
...baseDocument(),
|
||||
aidMemoire: {
|
||||
version: 1,
|
||||
enabled: true,
|
||||
columns: 1,
|
||||
cells: [{ label: "Bad", value: 10 }],
|
||||
},
|
||||
}),
|
||||
).toThrow(/value must be an integer from 0 to 9/u);
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,30 @@ describe("Sudoku Tools document format", () => {
|
||||
centerMarks: Array.from({ length: 81 }, () => [7, 4]),
|
||||
colors: Array.from({ length: 81 }, (_, index) => index % 9),
|
||||
elapsedMs: 12_345,
|
||||
constraints: [{ type: "killer-cage", cells: [0, 1], sum: 3 }],
|
||||
constraints: [
|
||||
{ type: "killer-cage", cells: [0, 1], sum: 3 },
|
||||
{
|
||||
type: "x-sum",
|
||||
side: "top",
|
||||
index: 8,
|
||||
sum: 1111,
|
||||
negated: true,
|
||||
},
|
||||
{
|
||||
type: "skyscraper",
|
||||
side: "left",
|
||||
index: 4,
|
||||
count: 898,
|
||||
negated: true,
|
||||
},
|
||||
{
|
||||
type: "quadruple",
|
||||
cells: [0, 1, 9, 10],
|
||||
digits: [1, 3, 8],
|
||||
negated: true,
|
||||
},
|
||||
{ type: "maximum", cell: 10, negated: true },
|
||||
],
|
||||
};
|
||||
const parsed = parseSudokuDocument(serializeSudokuDocument(source));
|
||||
expect(parsed).toEqual({
|
||||
@@ -59,6 +82,12 @@ describe("Sudoku Tools document format", () => {
|
||||
values: [2, ...Array<number>(15).fill(0)],
|
||||
}),
|
||||
).toThrow(/preserve its given digit/u);
|
||||
expect(() =>
|
||||
normalizeSudokuDocument({
|
||||
...document(Array(81).fill(0)),
|
||||
constraints: [{ type: "quadruple", cells: [0], digits: [1, 2] }],
|
||||
}),
|
||||
).toThrow(/no more digits than clue cells/u);
|
||||
});
|
||||
|
||||
it("round-trips compact share hashes", () => {
|
||||
|
||||
@@ -52,6 +52,12 @@ describe("fpuzzles interoperability", () => {
|
||||
inequality: [{ cells: ["R6C1", "R6C2"], value: ">" }],
|
||||
renban: [{ lines: [["R7C1", "R7C2"]] }],
|
||||
palindrome: [{ lines: [["R8C1", "R8C2"]] }],
|
||||
xsum: [{ cell: "R0C1", value: "15" }],
|
||||
skyscraper: [{ cell: "R2C10", value: "2" }],
|
||||
quadruple: [
|
||||
{ cells: ["R1C1", "R1C2", "R2C1", "R2C2"], values: [1, 3, 8] },
|
||||
],
|
||||
maximum: [{ cell: "R2C2" }],
|
||||
});
|
||||
expect(puzzle.givens[0]).toBe(5);
|
||||
expect(puzzle.values?.[1]).toBe(3);
|
||||
@@ -64,6 +70,10 @@ describe("fpuzzles interoperability", () => {
|
||||
{ type: "kropki", a: 27, b: 28, kind: "white" },
|
||||
{ type: "kropki", a: 28, b: 29, kind: "black" },
|
||||
{ type: "inequality", lesser: 46, greater: 45 },
|
||||
{ type: "x-sum", side: "top", index: 0, sum: 15 },
|
||||
{ type: "skyscraper", side: "right", index: 1, count: 2 },
|
||||
{ type: "quadruple", cells: [0, 1, 9, 10], digits: [1, 3, 8] },
|
||||
{ type: "maximum", cell: 10 },
|
||||
]),
|
||||
);
|
||||
expect(() => normalizePuzzle(toDomainPuzzle(puzzle))).not.toThrow();
|
||||
@@ -85,6 +95,10 @@ describe("fpuzzles interoperability", () => {
|
||||
{ type: "inequality", lesser: 45, greater: 46 },
|
||||
{ type: "renban", cells: [54, 55] },
|
||||
{ type: "palindrome", cells: [63, 64] },
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 6 },
|
||||
{ type: "skyscraper", side: "right", index: 1, count: 2 },
|
||||
{ type: "quadruple", cells: [0, 1, 9, 10], digits: [1, 3, 8] },
|
||||
{ type: "maximum", cell: 10 },
|
||||
],
|
||||
title: "Round trip",
|
||||
};
|
||||
@@ -110,6 +124,53 @@ describe("fpuzzles interoperability", () => {
|
||||
).toThrow(/local-only app cannot fetch/u);
|
||||
});
|
||||
|
||||
it("refuses to weaken false clues during fpuzzles export", () => {
|
||||
const source: SudokuDocument = {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: 1,
|
||||
size: 9,
|
||||
givens: Array<number>(81).fill(0),
|
||||
constraints: [
|
||||
{
|
||||
type: "x-sum",
|
||||
side: "top",
|
||||
index: 0,
|
||||
sum: 11,
|
||||
negated: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(() => exportFpuzzles(source)).toThrow(/individually false clue/u);
|
||||
});
|
||||
|
||||
it("rejects out-of-range outside clues during fpuzzles export", () => {
|
||||
const source = {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: 1,
|
||||
size: 9,
|
||||
givens: Array<number>(81).fill(0),
|
||||
} as const;
|
||||
|
||||
for (const constraint of [
|
||||
{ type: "x-sum", side: "top", index: -1, sum: 15 },
|
||||
{ type: "x-sum", side: "bottom", index: 9, sum: 15 },
|
||||
{ type: "x-sum", side: "right", index: 0, sum: 0 },
|
||||
{ type: "x-sum", side: "left", index: 0, sum: 46 },
|
||||
{ type: "skyscraper", side: "right", index: -1, count: 2 },
|
||||
{ type: "skyscraper", side: "left", index: 9, count: 2 },
|
||||
{ type: "skyscraper", side: "bottom", index: 0, count: 0 },
|
||||
{ type: "skyscraper", side: "top", index: 0, count: 10 },
|
||||
] as const) {
|
||||
expect(() =>
|
||||
exportFpuzzles({
|
||||
...source,
|
||||
constraints: [constraint],
|
||||
}),
|
||||
).toThrow(/outside the supported range/u);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unsupported generalized dots instead of weakening them", () => {
|
||||
expect(() =>
|
||||
parseFpuzzles({
|
||||
@@ -120,6 +181,40 @@ describe("fpuzzles interoperability", () => {
|
||||
).toThrow(/Difference-2 dots are not supported/u);
|
||||
});
|
||||
|
||||
it("rejects quadruples spanning more than four cells", () => {
|
||||
expect(() =>
|
||||
parseFpuzzles({
|
||||
size: 9,
|
||||
grid: emptyGrid(),
|
||||
quadruple: [
|
||||
{
|
||||
cells: ["R1C1", "R1C2", "R2C1", "R2C2", "R3C3"],
|
||||
values: [1, 2],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow(/one to four cells and clue digits/u);
|
||||
expect(() =>
|
||||
parseFpuzzles({
|
||||
size: 9,
|
||||
grid: emptyGrid(),
|
||||
quadruple: [{ cells: ["R1C1"], values: [1, 2] }],
|
||||
}),
|
||||
).toThrow(/one to four cells and clue digits/u);
|
||||
expect(() =>
|
||||
parseFpuzzles({
|
||||
size: 9,
|
||||
grid: emptyGrid(),
|
||||
quadruple: [
|
||||
{
|
||||
cells: ["R1C1", "R1C3", "R2C1", "R2C3"],
|
||||
values: [1, 2],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow(/four cells surrounding one grid intersection/u);
|
||||
});
|
||||
|
||||
it("rejects unsupported or unknown rules instead of silently weakening them", () => {
|
||||
expect(() =>
|
||||
parseFpuzzles({
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import fc from "fast-check";
|
||||
import { compressToBase64, compressToEncodedURIComponent } from "lz-string";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
LzStringOutputLimitError,
|
||||
decompressFromBase64Bounded,
|
||||
decompressFromBase64OrUriComponentBounded,
|
||||
decompressFromEncodedURIComponentBounded,
|
||||
} from "../../src/formats/boundedLz";
|
||||
import {
|
||||
MAX_DOCUMENT_BYTES,
|
||||
PUZZLE_HASH_PREFIX,
|
||||
SudokuFormatError,
|
||||
decodePuzzleHash,
|
||||
importFpuzzles,
|
||||
importPuzzle,
|
||||
importSudokuPad,
|
||||
parseFpuzzles,
|
||||
} from "../../src/formats";
|
||||
|
||||
function emptyGrid(size = 4) {
|
||||
return Array.from({ length: size }, () =>
|
||||
Array.from({ length: size }, () => ({})),
|
||||
);
|
||||
}
|
||||
|
||||
function expectLimitExceeded(action: () => unknown): void {
|
||||
try {
|
||||
action();
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(SudokuFormatError);
|
||||
expect(error).toMatchObject({ code: "LIMIT_EXCEEDED" });
|
||||
return;
|
||||
}
|
||||
throw new Error("Expected the import to reject an oversized payload.");
|
||||
}
|
||||
|
||||
describe("bounded puzzle imports", () => {
|
||||
it("keeps both LZ-String wire formats compatible for arbitrary text", () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string({ maxLength: 512 }), (source) => {
|
||||
const byteLength = new TextEncoder().encode(source).byteLength;
|
||||
const limit = Math.max(1, byteLength);
|
||||
expect(
|
||||
decompressFromBase64Bounded(compressToBase64(source), limit),
|
||||
).toBe(source);
|
||||
expect(
|
||||
decompressFromEncodedURIComponentBounded(
|
||||
compressToEncodedURIComponent(source),
|
||||
limit,
|
||||
),
|
||||
).toBe(source);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("counts UTF-8 bytes while expanding and stops before oversized output", () => {
|
||||
const source = "🧩".repeat(1_024);
|
||||
const compressed = compressToEncodedURIComponent(source);
|
||||
const exactBytes = new TextEncoder().encode(source).byteLength;
|
||||
|
||||
expect(
|
||||
decompressFromEncodedURIComponentBounded(compressed, exactBytes),
|
||||
).toBe(source);
|
||||
expect(() =>
|
||||
decompressFromEncodedURIComponentBounded(compressed, exactBytes - 1),
|
||||
).toThrow(LzStringOutputLimitError);
|
||||
});
|
||||
|
||||
it("selects URI-safe payloads before an invalid Base64 interpretation", () => {
|
||||
const title = "AE(a^'XS2pclC*+Q: ?8IJnG(Fe-nR";
|
||||
const grid = emptyGrid();
|
||||
const fpuzzlesJson = JSON.stringify({ size: 4, grid, title });
|
||||
const fpuzzlesPayload = compressToEncodedURIComponent(fpuzzlesJson);
|
||||
const sudokuPadJson = JSON.stringify({
|
||||
id: "local-scl",
|
||||
cells: grid,
|
||||
metadata: {
|
||||
title,
|
||||
author: "Tester",
|
||||
rules: "Normal rules apply.",
|
||||
antiknight: true,
|
||||
},
|
||||
cages: [
|
||||
{
|
||||
cells: [
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
],
|
||||
value: "3",
|
||||
unique: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const sudokuPadPayload = compressToEncodedURIComponent(sudokuPadJson);
|
||||
|
||||
expect(fpuzzlesPayload).toMatch(/[-$]/u);
|
||||
expect(sudokuPadPayload).toMatch(/[-$]/u);
|
||||
expect(
|
||||
decompressFromBase64Bounded(fpuzzlesPayload, MAX_DOCUMENT_BYTES),
|
||||
).not.toBe(fpuzzlesJson);
|
||||
expect(
|
||||
decompressFromBase64Bounded(sudokuPadPayload, MAX_DOCUMENT_BYTES),
|
||||
).not.toBe(sudokuPadJson);
|
||||
expect(
|
||||
decompressFromBase64OrUriComponentBounded(
|
||||
fpuzzlesPayload,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
),
|
||||
).toBe(fpuzzlesJson);
|
||||
expect(importFpuzzles(`fpuzzles${fpuzzlesPayload}`).title).toBe(title);
|
||||
expect(importSudokuPad(`ctc${sudokuPadPayload}`).title).toBe(title);
|
||||
});
|
||||
|
||||
it("rejects compressed bombs in every LZ-backed import format", () => {
|
||||
const oversized = " ".repeat(MAX_DOCUMENT_BYTES + 1);
|
||||
const base64 = compressToBase64(oversized);
|
||||
const uri = compressToEncodedURIComponent(oversized);
|
||||
|
||||
expectLimitExceeded(() => importFpuzzles(`fpuzzles${base64}`));
|
||||
expectLimitExceeded(() => importSudokuPad(`ctc${base64}`));
|
||||
expectLimitExceeded(() => decodePuzzleHash(`${PUZZLE_HASH_PREFIX}${uri}`));
|
||||
});
|
||||
|
||||
it("checks raw JSON character and byte limits before parsing", async () => {
|
||||
const tooManyCharacters = `{${" ".repeat(MAX_DOCUMENT_BYTES)}}`;
|
||||
await expect(importPuzzle(tooManyCharacters)).rejects.toMatchObject({
|
||||
code: "LIMIT_EXCEEDED",
|
||||
});
|
||||
|
||||
const tooManyUtf8Bytes = `{"future":"${"é".repeat(
|
||||
Math.floor(MAX_DOCUMENT_BYTES / 2),
|
||||
)}"}`;
|
||||
expect(tooManyUtf8Bytes.length).toBeLessThan(MAX_DOCUMENT_BYTES);
|
||||
await expect(importPuzzle(tooManyUtf8Bytes)).rejects.toMatchObject({
|
||||
code: "LIMIT_EXCEEDED",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown fpuzzles inequality markers", () => {
|
||||
for (const value of [undefined, "≤", "left", 1]) {
|
||||
expect(() =>
|
||||
parseFpuzzles({
|
||||
size: 4,
|
||||
grid: emptyGrid(),
|
||||
inequality: [{ cells: ["R1C1", "R1C2"], value }],
|
||||
}),
|
||||
).toThrow(/inequality\.value must be either/u);
|
||||
}
|
||||
|
||||
expect(
|
||||
parseFpuzzles({
|
||||
size: 4,
|
||||
grid: emptyGrid(),
|
||||
inequality: [{ cells: ["R1C1", "R1C2"], value: "<" }],
|
||||
}).constraints,
|
||||
).toContainEqual({ type: "inequality", lesser: 0, greater: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { compressToBase64 } from "lz-string";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
NetworkPuzzleIdError,
|
||||
UnsupportedPuzzleConstructsError,
|
||||
importPenpa,
|
||||
importPuzzle,
|
||||
importSudokuPad,
|
||||
parsePenpaText,
|
||||
parseSudokuPadPuzzle,
|
||||
} from "../../src/formats";
|
||||
|
||||
const penpaText = [
|
||||
"square,4,4,40,0,1,1,320,320,18,18,1,1,1,1,Title: Tiny,Author: Test,,Normal Sudoku rules apply.,ON,false",
|
||||
"[0,0,0,0]",
|
||||
"{}",
|
||||
JSON.stringify({
|
||||
number: { 18: [1, 1, "1"], 45: [4, 1, "1"] },
|
||||
thermo: [[19, 20]],
|
||||
arrows: [[26, 27]],
|
||||
}),
|
||||
JSON.stringify({ number: { 21: [2, 2, "1"] } }),
|
||||
"[18,1,1,1,5,1,1,1,5,1,1,1,5,1,1,1]",
|
||||
"[]",
|
||||
].join("\n");
|
||||
|
||||
const compressedPenpa =
|
||||
"bY/LCsIwEEX3+Yow64sksb6y8wd0YXchi4gRxbTRpEGk9N+lKoIgZ4bLncWBybfikkc1IiAgITFV4rVy+ZoP9bkLXvP63D6wLt0pJs1rnztgE1PjAt+VQ7wUnkrwmbvrNTwm2G5wdCF7Zka5gLCsH1hPbWn2PpHuSS5Jm9FPkiyompE21acOoO7kUxNJGyNXUMJakEsp3vN4UnOohbW/QiVJGwX1NgzMfH+Y/U/LjH0C";
|
||||
|
||||
function sclPuzzle() {
|
||||
const cells = Array.from({ length: 4 }, () =>
|
||||
Array.from({ length: 4 }, () => ({})),
|
||||
);
|
||||
cells[0]![0] = { value: "1" };
|
||||
cells[0]![1] = {
|
||||
value: 2,
|
||||
given: false,
|
||||
pencilMarks: [4, 3],
|
||||
centremarks: [2],
|
||||
};
|
||||
return {
|
||||
id: "local-scl",
|
||||
cells,
|
||||
metadata: {
|
||||
title: "Local SCL",
|
||||
author: "Tester",
|
||||
rules: "Normal rules apply.",
|
||||
antiknight: true,
|
||||
},
|
||||
cages: [
|
||||
{
|
||||
cells: [
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
],
|
||||
value: "3",
|
||||
unique: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("local puzzle interoperability", () => {
|
||||
it("imports bounded SudokuPad/CTC data and retains semantic features", () => {
|
||||
const parsed = parseSudokuPadPuzzle(sclPuzzle());
|
||||
|
||||
expect(parsed.size).toBe(4);
|
||||
expect(parsed.givens.slice(0, 2)).toEqual([1, 0]);
|
||||
expect(parsed.values?.slice(0, 2)).toEqual([1, 2]);
|
||||
expect(parsed.cornerMarks?.[1]).toEqual([3, 4]);
|
||||
expect(parsed.constraints).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
type: "killer-cage",
|
||||
cells: [0, 1],
|
||||
sum: 3,
|
||||
noRepeat: true,
|
||||
},
|
||||
{ type: "anti-knight" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("imports a self-contained ctc payload locally", async () => {
|
||||
const inline = `ctc${compressToBase64(JSON.stringify(sclPuzzle()))}`;
|
||||
const direct = importSudokuPad(inline);
|
||||
const detected = await importPuzzle(`https://sudokupad.app/${inline}`);
|
||||
|
||||
expect(direct.title).toBe("Local SCL");
|
||||
expect(detected.format).toBe("sudokupad");
|
||||
expect(detected.document.givens[0]).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects visual-only SCL constructs instead of weakening them", () => {
|
||||
expect(() =>
|
||||
parseSudokuPadPuzzle({
|
||||
...sclPuzzle(),
|
||||
lines: [
|
||||
{
|
||||
wayPoints: [
|
||||
[0.5, 0.5],
|
||||
[1.5, 1.5],
|
||||
],
|
||||
},
|
||||
],
|
||||
overlays: [{ text: "?" }],
|
||||
}),
|
||||
).toThrow(UnsupportedPuzzleConstructsError);
|
||||
expect(() =>
|
||||
parseSudokuPadPuzzle({
|
||||
...sclPuzzle(),
|
||||
lines: [{}],
|
||||
}),
|
||||
).toThrow(/visual lines/u);
|
||||
});
|
||||
|
||||
it("parses semantic Penpa+ Sudoku layers and local progress", () => {
|
||||
const parsed = parsePenpaText(penpaText);
|
||||
|
||||
expect(parsed.size).toBe(4);
|
||||
expect(parsed.title).toBe("Tiny");
|
||||
expect(parsed.author).toBe("Test");
|
||||
expect(parsed.givens[0]).toBe(1);
|
||||
expect(parsed.givens[15]).toBe(4);
|
||||
expect(parsed.values?.[3]).toBe(2);
|
||||
expect(parsed.constraints).toEqual([
|
||||
{ type: "thermo", cells: [1, 2] },
|
||||
{ type: "arrow", bulb: [4], line: [5] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects an already-decompressed Penpa+ text record", async () => {
|
||||
const parsed = await importPuzzle(penpaText);
|
||||
|
||||
expect(parsed.format).toBe("penpa");
|
||||
expect(parsed.label).toBe("Penpa+ text");
|
||||
expect(parsed.document.constraints).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("decompresses a complete Penpa+ long URL without fetching", async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const parsed = await importPenpa(
|
||||
`https://swaroopg92.github.io/penpa-edit/#m=solve&p=${compressedPenpa}`,
|
||||
);
|
||||
|
||||
expect(parsed.givens[0]).toBe(1);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("rejects unsupported Penpa+ drawings with a compatibility list", () => {
|
||||
const rows = penpaText.split("\n");
|
||||
rows[3] = JSON.stringify({
|
||||
number: { 18: [1, 1, "1"] },
|
||||
line: { "18,19": 3 },
|
||||
symbol: { 20: [1, "circle_L", 1] },
|
||||
});
|
||||
|
||||
expect(() => parsePenpaText(rows.join("\n"))).toThrow(
|
||||
/problem line, problem symbol/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("never resolves a server-only SudokuPad short ID", async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
await expect(
|
||||
importPuzzle("https://sudokupad.app/serverOnly42"),
|
||||
).rejects.toBeInstanceOf(NetworkPuzzleIdError);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
SUDOKU_DOCUMENT_SCHEMA,
|
||||
SUDOKU_DOCUMENT_VERSION,
|
||||
buildJpegPdf,
|
||||
renderPuzzleSvg,
|
||||
type SudokuDocument,
|
||||
} from "../../src/formats";
|
||||
|
||||
function visualDocument(): SudokuDocument {
|
||||
const values = Array.from({ length: 16 }, () => 0);
|
||||
values[0] = 1;
|
||||
values[15] = 4;
|
||||
const cornerMarks = Array.from({ length: 16 }, () => [] as number[]);
|
||||
cornerMarks[1] = [2, 3];
|
||||
const centerMarks = Array.from({ length: 16 }, () => [] as number[]);
|
||||
centerMarks[2] = [1, 4];
|
||||
return {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: SUDOKU_DOCUMENT_VERSION,
|
||||
size: 4,
|
||||
givens: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
values,
|
||||
cornerMarks,
|
||||
centerMarks,
|
||||
colors: [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
constraints: [
|
||||
{ type: "diagonal", direction: "main" },
|
||||
{ type: "killer-cage", cells: [0, 1], sum: 3 },
|
||||
{ type: "thermo", cells: [4, 5] },
|
||||
{ type: "arrow", bulb: [8], line: [9, 10] },
|
||||
{ type: "kropki", a: 2, b: 3, kind: "white" },
|
||||
{ type: "xv", a: 6, b: 7, total: 5 },
|
||||
{ type: "inequality", lesser: 10, greater: 11 },
|
||||
{ type: "renban", cells: [12, 13] },
|
||||
{ type: "palindrome", cells: [14, 15] },
|
||||
{ type: "maximum", cell: 5 },
|
||||
{ type: "quadruple", cells: [0, 1, 4, 5], digits: [1, 2] },
|
||||
{ type: "x-sum", side: "top", index: 0, sum: 1 },
|
||||
{ type: "skyscraper", side: "left", index: 2, count: 2 },
|
||||
{ type: "anti-knight" },
|
||||
],
|
||||
title: 'A < B & "C"',
|
||||
author: "Synthetic test",
|
||||
};
|
||||
}
|
||||
|
||||
describe("visual puzzle export", () => {
|
||||
it("renders a standalone SVG with clues, regions, givens and progress", () => {
|
||||
const svg = renderPuzzleSvg(visualDocument());
|
||||
const parsed = new DOMParser().parseFromString(svg, "image/svg+xml");
|
||||
|
||||
expect(parsed.querySelector("parsererror")).toBeNull();
|
||||
expect(parsed.documentElement.localName).toBe("svg");
|
||||
expect(svg).toContain("A < B & "C"");
|
||||
expect(svg).toContain('class="constraint diagonal" x1="58" y1="132"');
|
||||
expect(svg).toContain('class="constraint cage"');
|
||||
expect(svg).toContain('class="constraint thermo"');
|
||||
expect(svg).toContain('class="constraint arrow"');
|
||||
expect(svg).toContain('class="constraint outside x-sum"');
|
||||
expect(svg).toContain('class="constraint xv total-5"');
|
||||
expect(svg).toContain(">5</text>");
|
||||
expect(svg).not.toContain(">V</text>");
|
||||
expect(svg).toContain('class="inequality-tip"');
|
||||
expect(svg).toContain('class="cell-value given"');
|
||||
expect(svg).toContain('class="cell-value progress"');
|
||||
expect(svg).toContain('class="corner-note" x=');
|
||||
expect(svg).toContain('class="center-note" x=');
|
||||
expect(svg).not.toMatch(/<(?:script|image)|(?:href|src)=/iu);
|
||||
});
|
||||
|
||||
it("can omit all solving progress from the visual", () => {
|
||||
const svg = renderPuzzleSvg(visualDocument(), {
|
||||
includeProgress: false,
|
||||
includeNotes: false,
|
||||
});
|
||||
|
||||
expect(svg).toContain('class="cell-value given"');
|
||||
expect(svg).not.toContain('class="cell-value progress"');
|
||||
expect(svg).not.toContain('class="corner-note" x=');
|
||||
expect(svg).not.toContain('class="center-note" x=');
|
||||
expect(svg).not.toContain('class="cell-color"');
|
||||
});
|
||||
|
||||
it("builds a bounded single-page PDF around local JPEG bytes", () => {
|
||||
const pdf = buildJpegPdf(
|
||||
new Uint8Array([0xff, 0xd8, 0xff, 0xd9]),
|
||||
320,
|
||||
240,
|
||||
);
|
||||
const text = new TextDecoder("latin1").decode(pdf);
|
||||
|
||||
expect(text.startsWith("%PDF-1.4")).toBe(true);
|
||||
expect(text).toContain("/DCTDecode");
|
||||
expect(text).toContain("xref\n0 6");
|
||||
expect(text.endsWith("%%EOF\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid or unbounded PDF image input", () => {
|
||||
expect(() => buildJpegPdf(new Uint8Array([0, 1, 2, 3]), 320, 240)).toThrow(
|
||||
/JPEG/u,
|
||||
);
|
||||
expect(() =>
|
||||
buildJpegPdf(new Uint8Array([0xff, 0xd8, 0xff, 0xd9]), 99_999, 240),
|
||||
).toThrow(/dimensions/u);
|
||||
});
|
||||
|
||||
it("validates progress before embedding it in a visual", () => {
|
||||
const document = visualDocument();
|
||||
const invalidMarks = Array.from({ length: 16 }, () => [] as number[]);
|
||||
invalidMarks[0] = [99];
|
||||
|
||||
expect(() =>
|
||||
renderPuzzleSvg({ ...document, cornerMarks: invalidMarks }),
|
||||
).toThrow(/cornerMarks/u);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createEmptyPuzzle, normalizePuzzle } from "../../src/domain";
|
||||
import {
|
||||
candidateCellsForValues,
|
||||
candidateUnitIndicesForCells,
|
||||
candidateValuesFromMask,
|
||||
deriveCandidateLinks,
|
||||
inspectCandidateCells,
|
||||
inspectCandidateHouse,
|
||||
} from "../../src/helpers";
|
||||
|
||||
function mask(...values: number[]): number {
|
||||
return values.reduce((result, value) => result | (1 << (value - 1)), 0);
|
||||
}
|
||||
|
||||
const puzzle = normalizePuzzle(createEmptyPuzzle(4));
|
||||
|
||||
describe("candidate inspection", () => {
|
||||
it("decodes masks and identifies the houses touching selected cells", () => {
|
||||
expect(candidateValuesFromMask(mask(1, 3, 4), 4)).toEqual([1, 3, 4]);
|
||||
expect(candidateUnitIndicesForCells(puzzle, [0])).toEqual([0, 1, 2]);
|
||||
|
||||
const cells = inspectCandidateCells(
|
||||
puzzle,
|
||||
[mask(1, 4), ...new Array<number>(15).fill(0)],
|
||||
[0],
|
||||
);
|
||||
expect(cells).toEqual([
|
||||
{
|
||||
cell: 0,
|
||||
values: [1, 4],
|
||||
houseLabels: ["Row 1", "Column 1", "Region 1"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports missing values, candidate positions and conjugate pairs in a house", () => {
|
||||
const values = [4, 0, 0, 2, ...new Array<number>(12).fill(0)];
|
||||
const masks = new Array<number>(16).fill(0);
|
||||
masks[1] = mask(1, 3);
|
||||
masks[2] = mask(1, 3);
|
||||
|
||||
expect(inspectCandidateHouse(puzzle, values, masks, 0)).toMatchObject({
|
||||
label: "Row 1",
|
||||
missingValues: [1, 3],
|
||||
positions: [
|
||||
{ value: 1, cells: [1, 2], linkKind: "strong" },
|
||||
{ value: 3, cells: [1, 2], linkKind: "strong" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("candidate link graph", () => {
|
||||
it("derives strong house and bivalue links and merges shared contexts", () => {
|
||||
const masks = new Array<number>(16).fill(0);
|
||||
masks[0] = mask(1, 2);
|
||||
masks[1] = mask(1, 3);
|
||||
|
||||
const links = deriveCandidateLinks(puzzle, masks);
|
||||
const houseLink = links.find(
|
||||
({ a, b }) =>
|
||||
a.cell === 0 && a.value === 1 && b.cell === 1 && b.value === 1,
|
||||
);
|
||||
expect(houseLink).toMatchObject({ kind: "strong" });
|
||||
expect(houseLink?.contexts.map(({ label }) => label)).toEqual([
|
||||
"Row 1",
|
||||
"Region 1",
|
||||
]);
|
||||
expect(links).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "strong",
|
||||
a: { cell: 0, value: 1 },
|
||||
b: { cell: 0, value: 2 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps larger candidate groups weak and supports digit and house scopes", () => {
|
||||
const masks = new Array<number>(16).fill(0);
|
||||
masks[0] = mask(2, 4);
|
||||
masks[1] = mask(2);
|
||||
masks[2] = mask(2);
|
||||
masks[3] = mask(3, 4);
|
||||
|
||||
const links = deriveCandidateLinks(puzzle, masks, {
|
||||
unitIndices: [0],
|
||||
values: [2],
|
||||
});
|
||||
expect(links).toHaveLength(3);
|
||||
expect(links.every(({ kind }) => kind === "weak")).toBe(true);
|
||||
expect(links.every(({ a, b }) => a.value === 2 && b.value === 2)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(candidateCellsForValues(masks, [4])).toEqual([0, 3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import fc from "fast-check";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { analyzeSumLab, sumCombinationKey } from "../../src/helpers/sumLab";
|
||||
|
||||
function mask(...digits: number[]): number {
|
||||
return digits.reduce((value, digit) => value | (2 ** (digit - 1)), 0);
|
||||
}
|
||||
|
||||
describe("generalized sum lab", () => {
|
||||
it("enumerates deterministic, sorted combinations", () => {
|
||||
const result = analyzeSumLab({ cellCount: 2, target: 10 });
|
||||
|
||||
expect(result.combinations.map(({ digits }) => digits)).toEqual([
|
||||
[1, 9],
|
||||
[2, 8],
|
||||
[3, 7],
|
||||
[4, 6],
|
||||
]);
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it("supports a digit range, repeats, required and excluded digits", () => {
|
||||
const result = analyzeSumLab({
|
||||
cellCount: 3,
|
||||
target: 15,
|
||||
minimumDigit: 3,
|
||||
maximumDigit: 7,
|
||||
allowRepeats: true,
|
||||
requiredDigits: [3],
|
||||
excludedDigits: [4],
|
||||
});
|
||||
|
||||
expect(result.combinations.map(({ digits }) => digits)).toEqual([
|
||||
[3, 5, 7],
|
||||
[3, 6, 6],
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses per-cell candidate masks and derives positional possibilities", () => {
|
||||
const result = analyzeSumLab({
|
||||
cellCount: 2,
|
||||
target: 10,
|
||||
candidateMasks: [mask(1, 2), mask(8, 9)],
|
||||
});
|
||||
|
||||
expect(result.combinations.map(({ digits }) => digits)).toEqual([
|
||||
[1, 9],
|
||||
[2, 8],
|
||||
]);
|
||||
expect(result.assignments).toEqual([
|
||||
[1, 9],
|
||||
[2, 8],
|
||||
]);
|
||||
expect(result.possibleByCell).toEqual([
|
||||
[1, 2],
|
||||
[8, 9],
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes toggled combinations from every live deduction", () => {
|
||||
const eliminatedKeys = new Set([sumCombinationKey([1, 9])]);
|
||||
const result = analyzeSumLab({
|
||||
cellCount: 2,
|
||||
target: 10,
|
||||
eliminatedKeys,
|
||||
});
|
||||
|
||||
expect(result.combinations).toHaveLength(4);
|
||||
expect(result.eliminatedCombinations.map(({ digits }) => digits)).toEqual([
|
||||
[1, 9],
|
||||
]);
|
||||
expect(result.activeCombinations.map(({ digits }) => digits)).toEqual([
|
||||
[2, 8],
|
||||
[3, 7],
|
||||
[4, 6],
|
||||
]);
|
||||
expect(result.possibleDigits).not.toContain(1);
|
||||
expect(result.possibleDigits).not.toContain(9);
|
||||
});
|
||||
|
||||
it("reports which independent safety bound stopped an analysis", () => {
|
||||
const result = analyzeSumLab({
|
||||
cellCount: 8,
|
||||
target: 36,
|
||||
allowRepeats: true,
|
||||
maxSearchNodes: 1,
|
||||
});
|
||||
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.truncationReason).toBe("search");
|
||||
expect(result.exploredNodes).toBe(2);
|
||||
});
|
||||
|
||||
it("validates positional candidates and contradictory digit filters", () => {
|
||||
expect(() =>
|
||||
analyzeSumLab({ cellCount: 2, target: 3, candidateMasks: [mask(1)] }),
|
||||
).toThrow(/exactly 2/u);
|
||||
expect(
|
||||
analyzeSumLab({
|
||||
cellCount: 2,
|
||||
target: 3,
|
||||
requiredDigits: [1],
|
||||
excludedDigits: [1],
|
||||
}).combinations,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves combination invariants over generated inputs", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 3, max: 12 }),
|
||||
fc.integer({ min: 1, max: 6 }),
|
||||
fc.integer({ min: 0, max: 72 }),
|
||||
fc.boolean(),
|
||||
(maximumDigit, cellCount, target, allowRepeats) => {
|
||||
if (target > maximumDigit * cellCount) return;
|
||||
const result = analyzeSumLab({
|
||||
cellCount,
|
||||
target,
|
||||
maximumDigit,
|
||||
allowRepeats,
|
||||
});
|
||||
const keys = new Set<string>();
|
||||
for (const combination of result.combinations) {
|
||||
expect(combination.digits).toHaveLength(cellCount);
|
||||
expect(
|
||||
combination.digits.reduce((total, digit) => total + digit, 0),
|
||||
).toBe(target);
|
||||
expect(combination.digits).toEqual(
|
||||
[...combination.digits].sort((left, right) => left - right),
|
||||
);
|
||||
if (!allowRepeats) {
|
||||
expect(new Set(combination.digits)).toHaveProperty(
|
||||
"size",
|
||||
cellCount,
|
||||
);
|
||||
}
|
||||
keys.add(combination.key);
|
||||
}
|
||||
expect(keys.size).toBe(result.combinations.length);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,28 @@ describe("logical solver", () => {
|
||||
expect(result.steps).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not apply positive cage reductions to a false cage", () => {
|
||||
const result = solveLogically({
|
||||
version: 1,
|
||||
size: 4,
|
||||
givens: Array<number>(16).fill(0),
|
||||
regions: classicRegions(4),
|
||||
constraints: [
|
||||
{
|
||||
type: "killer-cage",
|
||||
cells: [0, 1],
|
||||
sum: 3,
|
||||
noRepeat: false,
|
||||
negated: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.status).toBe("stuck");
|
||||
expect(result.steps).toEqual([]);
|
||||
expect(result.candidates[0]).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("returns bounded killer combinations", () => {
|
||||
expect(
|
||||
killerDigitCombinations({ size: 9, count: 2, sum: 10 }).combinations,
|
||||
|
||||
@@ -196,6 +196,35 @@ describe("variant generator", () => {
|
||||
expect(generated.puzzle.rules).not.toMatch(/Kropki|negative/i);
|
||||
});
|
||||
|
||||
it("mines deterministically for a requested practice technique", () => {
|
||||
const options = {
|
||||
variant: "classic",
|
||||
size: 9,
|
||||
seed: "mine-practice",
|
||||
targetDifficulty: "hard",
|
||||
requiredTechnique: "naked-pair",
|
||||
maxTechniqueAttempts: 6,
|
||||
maxChecks: 72,
|
||||
} as const;
|
||||
const generated = generateVariant(options);
|
||||
expect(generated.requestedTechnique).toBe("naked-pair");
|
||||
expect(generated.generationAttempts).toBe(4);
|
||||
expect(generated.difficulty.techniqueCounts["naked-pair"]).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(generateVariant(options)).toEqual(generated);
|
||||
});
|
||||
|
||||
it("keeps Killer-cage practice tied to the Killer generator", () => {
|
||||
expect(() =>
|
||||
generateVariant({
|
||||
variant: "classic",
|
||||
size: 4,
|
||||
requiredTechnique: "killer-cage",
|
||||
}),
|
||||
).toThrow(/requires the Killer generator/i);
|
||||
});
|
||||
|
||||
it("keeps the public variant ID type exhaustive", () => {
|
||||
const ids: GeneratorVariant[] = GENERATOR_VARIANTS.map(({ id }) => id);
|
||||
expect(new Set(ids).size).toBe(GENERATOR_VARIANTS.length);
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
aidMemoireCellDescription,
|
||||
aidMemoireFromPortable,
|
||||
aidMemoireToPortable,
|
||||
clearAidMemoireEntries,
|
||||
compactAidMemoireColumns,
|
||||
configureAidMemoire,
|
||||
createAidMemoire,
|
||||
enterAidMemoireCell,
|
||||
eraseAidMemoireCell,
|
||||
labelAidMemoireCell,
|
||||
MAX_AID_MEMOIRE_CELLS,
|
||||
normalizePortableAidMemoire,
|
||||
resetAidMemoire,
|
||||
} from "../../src/state/aidMemoire";
|
||||
import { maskValues } from "../../src/state/session";
|
||||
|
||||
describe("aid-mémoire state", () => {
|
||||
it("creates a bounded row and preserves existing cells while reflowing", () => {
|
||||
let state = createAidMemoire(9, { enabled: true });
|
||||
expect(state.cells).toHaveLength(9);
|
||||
expect(state.columns).toBe(9);
|
||||
expect(state.cells.map((cell) => cell.label)).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
]);
|
||||
|
||||
state = labelAidMemoireCell(state, 0, "Prime");
|
||||
state = enterAidMemoireCell(state, 0, "value", 7, 9);
|
||||
state = configureAidMemoire(state, 9, 12, 4);
|
||||
expect(state.cells).toHaveLength(12);
|
||||
expect(state.columns).toBe(4);
|
||||
expect(state.cells[0]).toMatchObject({ label: "Prime", value: 7 });
|
||||
expect(state.cells[11]?.label).toBe("12");
|
||||
|
||||
state = configureAidMemoire(state, 9, 1_000, 1_000);
|
||||
expect(state.cells).toHaveLength(MAX_AID_MEMOIRE_CELLS);
|
||||
expect(state.columns).toBe(MAX_AID_MEMOIRE_CELLS);
|
||||
state = configureAidMemoire(state, 9, Number.NaN, Number.NaN);
|
||||
expect(state.cells).toHaveLength(9);
|
||||
expect(state.columns).toBe(1);
|
||||
state = configureAidMemoire(state, 9, 3.9, 2.8);
|
||||
expect(state.cells).toHaveLength(3);
|
||||
expect(state.columns).toBe(2);
|
||||
expect(compactAidMemoireColumns(9)).toBe(3);
|
||||
expect(compactAidMemoireColumns(Number.NaN)).toBe(1);
|
||||
});
|
||||
|
||||
it("edits values, both mark types and colours like normal cells", () => {
|
||||
let state = createAidMemoire(9, { enabled: true, cellCount: 1 });
|
||||
state = enterAidMemoireCell(state, 0, "corner", 2, 9);
|
||||
state = enterAidMemoireCell(state, 0, "corner", 8, 9);
|
||||
state = enterAidMemoireCell(state, 0, "center", 4, 9);
|
||||
state = enterAidMemoireCell(state, 0, "color", 6, 9);
|
||||
expect(maskValues(state.cells[0]!.cornerMarks, 9)).toEqual([2, 8]);
|
||||
expect(maskValues(state.cells[0]!.centerMarks, 9)).toEqual([4]);
|
||||
expect(state.cells[0]?.color).toBe(6);
|
||||
|
||||
state = enterAidMemoireCell(state, 0, "value", 5, 9);
|
||||
expect(state.cells[0]).toMatchObject({
|
||||
value: 5,
|
||||
cornerMarks: 0,
|
||||
centerMarks: 0,
|
||||
color: 6,
|
||||
});
|
||||
expect(enterAidMemoireCell(state, 0, "center", 3, 9)).toBe(state);
|
||||
expect(enterAidMemoireCell(state, 0, "value", 2.5, 9)).toBe(state);
|
||||
state = eraseAidMemoireCell(state, 0, "value");
|
||||
state = enterAidMemoireCell(state, 0, "color", 6, 9);
|
||||
expect(state.cells[0]).toMatchObject({ value: 0, color: 0 });
|
||||
});
|
||||
|
||||
it("clears entries without losing layout or labels and can reset all", () => {
|
||||
let state = createAidMemoire(6, {
|
||||
enabled: true,
|
||||
cellCount: 4,
|
||||
columns: 2,
|
||||
});
|
||||
state = labelAidMemoireCell(state, 0, "Used");
|
||||
state = enterAidMemoireCell(state, 0, "value", 3, 6);
|
||||
const cleared = clearAidMemoireEntries(state);
|
||||
expect(cleared).toMatchObject({ enabled: true, columns: 2 });
|
||||
expect(cleared.cells[0]).toMatchObject({ label: "Used", value: 0 });
|
||||
|
||||
const reset = resetAidMemoire(cleared, 6);
|
||||
expect(reset).toMatchObject({ enabled: true, columns: 6 });
|
||||
expect(reset.cells).toHaveLength(6);
|
||||
expect(reset.cells[0]?.label).toBe("1");
|
||||
});
|
||||
|
||||
it("round-trips a stable portable representation", () => {
|
||||
const portable = normalizePortableAidMemoire(
|
||||
{
|
||||
version: 1,
|
||||
enabled: true,
|
||||
columns: 2,
|
||||
cells: [
|
||||
{
|
||||
label: "Primes",
|
||||
value: 0,
|
||||
cornerMarks: [7, 2, 2],
|
||||
centerMarks: [5, 3],
|
||||
color: 4,
|
||||
},
|
||||
{ label: "Used", value: 8, color: 0 },
|
||||
],
|
||||
},
|
||||
9,
|
||||
);
|
||||
expect(portable.cells[0]).toMatchObject({
|
||||
cornerMarks: [2, 7],
|
||||
centerMarks: [3, 5],
|
||||
});
|
||||
expect(
|
||||
aidMemoireToPortable(aidMemoireFromPortable(portable, 9), 9),
|
||||
).toEqual(portable);
|
||||
});
|
||||
|
||||
it("rejects unsafe portable layouts and provides complete cell narration", () => {
|
||||
expect(() =>
|
||||
normalizePortableAidMemoire(
|
||||
{
|
||||
version: 1,
|
||||
enabled: true,
|
||||
columns: 1,
|
||||
cells: Array.from({ length: MAX_AID_MEMOIRE_CELLS + 1 }, () => ({})),
|
||||
},
|
||||
9,
|
||||
),
|
||||
).toThrow(/1 to 36/u);
|
||||
|
||||
let state = createAidMemoire(9, { cellCount: 1 });
|
||||
state = labelAidMemoireCell(state, 0, "Odd candidates");
|
||||
state = enterAidMemoireCell(state, 0, "corner", 1, 9);
|
||||
state = enterAidMemoireCell(state, 0, "center", 7, 9);
|
||||
state = enterAidMemoireCell(state, 0, "color", 3, 9);
|
||||
expect(aidMemoireCellDescription(state.cells[0]!, 0, 9)).toBe(
|
||||
"Aid-mémoire cell 1, label Odd candidates, empty, corner marks 1, centre marks 7, colour 3",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { navigateGridCell } from "../../src/state/gridNavigation";
|
||||
|
||||
describe("accessible grid navigation", () => {
|
||||
it("moves with arrows without wrapping at edges", () => {
|
||||
expect(navigateGridCell(4, 3, "ArrowRight")).toBe(5);
|
||||
expect(navigateGridCell(5, 3, "ArrowRight")).toBe(5);
|
||||
expect(navigateGridCell(3, 3, "ArrowLeft")).toBe(3);
|
||||
expect(navigateGridCell(1, 3, "ArrowUp")).toBe(1);
|
||||
expect(navigateGridCell(7, 3, "ArrowDown")).toBe(7);
|
||||
});
|
||||
|
||||
it("supports row, grid and column-edge shortcuts", () => {
|
||||
expect(navigateGridCell(4, 3, "Home")).toBe(3);
|
||||
expect(navigateGridCell(4, 3, "End")).toBe(5);
|
||||
expect(navigateGridCell(4, 3, "Home", true)).toBe(0);
|
||||
expect(navigateGridCell(4, 3, "End", true)).toBe(8);
|
||||
expect(navigateGridCell(4, 3, "PageUp")).toBe(1);
|
||||
expect(navigateGridCell(4, 3, "PageDown")).toBe(7);
|
||||
});
|
||||
|
||||
it("ignores non-navigation keys and rejects unsafe inputs", () => {
|
||||
expect(navigateGridCell(0, 9, "Enter")).toBeNull();
|
||||
expect(() => navigateGridCell(-1, 9, "Home")).toThrow(/outside/i);
|
||||
expect(() => navigateGridCell(0, 0, "Home")).toThrow(/size/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
activeHypothesis,
|
||||
beginHypothesis,
|
||||
createGameplayHistory,
|
||||
createSavepoint,
|
||||
deleteSavepoint,
|
||||
describeGameplayChange,
|
||||
finishHypothesis,
|
||||
gameplayMoment,
|
||||
recordGameplayMoment,
|
||||
restoreSavepoint,
|
||||
sessionFromGameplayState,
|
||||
} from "../../src/state/playHistory";
|
||||
import {
|
||||
createAidMemoire,
|
||||
enterAidMemoireCell,
|
||||
} from "../../src/state/aidMemoire";
|
||||
import { createSession } from "../../src/state/session";
|
||||
|
||||
describe("gameplay history", () => {
|
||||
it("records immutable states with useful move labels", () => {
|
||||
const initial = createSession([0, 0, 0, 0]);
|
||||
const changed = { ...initial, values: [0, 3, 0, 0] };
|
||||
const label = describeGameplayChange(initial, changed, 2);
|
||||
const history = recordGameplayMoment(
|
||||
createGameplayHistory(initial),
|
||||
changed,
|
||||
label,
|
||||
);
|
||||
|
||||
expect(label).toBe("Set r1c2 to 3");
|
||||
expect(history.moments.map((moment) => moment.label)).toEqual([
|
||||
"Puzzle opened",
|
||||
"Set r1c2 to 3",
|
||||
]);
|
||||
changed.values[1] = 4;
|
||||
expect(history.moments[1]!.state.values[1]).toBe(3);
|
||||
});
|
||||
|
||||
it("creates, restores and deletes full named savepoints", () => {
|
||||
const session = createSession([0, 0, 0, 0]);
|
||||
session.values[1] = 2;
|
||||
session.centerMarks[2] = 5;
|
||||
session.colors[3] = 4;
|
||||
session.elapsedSeconds = 75;
|
||||
let history = createSavepoint(
|
||||
createGameplayHistory(session),
|
||||
session,
|
||||
"Before chain",
|
||||
);
|
||||
|
||||
session.values[1] = 4;
|
||||
session.centerMarks[2] = 0;
|
||||
const transition = restoreSavepoint(history, history.savepoints[0]!.id);
|
||||
const restored = sessionFromGameplayState(transition.state);
|
||||
expect(restored.values[1]).toBe(2);
|
||||
expect(restored.centerMarks[2]).toBe(5);
|
||||
expect(restored.colors[3]).toBe(4);
|
||||
expect(restored.elapsedSeconds).toBe(75);
|
||||
expect(transition.history.moments.at(-1)?.label).toBe(
|
||||
"Restored “Before chain”",
|
||||
);
|
||||
|
||||
history = deleteSavepoint(
|
||||
transition.history,
|
||||
transition.history.savepoints[0]!.id,
|
||||
);
|
||||
expect(history.savepoints).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("captures aid-mémoire state in moments and savepoints", () => {
|
||||
const session = createSession([0, 0, 0, 0]);
|
||||
let aidMemoire = createAidMemoire(2, {
|
||||
enabled: true,
|
||||
cellCount: 2,
|
||||
});
|
||||
aidMemoire = enterAidMemoireCell(aidMemoire, 0, "value", 1, 2);
|
||||
const history = createSavepoint(
|
||||
createGameplayHistory(session, aidMemoire),
|
||||
session,
|
||||
"Scratch one",
|
||||
aidMemoire,
|
||||
);
|
||||
|
||||
aidMemoire = enterAidMemoireCell(aidMemoire, 0, "value", 2, 2);
|
||||
const restored = restoreSavepoint(history, history.savepoints[0]!.id);
|
||||
expect(restored.state.aidMemoire?.cells[0]?.value).toBe(1);
|
||||
expect(history.moments[0]?.state.aidMemoire?.cells[0]?.value).toBe(1);
|
||||
expect(aidMemoire.cells[0]?.value).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps discarded hypotheses available while restoring their exact base", () => {
|
||||
const session = createSession([0, 0, 0, 0]);
|
||||
session.values[0] = 1;
|
||||
let transition = beginHypothesis(
|
||||
createGameplayHistory(session),
|
||||
session,
|
||||
"Try a 2",
|
||||
);
|
||||
expect(activeHypothesis(transition.history)?.name).toBe("Try a 2");
|
||||
|
||||
const branchSession = sessionFromGameplayState(transition.state);
|
||||
branchSession.values[1] = 2;
|
||||
transition = {
|
||||
history: recordGameplayMoment(
|
||||
transition.history,
|
||||
branchSession,
|
||||
"Set r1c2 to 2",
|
||||
),
|
||||
state: transition.state,
|
||||
};
|
||||
const result = finishHypothesis(
|
||||
transition.history,
|
||||
branchSession,
|
||||
"discard",
|
||||
);
|
||||
|
||||
expect(result.state.values).toEqual([1, 0, 0, 0]);
|
||||
expect(result.history.branches[0]?.status).toBe("discarded");
|
||||
expect(
|
||||
result.history.moments.some(
|
||||
(moment) =>
|
||||
moment.branchId === result.history.branches[0]!.id &&
|
||||
moment.state.values[1] === 2,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.history.moments.at(-1)?.label).toBe(
|
||||
"Discarded hypothesis “Try a 2”",
|
||||
);
|
||||
});
|
||||
|
||||
it("can branch from a replayed moment and merge accepted changes", () => {
|
||||
const initial = createSession([0, 0, 0, 0]);
|
||||
const first = { ...initial, values: [1, 0, 0, 0] };
|
||||
const second = { ...initial, values: [1, 2, 0, 0] };
|
||||
let history = recordGameplayMoment(
|
||||
createGameplayHistory(initial),
|
||||
first,
|
||||
"First move",
|
||||
);
|
||||
const replayId = history.currentMomentId;
|
||||
history = recordGameplayMoment(history, second, "Second move");
|
||||
|
||||
let transition = beginHypothesis(history, second, "Alternative", replayId);
|
||||
expect(transition.state.values).toEqual([1, 0, 0, 0]);
|
||||
const alternative = sessionFromGameplayState(transition.state);
|
||||
alternative.values[2] = 3;
|
||||
transition = {
|
||||
history: recordGameplayMoment(
|
||||
transition.history,
|
||||
alternative,
|
||||
"Alternative move",
|
||||
),
|
||||
state: transition.state,
|
||||
};
|
||||
const kept = finishHypothesis(transition.history, alternative, "keep");
|
||||
|
||||
expect(kept.state.values).toEqual([1, 0, 3, 0]);
|
||||
expect(kept.history.branches.at(-1)?.status).toBe("kept");
|
||||
expect(
|
||||
gameplayMoment(kept.history, kept.history.currentMomentId)?.label,
|
||||
).toBe("Kept hypothesis “Alternative”");
|
||||
});
|
||||
|
||||
it("rejects blank checkpoint and hypothesis names", () => {
|
||||
const session = createSession([0, 0, 0, 0]);
|
||||
const history = createGameplayHistory(session);
|
||||
expect(() => createSavepoint(history, session, " ")).toThrow(
|
||||
"Savepoint name cannot be empty",
|
||||
);
|
||||
expect(() => beginHypothesis(history, session, " ")).toThrow(
|
||||
"Hypothesis name cannot be empty",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SUDOKU_DOCUMENT_SCHEMA } from "../../src/formats";
|
||||
import {
|
||||
ProjectLibrary,
|
||||
createProjectRecord,
|
||||
normalizeProjectRecord,
|
||||
} from "../../src/storage";
|
||||
import {
|
||||
aidMemoireToPortable,
|
||||
createAidMemoire,
|
||||
enterAidMemoireCell,
|
||||
} from "../../src/state/aidMemoire";
|
||||
|
||||
const puzzle = {
|
||||
schema: SUDOKU_DOCUMENT_SCHEMA,
|
||||
version: 1 as const,
|
||||
size: 9,
|
||||
givens: Array<number>(81).fill(0),
|
||||
constraints: [],
|
||||
};
|
||||
|
||||
describe("aid-mémoire Library progress", () => {
|
||||
it("persists bounded scratch state in an independent local clone", async () => {
|
||||
let aidMemoire = createAidMemoire(9, {
|
||||
enabled: true,
|
||||
cellCount: 4,
|
||||
columns: 2,
|
||||
});
|
||||
aidMemoire = enterAidMemoireCell(aidMemoire, 2, "value", 6, 9);
|
||||
const record = createProjectRecord(puzzle, {
|
||||
id: "aid-progress",
|
||||
now: 10,
|
||||
progress: {
|
||||
version: 1,
|
||||
values: Array<number>(81).fill(0),
|
||||
aidMemoire: aidMemoireToPortable(aidMemoire, 9),
|
||||
},
|
||||
});
|
||||
const library = new ProjectLibrary({ indexedDB: null });
|
||||
await library.put(record);
|
||||
|
||||
const loaded = await library.get("aid-progress");
|
||||
expect(loaded?.progress?.aidMemoire).toEqual(record.progress?.aidMemoire);
|
||||
(loaded!.progress!.aidMemoire!.cells[2]!.centerMarks as number[]).push(4);
|
||||
expect(
|
||||
(await library.get("aid-progress"))?.progress?.aidMemoire?.cells[2]
|
||||
?.centerMarks,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects malformed scratch progress at the storage boundary", () => {
|
||||
const record = createProjectRecord(puzzle, {
|
||||
id: "bad-aid-progress",
|
||||
now: 1,
|
||||
});
|
||||
expect(() =>
|
||||
normalizeProjectRecord({
|
||||
...record,
|
||||
progress: {
|
||||
version: 1,
|
||||
values: Array<number>(81).fill(0),
|
||||
aidMemoire: {
|
||||
version: 1,
|
||||
enabled: true,
|
||||
columns: 2,
|
||||
cells: [{}],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow(/columns must be an integer from 1 to 1/u);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user