feat: expand sudoku analysis and interoperability

This commit is contained in:
2026-08-30 23:21:56 +02:00
parent 4a9869baa0
commit 8ca9300ab3
73 changed files with 12482 additions and 384 deletions
+35
View File
@@ -16,6 +16,41 @@ All notable changes are documented here.
constraint family. constraint family.
- Added bounded, seedable generation for classic and twelve variant families, - Added bounded, seedable generation for classic and twelve variant families,
plus independent uniqueness and evidence-based difficulty assessment. 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 ## 0.1.0 - 2026-08-30
+46 -15
View File
@@ -8,33 +8,63 @@ release also runs independently from any static HTTPS host or local preview.
## Workspaces ## Workspaces
- **Play** — keyboard, mouse and touch entry; multi-cell selection; values, - **Play** — keyboard, mouse and touch entry; multi-cell selection; values,
corner/centre notes and colours; undo/redo; conflict highlighting; timer and corner/centre notes and colours; undo/redo; named savepoints, hypothesis
local progress; optional digit-completion counts and matching-digit branches and read-only replay; conflict highlighting; timer and local
highlights. 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 - **Set** — givens, metadata, regions and typed constraints; uniqueness checks
with overlap-safe cage replacement and selection-based cage removal. with overlap-safe cage replacement and selection-based cage removal.
- **Generate** — seedable, bounded construction for classic and 12 variant - **Generate** — seedable, bounded construction for classic and 12 variant
families; independent uniqueness verification and evidence-based difficulty 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 - **Solve** — exact solution counting plus an original human-style engine whose
steps include structured evidence, placements and eliminations. steps include structured evidence, placements and eliminations.
- **Helpers** — Killer combinations and candidate-aware assignments, 45-rule - **Helpers** — generalized sum combinations with positional candidates and
residuals, and Kropki, XV or inequality relation pairs. 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 Classic grids and common variants share one bounded puzzle model: irregular
regions, diagonals, Killer cages, thermometers, arrows, Kropki and numbered regions, diagonals, Killer cages, thermometers, arrows, Kropki and numbered
5/10 sum-pair clues, inequalities, renban lines, palindromes, anti-knight, 5/10 sum-pair clues, inequalities, renban lines, palindromes, X-sums,
anti-king and non-consecutive rules. Thirteen bundled, uniquely checked examples skyscrapers, quadruples, maximum cells, anti-knight, anti-king and
demonstrate every supported constraint. Import accepts compact grid strings, non-consecutive rules. Every local clue can also be required to be false for
this project's JSON format, share fragments, and the common f-puzzles fields liar/Wrogn constructions. Fourteen original bundled examples demonstrate every
supported by the model. Unknown constraints are reported rather than silently supported constraint and are exact-search verified as unique.
discarded.
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 Generation supports only size/variant combinations which pass the bounded
construction checks. The requested difficulty controls clue-removal targets; construction checks. The requested difficulty controls clue-removal targets;
the displayed 0100 result is calculated afterwards from logical techniques, the displayed 0100 result is calculated afterwards from logical techniques,
clue load and reproducible exact-search evidence. A timeout or node limit is 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. 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 ## 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 SudokuPad short IDs identify server-hosted records and therefore cannot be
resolved by this deliberately offline application. Embedded f-puzzles payloads 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 ## Development
@@ -76,8 +107,8 @@ browser workflows and Toolbox contract before creating a deterministic
The interaction ideas and published solving concepts explored by SudokuPad, The interaction ideas and published solving concepts explored by SudokuPad,
SudokuWiki and the wider puzzle community informed the product requirements. SudokuWiki and the wider puzzle community informed the product requirements.
No code, artwork, wording or proprietary puzzle data from those applications is No externally authored puzzle definitions, code, artwork or documentation text
included. Algorithms and explanations in this repository are independently is bundled. Algorithms, UI, original examples and explanations are independently
implemented from mathematical rules and public standards. implemented from mathematical rules and public standards.
## Licence ## Licence
+9 -1
View File
@@ -16,4 +16,12 @@ runtime package. Development-only packages build and test the source but are
not shipped as runtime modules. not shipped as runtime modules.
SudokuPad, SudokuWiki and HoDoKu are research references only. Their source, 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.
+35
View File
@@ -16,6 +16,41 @@ All notable changes are documented here.
constraint family. constraint family.
- Added bounded, seedable generation for classic and twelve variant families, - Added bounded, seedable generation for classic and twelve variant families,
plus independent uniqueness and evidence-based difficulty assessment. 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 ## 0.1.0 - 2026-08-30
+46 -15
View File
@@ -8,33 +8,63 @@ release also runs independently from any static HTTPS host or local preview.
## Workspaces ## Workspaces
- **Play** — keyboard, mouse and touch entry; multi-cell selection; values, - **Play** — keyboard, mouse and touch entry; multi-cell selection; values,
corner/centre notes and colours; undo/redo; conflict highlighting; timer and corner/centre notes and colours; undo/redo; named savepoints, hypothesis
local progress; optional digit-completion counts and matching-digit branches and read-only replay; conflict highlighting; timer and local
highlights. 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 - **Set** — givens, metadata, regions and typed constraints; uniqueness checks
with overlap-safe cage replacement and selection-based cage removal. with overlap-safe cage replacement and selection-based cage removal.
- **Generate** — seedable, bounded construction for classic and 12 variant - **Generate** — seedable, bounded construction for classic and 12 variant
families; independent uniqueness verification and evidence-based difficulty 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 - **Solve** — exact solution counting plus an original human-style engine whose
steps include structured evidence, placements and eliminations. steps include structured evidence, placements and eliminations.
- **Helpers** — Killer combinations and candidate-aware assignments, 45-rule - **Helpers** — generalized sum combinations with positional candidates and
residuals, and Kropki, XV or inequality relation pairs. 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 Classic grids and common variants share one bounded puzzle model: irregular
regions, diagonals, Killer cages, thermometers, arrows, Kropki and numbered regions, diagonals, Killer cages, thermometers, arrows, Kropki and numbered
5/10 sum-pair clues, inequalities, renban lines, palindromes, anti-knight, 5/10 sum-pair clues, inequalities, renban lines, palindromes, X-sums,
anti-king and non-consecutive rules. Thirteen bundled, uniquely checked examples skyscrapers, quadruples, maximum cells, anti-knight, anti-king and
demonstrate every supported constraint. Import accepts compact grid strings, non-consecutive rules. Every local clue can also be required to be false for
this project's JSON format, share fragments, and the common f-puzzles fields liar/Wrogn constructions. Fourteen original bundled examples demonstrate every
supported by the model. Unknown constraints are reported rather than silently supported constraint and are exact-search verified as unique.
discarded.
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 Generation supports only size/variant combinations which pass the bounded
construction checks. The requested difficulty controls clue-removal targets; construction checks. The requested difficulty controls clue-removal targets;
the displayed 0100 result is calculated afterwards from logical techniques, the displayed 0100 result is calculated afterwards from logical techniques,
clue load and reproducible exact-search evidence. A timeout or node limit is 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. 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 ## 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 SudokuPad short IDs identify server-hosted records and therefore cannot be
resolved by this deliberately offline application. Embedded f-puzzles payloads 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 ## Development
@@ -76,8 +107,8 @@ browser workflows and Toolbox contract before creating a deterministic
The interaction ideas and published solving concepts explored by SudokuPad, The interaction ideas and published solving concepts explored by SudokuPad,
SudokuWiki and the wider puzzle community informed the product requirements. SudokuWiki and the wider puzzle community informed the product requirements.
No code, artwork, wording or proprietary puzzle data from those applications is No externally authored puzzle definitions, code, artwork or documentation text
included. Algorithms and explanations in this repository are independently is bundled. Algorithms, UI, original examples and explanations are independently
implemented from mathematical rules and public standards. implemented from mathematical rules and public standards.
## Licence ## Licence
+9 -1
View File
@@ -16,4 +16,12 @@ runtime package. Development-only packages build and test the source but are
not shipped as runtime modules. not shipped as runtime modules.
SudokuPad, SudokuWiki and HoDoKu are research references only. Their source, 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.
+378
View File
@@ -0,0 +1,378 @@
import { useRef, type CSSProperties, type KeyboardEvent } from "react";
import {
aidMemoireCellDescription,
clearAidMemoireEntries,
compactAidMemoireColumns,
configureAidMemoire,
enterAidMemoireCell,
eraseAidMemoireCell,
labelAidMemoireCell,
MAX_AID_MEMOIRE_CELLS,
MAX_AID_MEMOIRE_COLUMNS,
resetAidMemoire,
setAidMemoireEnabled,
type AidMemoireState,
} from "../state/aidMemoire";
import {
maskValues,
symbolFor,
valueForKey,
type EntryMode,
} from "../state/session";
interface AidMemoireProps {
readonly size: number;
readonly state: AidMemoireState;
readonly selectedCell: number;
readonly active: boolean;
readonly readOnly?: boolean;
readonly mode: EntryMode;
readonly onStateChange: (state: AidMemoireState) => void;
readonly onSelect: (cell: number) => void;
readonly onMode: (mode: EntryMode) => void;
}
const modes: ReadonlyArray<{ mode: EntryMode; label: string; key: string }> = [
{ mode: "value", label: "Value", key: "Z" },
{ mode: "corner", label: "Corner", key: "X" },
{ mode: "center", label: "Centre", key: "C" },
{ mode: "color", label: "Colour", key: "V" },
];
export function AidMemoire({
size,
state,
selectedCell,
active,
readOnly = false,
mode,
onStateChange,
onSelect,
onMode,
}: AidMemoireProps) {
const cellRefs = useRef<Array<HTMLButtonElement | null>>([]);
const selected = Math.max(0, Math.min(state.cells.length - 1, selectedCell));
const selectAndFocus = (cell: number) => {
const next = Math.max(0, Math.min(state.cells.length - 1, cell));
onSelect(next);
cellRefs.current[next]?.focus();
};
const handleKeyDown = (
cell: number,
event: KeyboardEvent<HTMLButtonElement>,
) => {
const row = Math.floor(cell / state.columns);
const column = cell % state.columns;
const rowStart = row * state.columns;
const rowEnd = Math.min(
state.cells.length - 1,
rowStart + state.columns - 1,
);
let destination: number | undefined;
if (event.key === "ArrowLeft") destination = Math.max(rowStart, cell - 1);
if (event.key === "ArrowRight") destination = Math.min(rowEnd, cell + 1);
if (event.key === "ArrowUp") {
destination = cell - state.columns >= 0 ? cell - state.columns : cell;
}
if (event.key === "ArrowDown") {
destination =
cell + state.columns < state.cells.length ? cell + state.columns : cell;
}
if (event.key === "Home") {
destination = event.ctrlKey || event.metaKey ? 0 : rowStart;
}
if (event.key === "End") {
destination =
event.ctrlKey || event.metaKey ? state.cells.length - 1 : rowEnd;
}
if (event.key === "PageUp") destination = column;
if (event.key === "PageDown") {
const lastRowStart =
Math.floor((state.cells.length - 1) / state.columns) * state.columns;
destination = Math.min(state.cells.length - 1, lastRowStart + column);
}
if (destination !== undefined) {
event.preventDefault();
selectAndFocus(destination);
return;
}
if (readOnly) return;
if (
event.key === "Backspace" ||
event.key === "Delete" ||
event.key === "0"
) {
event.preventDefault();
onStateChange(eraseAidMemoireCell(state, cell, mode));
return;
}
if (!event.ctrlKey && !event.metaKey && !event.altKey) {
const modeKey: Record<string, EntryMode> = {
z: "value",
x: "corner",
c: "center",
v: "color",
};
const nextMode = modeKey[event.key.toLowerCase()];
if (nextMode !== undefined) {
event.preventDefault();
onMode(nextMode);
return;
}
const value = valueForKey(event.key, mode === "color" ? 8 : size);
if (value !== null) {
event.preventDefault();
onStateChange(enterAidMemoireCell(state, cell, mode, value, size));
}
}
};
const configure = (cellCount: number, columns: number) => {
const next = configureAidMemoire(state, size, cellCount, columns);
onStateChange(next);
if (selected >= next.cells.length) onSelect(next.cells.length - 1);
};
return (
<section className="aid-memoire" aria-labelledby="aid-memoire-title">
<header className="section-heading aid-memoire__heading">
<div>
<p className="eyebrow">Optional scratch cells</p>
<h2 id="aid-memoire-title">Aid-mémoire</h2>
<p className="muted">
Track contextual sets, pseudo-digits or mappings. These cells do not
constrain the Sudoku.
</p>
</div>
<button
type="button"
className="text-button"
disabled={readOnly}
onClick={() => onStateChange(setAidMemoireEnabled(state, false))}
>
Hide
</button>
</header>
<div
className="aid-memoire__scroller"
aria-label="Scrollable aid-mémoire area"
>
<div
className="aid-memoire__grid"
role="grid"
aria-label={`${String(state.cells.length)} aid-mémoire scratch cells`}
aria-colcount={state.columns}
aria-rowcount={Math.ceil(state.cells.length / state.columns)}
aria-readonly={readOnly}
style={{ "--aid-columns": state.columns } as CSSProperties}
>
{Array.from(
{ length: Math.ceil(state.cells.length / state.columns) },
(_, rowIndex) => (
<div
key={rowIndex}
className="aid-memoire__row"
role="row"
aria-rowindex={rowIndex + 1}
>
{state.cells
.slice(
rowIndex * state.columns,
(rowIndex + 1) * state.columns,
)
.map((cell, columnIndex) => {
const index = rowIndex * state.columns + columnIndex;
const cornerMarks = maskValues(cell.cornerMarks, size);
const centerMarks = maskValues(cell.centerMarks, size);
return (
<button
key={index}
ref={(element) => {
cellRefs.current[index] = element;
}}
type="button"
role="gridcell"
className={`aid-memoire__cell${active && selected === index ? " is-selected" : ""}${cell.color ? ` has-color-${String(cell.color)}` : ""}`}
aria-label={aidMemoireCellDescription(
cell,
index,
size,
)}
aria-colindex={columnIndex + 1}
aria-selected={active && selected === index}
tabIndex={selected === index ? 0 : -1}
onClick={() => {
if (!readOnly) onSelect(index);
}}
onFocus={() => {
if (!readOnly) onSelect(index);
}}
onKeyDown={(event) => handleKeyDown(index, event)}
>
<span className="aid-memoire__label" aria-hidden="true">
{cell.label || `#${String(index + 1)}`}
</span>
{cell.value ? (
<span
className="aid-memoire__value"
aria-hidden="true"
>
{symbolFor(cell.value, size)}
</span>
) : (
<>
<span
className="aid-memoire__corner"
aria-hidden="true"
>
{cornerMarks
.map((mark) => symbolFor(mark, size))
.join(" ")}
</span>
<span
className="aid-memoire__centre"
aria-hidden="true"
>
{centerMarks
.map((mark) => symbolFor(mark, size))
.join("")}
</span>
</>
)}
</button>
);
})}
</div>
),
)}
</div>
</div>
<div
className="mode-switcher aid-memoire__modes"
role="group"
aria-label="Aid-mémoire entry mode"
>
{modes.map((item) => (
<button
key={item.mode}
type="button"
disabled={readOnly}
className={mode === item.mode ? "is-active" : ""}
aria-pressed={mode === item.mode}
title={`${item.label} mode (${item.key})`}
onClick={() => onMode(item.mode)}
>
{item.label}
</button>
))}
</div>
<div className="aid-memoire__selected">
<label>
Selected cell label
<input
value={state.cells[selected]?.label ?? ""}
maxLength={40}
disabled={readOnly}
onChange={(event) =>
onStateChange(
labelAidMemoireCell(state, selected, event.target.value),
)
}
/>
</label>
<p className="muted">
The regular keypad edits this cell in {mode} mode. Digits, Z/X/C/V,
Delete and arrow keys also work directly in the scratch grid.
</p>
</div>
<details className="aid-memoire__settings">
<summary>Configure scratch layout</summary>
<div className="aid-memoire__settings-body">
<div className="inline-fields">
<label>
Cells
<input
type="number"
min={1}
max={MAX_AID_MEMOIRE_CELLS}
value={state.cells.length}
disabled={readOnly}
onChange={(event) =>
configure(Number(event.target.value), state.columns)
}
/>
</label>
<label>
Columns
<input
type="number"
min={1}
max={Math.min(MAX_AID_MEMOIRE_COLUMNS, state.cells.length)}
value={state.columns}
disabled={readOnly}
onChange={(event) =>
configure(state.cells.length, Number(event.target.value))
}
/>
</label>
</div>
<div className="action-row">
<button
type="button"
disabled={readOnly}
onClick={() => configure(state.cells.length, state.cells.length)}
>
Single row
</button>
<button
type="button"
disabled={readOnly}
onClick={() =>
configure(
state.cells.length,
compactAidMemoireColumns(state.cells.length),
)
}
>
Compact grid
</button>
<button
type="button"
disabled={readOnly}
onClick={() => {
if (window.confirm("Clear every aid-mémoire entry?")) {
onStateChange(clearAidMemoireEntries(state));
}
}}
>
Clear entries
</button>
<button
type="button"
className="danger"
disabled={readOnly}
onClick={() => {
if (
window.confirm(
"Reset the aid-mémoire layout, labels and entries?",
)
) {
onStateChange(resetAidMemoire(state, size));
onSelect(0);
}
}}
>
Reset all
</button>
</div>
</div>
</details>
</section>
);
}
+408
View File
@@ -0,0 +1,408 @@
import { useEffect, useMemo, useState } from "react";
import { compilePuzzle, type NormalizedPuzzle } from "../domain";
import {
candidateCellsForValues,
candidateUnitIndicesForCells,
cellLabel,
deriveCandidateLinks,
houseLabel,
inspectCandidateCells,
inspectCandidateHouses,
type CandidateLink,
type CandidateOverlay,
} from "../helpers";
import { symbolFor } from "../state/session";
const LINK_LIST_LIMIT = 160;
const OVERLAY_LINK_LIMIT = 500;
interface CandidateLabProps {
readonly puzzle: NormalizedPuzzle;
readonly values: readonly number[];
readonly candidateMasks: readonly number[];
readonly selectedCells: readonly number[];
readonly onOverlayChange?: (overlay: CandidateOverlay | undefined) => void;
}
function nodeLabel(node: CandidateLink["a"], size: number): string {
return `${cellLabel(node.cell, size)}(${symbolFor(node.value, size)})`;
}
function linkLabel(link: CandidateLink, size: number): string {
return `${nodeLabel(link.a, size)} ${link.kind === "strong" ? "⇔" : "—"} ${nodeLabel(link.b, size)}`;
}
export function CandidateLab({
puzzle,
values,
candidateMasks,
selectedCells,
onOverlayChange,
}: CandidateLabProps) {
const [activeValues, setActiveValues] = useState<readonly number[]>([]);
const [scope, setScope] = useState("selection");
const [showStrong, setShowStrong] = useState(true);
const [showWeak, setShowWeak] = useState(false);
const [includeCellLinks, setIncludeCellLinks] = useState(true);
const [focusedLinkId, setFocusedLinkId] = useState<string>();
const compiled = useMemo(() => compilePuzzle(puzzle), [puzzle]);
const effectiveActiveValues = useMemo(
() => activeValues.filter((value) => value <= puzzle.size),
[activeValues, puzzle.size],
);
const relatedUnitIndices = useMemo(
() => candidateUnitIndicesForCells(puzzle, selectedCells),
[puzzle, selectedCells],
);
const selectedUnitIndex = scope.startsWith("unit:")
? Number(scope.slice(5))
: undefined;
const effectiveScope =
selectedUnitIndex !== undefined &&
!relatedUnitIndices.includes(selectedUnitIndex)
? "selection"
: scope;
const scopedUnitIndices = useMemo(() => {
if (effectiveScope === "all")
return compiled.units.map((_, index) => index);
if (
selectedUnitIndex !== undefined &&
Number.isInteger(selectedUnitIndex) &&
compiled.units[selectedUnitIndex] !== undefined
) {
return [selectedUnitIndex];
}
return relatedUnitIndices;
}, [compiled.units, effectiveScope, relatedUnitIndices, selectedUnitIndex]);
const scopedCells = useMemo(() => {
if (effectiveScope === "selection") return [...new Set(selectedCells)];
return [
...new Set(
scopedUnitIndices.flatMap(
(unitIndex) => compiled.units[unitIndex]?.cells ?? [],
),
),
];
}, [compiled.units, effectiveScope, scopedUnitIndices, selectedCells]);
const cellInspections = useMemo(
() => inspectCandidateCells(puzzle, candidateMasks, selectedCells),
[candidateMasks, puzzle, selectedCells],
);
const houseInspections = useMemo(
() =>
inspectCandidateHouses(puzzle, values, candidateMasks, scopedUnitIndices),
[candidateMasks, puzzle, scopedUnitIndices, values],
);
const links = useMemo(
() =>
deriveCandidateLinks(puzzle, candidateMasks, {
unitIndices: scopedUnitIndices,
cellIndices: scopedCells,
...(effectiveActiveValues.length === 0
? {}
: { values: effectiveActiveValues }),
includeCellLinks,
}).filter(({ kind }) => (kind === "strong" ? showStrong : showWeak)),
[
candidateMasks,
effectiveActiveValues,
includeCellLinks,
puzzle,
scopedCells,
scopedUnitIndices,
showStrong,
showWeak,
],
);
const focusedLink = links.find(({ id }) => id === focusedLinkId);
const visibleLinks = useMemo(
() =>
focusedLink === undefined
? links.slice(0, OVERLAY_LINK_LIMIT)
: [focusedLink],
[focusedLink, links],
);
const candidateCells = useMemo(() => {
if (effectiveActiveValues.length === 0) return [];
return candidateCellsForValues(candidateMasks, effectiveActiveValues);
}, [candidateMasks, effectiveActiveValues]);
const overlay = useMemo<CandidateOverlay>(
() => ({
activeValues: effectiveActiveValues,
candidateCells,
links: visibleLinks,
}),
[candidateCells, effectiveActiveValues, visibleLinks],
);
useEffect(() => {
onOverlayChange?.(overlay);
}, [onOverlayChange, overlay]);
useEffect(
() => () => {
onOverlayChange?.(undefined);
},
[onOverlayChange],
);
const toggleValue = (value: number) => {
setFocusedLinkId(undefined);
setActiveValues((current) => {
const valid = current.filter((candidate) => candidate <= puzzle.size);
return valid.includes(value)
? valid.filter((candidate) => candidate !== value)
: [...valid, value].sort((a, b) => a - b);
});
};
const strongCount = links.filter(({ kind }) => kind === "strong").length;
const weakCount = links.length - strongCount;
return (
<section
className="candidate-lab stack"
aria-labelledby="candidate-lab-title"
>
<div>
<p className="eyebrow">Candidate graph</p>
<h3 id="candidate-lab-title">Links and houses</h3>
<p className="muted">
Inspect legal candidates without changing any notes. A solid line is
strong (at least one endpoint is true); a dashed line is weak (both
endpoints cannot be true).
</p>
</div>
<fieldset className="candidate-filter">
<legend>Digit filters</legend>
<div
className={`candidate-filter__digits${puzzle.size > 9 ? " candidate-filter__digits--wide" : ""}`}
>
<button
type="button"
className={effectiveActiveValues.length === 0 ? "is-active" : ""}
aria-pressed={effectiveActiveValues.length === 0}
onClick={() => {
setActiveValues([]);
setFocusedLinkId(undefined);
}}
>
All
</button>
{Array.from({ length: puzzle.size }, (_, index) => index + 1).map(
(value) => (
<button
key={value}
type="button"
className={
effectiveActiveValues.includes(value) ? "is-active" : ""
}
aria-label={`Filter candidate ${symbolFor(value, puzzle.size)}`}
aria-pressed={effectiveActiveValues.includes(value)}
onClick={() => toggleValue(value)}
>
{symbolFor(value, puzzle.size)}
</button>
),
)}
</div>
</fieldset>
<div className="candidate-lab__controls">
<label>
Inspect
<select
aria-label="Candidate house scope"
value={effectiveScope}
onChange={(event) => {
setScope(event.target.value);
setFocusedLinkId(undefined);
}}
>
<option value="selection">
Selected cells and touching houses
</option>
{relatedUnitIndices.map((unitIndex) => (
<option key={unitIndex} value={`unit:${String(unitIndex)}`}>
{houseLabel(compiled.units[unitIndex]!)}
</option>
))}
<option value="all">Whole grid</option>
</select>
</label>
<label className="check-row">
<input
type="checkbox"
checked={showStrong}
onChange={(event) => {
setShowStrong(event.target.checked);
setFocusedLinkId(undefined);
}}
/>
Strong links
</label>
<label className="check-row">
<input
type="checkbox"
checked={showWeak}
onChange={(event) => {
setShowWeak(event.target.checked);
setFocusedLinkId(undefined);
}}
/>
Weak links
</label>
<label className="check-row">
<input
type="checkbox"
checked={includeCellLinks}
onChange={(event) => {
setIncludeCellLinks(event.target.checked);
setFocusedLinkId(undefined);
}}
/>
Links inside cells
</label>
</div>
<div className="metric-row" aria-live="polite">
<span>
Candidate cells <strong>{candidateCells.length}</strong>
</span>
<span>
Strong links <strong>{strongCount}</strong>
</span>
<span>
Weak links <strong>{weakCount}</strong>
</span>
</div>
<section
className="candidate-inspection"
aria-labelledby="selected-candidates-title"
>
<h4 id="selected-candidates-title">Selected cells</h4>
<div className="candidate-cell-list">
{cellInspections.map((inspection) => (
<article key={inspection.cell}>
<strong>{cellLabel(inspection.cell, puzzle.size)}</strong>
<span>
{inspection.values.length === 0
? values[inspection.cell]
? `Filled ${symbolFor(values[inspection.cell]!, puzzle.size)}`
: "No legal candidates"
: inspection.values
.map((value) => symbolFor(value, puzzle.size))
.join(" ")}
</span>
<small>{inspection.houseLabels.join(" · ")}</small>
</article>
))}
</div>
</section>
<section
className="candidate-inspection"
aria-labelledby="house-candidates-title"
>
<h4 id="house-candidates-title">House positions</h4>
<div className="candidate-house-list">
{houseInspections.map((inspection) => (
<details
key={inspection.unitIndex}
open={houseInspections.length === 1}
>
<summary>
<strong>{inspection.label}</strong>
<span>{inspection.missingValues.length} missing</span>
</summary>
<dl>
{inspection.positions
.filter(
({ value }) =>
effectiveActiveValues.length === 0 ||
effectiveActiveValues.includes(value),
)
.map((position) => (
<div
key={position.value}
data-link-kind={position.linkKind}
>
<dt>{symbolFor(position.value, puzzle.size)}</dt>
<dd>
{position.cells.length === 0
? "nowhere"
: position.cells
.map((cell) => cellLabel(cell, puzzle.size))
.join(" · ")}
{position.linkKind === "strong" ? " · strong" : ""}
</dd>
</div>
))}
</dl>
</details>
))}
</div>
</section>
<section
className="candidate-inspection"
aria-labelledby="candidate-links-title"
>
<div className="candidate-section-heading">
<h4 id="candidate-links-title">Visible links</h4>
{focusedLink !== undefined && (
<button
type="button"
className="text-button"
onClick={() => setFocusedLinkId(undefined)}
>
Show all
</button>
)}
</div>
{links.length === 0 ? (
<p className="muted">
No links match this scope and filter. Enable weak links or inspect
another house.
</p>
) : (
<div className="candidate-link-list">
{links.slice(0, LINK_LIST_LIMIT).map((link) => (
<button
key={link.id}
type="button"
className={`candidate-link candidate-link--${link.kind}`}
aria-pressed={focusedLink?.id === link.id}
title={link.contexts.map(({ label }) => label).join(", ")}
onClick={() =>
setFocusedLinkId((current) =>
current === link.id ? undefined : link.id,
)
}
>
<span>{linkLabel(link, puzzle.size)}</span>
<small>
{link.kind} ·{" "}
{link.contexts.map(({ label }) => label).join(" / ")}
</small>
</button>
))}
</div>
)}
{links.length > LINK_LIST_LIMIT && (
<p className="muted">
Showing the first {LINK_LIST_LIMIT} of {links.length} links. Narrow
the digit or house filter to inspect the rest.
</p>
)}
{focusedLink === undefined && links.length > OVERLAY_LINK_LIMIT && (
<p className="muted">
The board overlay is capped at {OVERLAY_LINK_LIMIT} links for
responsiveness. Focus a listed link or narrow the filters to see a
precise relationship.
</p>
)}
</section>
</section>
);
}
+362 -34
View File
@@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { cellsFormQuadruple } from "../domain/geometry";
import type { PuzzleDefinition, VariantConstraint } from "../domain/types"; import type { PuzzleDefinition, VariantConstraint } from "../domain/types";
import { import {
removeKillerCagesAtCells, removeKillerCagesAtCells,
@@ -19,6 +20,10 @@ interface ConstraintEditorProps {
function describeConstraint(constraint: VariantConstraint, size: number) { function describeConstraint(constraint: VariantConstraint, size: number) {
const cell = (index: number) => const cell = (index: number) =>
`r${String(Math.floor(index / size) + 1)}c${String((index % size) + 1)}`; `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) { switch (constraint.type) {
case "diagonal": case "diagonal":
return `${constraint.direction} diagonal`; return `${constraint.direction} diagonal`;
@@ -29,22 +34,67 @@ function describeConstraint(constraint: VariantConstraint, size: number) {
case "non-consecutive": case "non-consecutive":
return "non-consecutive"; return "non-consecutive";
case "killer-cage": 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 "thermo":
case "renban": case "renban":
case "palindrome": case "palindrome":
return `${constraint.type} · ${String(constraint.cells.length)} cells`; return marked(
`${constraint.type} · ${String(constraint.cells.length)} cells`,
);
case "arrow": 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": 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": 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": 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({ export function ConstraintEditor({
puzzle, puzzle,
selection, selection,
@@ -55,23 +105,55 @@ export function ConstraintEditor({
busy, busy,
}: ConstraintEditorProps) { }: ConstraintEditorProps) {
const [cageSum, setCageSum] = useState(10); const [cageSum, setCageSum] = useState(10);
const [cageAllowsRepeats, setCageAllowsRepeats] = useState(false);
const [region, setRegion] = useState(1); 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 constraints = puzzle.constraints ?? [];
const append = (constraint: VariantConstraint) => 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 need = (count: number) => selection.length === count;
const atLeast = (count: number) => selection.length >= count; const atLeast = (count: number) => selection.length >= count;
const cageCellCount = Math.max(1, selection.length); const cageCellCount = Math.max(1, selection.length);
const minimumCageSum = (cageCellCount * (cageCellCount + 1)) / 2; const minimumCageSum = cageAllowsRepeats
const maximumCageSum = ? cageCellCount
(cageCellCount * (2 * puzzle.size - cageCellCount + 1)) / 2; : (cageCellCount * (cageCellCount + 1)) / 2;
const maximumCageSum = cageAllowsRepeats
? cageCellCount * puzzle.size
: (cageCellCount * (2 * puzzle.size - cageCellCount + 1)) / 2;
const validCage = const validCage =
selection.length >= 1 && selection.length >= 1 &&
selection.length <= puzzle.size && selection.length <= puzzle.size &&
Number.isInteger(cageSum) && Number.isInteger(cageSum) &&
cageSum >= minimumCageSum && (newCluesAreFalse
cageSum <= maximumCageSum; ? 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 selectedCageExists = selectionTouchesKillerCage(constraints, selection);
const toggleGlobal = ( const toggleGlobal = (
@@ -154,13 +236,21 @@ export function ConstraintEditor({
Selection order defines lines and pair direction. Shift-click or drag Selection order defines lines and pair direction. Shift-click or drag
to build a selection. to build a selection.
</p> </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"> <div className="inline-fields">
<label className="compact-field"> <label className="compact-field">
Cage sum Cage sum
<input <input
type="number" type="number"
min={minimumCageSum} min={newCluesAreFalse ? 1 : minimumCageSum}
max={maximumCageSum} max={newCluesAreFalse ? puzzle.size ** 3 : maximumCageSum}
value={cageSum} value={cageSum}
onChange={(event) => setCageSum(Number(event.target.value))} onChange={(event) => setCageSum(Number(event.target.value))}
/> />
@@ -175,6 +265,8 @@ export function ConstraintEditor({
type: "killer-cage", type: "killer-cage",
cells: selection, cells: selection,
sum: cageSum, sum: cageSum,
...(cageAllowsRepeats ? { noRepeat: false } : {}),
...(newCluesAreFalse ? { negated: true } : {}),
}), }),
}) })
} }
@@ -195,6 +287,14 @@ export function ConstraintEditor({
Remove selected cage Remove selected cage
</button> </button>
</div> </div>
<label className="check-row">
<input
type="checkbox"
checked={cageAllowsRepeats}
onChange={(event) => setCageAllowsRepeats(event.target.checked)}
/>
Cage digits may repeat
</label>
<p className="muted"> <p className="muted">
Adding replaces any cage touching the selection. Removing clears every Adding replaces any cage touching the selection. Removing clears every
cage touching a selected cell. cage touching a selected cell.
@@ -316,7 +416,41 @@ export function ConstraintEditor({
> >
1st &gt; 2nd (inequality) 1st &gt; 2nd (inequality)
</button> </button>
<button
type="button"
disabled={!need(1)}
onClick={() => append({ type: "maximum", cell: selection[0]! })}
>
Maximum cell
</button>
</div> </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"> <div className="inline-fields">
<label className="compact-field"> <label className="compact-field">
Region Region
@@ -342,6 +476,120 @@ export function ConstraintEditor({
</div> </div>
</section> </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"> <section className="panel-section">
<p className="eyebrow">Global rules</p> <p className="eyebrow">Global rules</p>
<div className="button-grid"> <div className="button-grid">
@@ -398,27 +646,107 @@ export function ConstraintEditor({
{constraints.length > 0 && ( {constraints.length > 0 && (
<section className="panel-section"> <section className="panel-section">
<p className="eyebrow">Constraints</p> <div className="section-heading">
<p className="eyebrow">Constraints</p>
<div className="constraint-batch-actions">
<button
className="text-button"
type="button"
onClick={() =>
onChange({
...puzzle,
constraints: constraints.map((constraint) =>
supportsPolarity(constraint)
? ({
...constraint,
negated: true,
} as VariantConstraint)
: constraint,
),
})
}
>
Make all clues false
</button>
<button
className="text-button"
type="button"
onClick={() =>
onChange({
...puzzle,
constraints: constraints.map((constraint) => {
if (!supportsPolarity(constraint)) return constraint;
const cleaned = { ...constraint } as VariantConstraint & {
negated?: boolean;
};
delete cleaned.negated;
return cleaned;
}),
})
}
>
Make all clues true
</button>
</div>
</div>
<ul className="constraint-list"> <ul className="constraint-list">
{constraints.map((constraint, index) => ( {constraints.map((constraint, index) => {
<li key={`${constraint.type}-${String(index)}`}> const description = describeConstraint(constraint, puzzle.size);
<span>{describeConstraint(constraint, puzzle.size)}</span> const negated =
<button "negated" in constraint && constraint.negated === true;
className="text-button danger" return (
type="button" <li key={`${constraint.type}-${String(index)}`}>
onClick={() => <span>{description}</span>
onChange({ <span className="constraint-actions">
...puzzle, {supportsPolarity(constraint) && (
constraints: constraints.filter( <button
(_, item) => item !== index, className="text-button"
), type="button"
}) aria-label={`${negated ? "Require true for" : "Require false for"} ${description}`}
} onClick={() =>
> onChange({
Remove ...puzzle,
</button> constraints: constraints.map((item, itemIndex) => {
</li> if (itemIndex !== index) return item;
))} if ("negated" in item && item.negated === true) {
const cleaned = {
...item,
} as VariantConstraint & {
negated?: boolean;
};
delete cleaned.negated;
return cleaned;
}
return {
...item,
negated: true,
} as VariantConstraint;
}),
})
}
>
{negated ? "Require true" : "Require false"}
</button>
)}
<button
className="text-button danger"
type="button"
aria-label={`Remove ${description}`}
onClick={() =>
onChange({
...puzzle,
constraints: constraints.filter(
(_, item) => item !== index,
),
})
}
>
Remove
</button>
</span>
</li>
);
})}
</ul> </ul>
</section> </section>
)} )}
+295
View File
@@ -0,0 +1,295 @@
import { useMemo, useState, type FormEvent } from "react";
import {
activeHypothesis,
gameplayMoment,
MAIN_BRANCH_ID,
type GameplayHistory,
} from "../state/playHistory";
import { Modal } from "./Modal";
interface GameplayHistoryDialogProps {
readonly open: boolean;
readonly history: GameplayHistory;
readonly replayMomentId?: string;
readonly onClose: () => void;
readonly onCreateSavepoint: (name: string) => void;
readonly onRestoreSavepoint: (savepointId: string) => void;
readonly onDeleteSavepoint: (savepointId: string) => void;
readonly onStartHypothesis: (name: string, fromMomentId?: string) => void;
readonly onFinishHypothesis: (decision: "keep" | "discard") => void;
readonly onReplayMoment: (momentId: string) => void;
readonly onReturnLive: () => void;
}
function formatElapsed(seconds: number): string {
const hours = Math.floor(seconds / 3_600);
const minutes = Math.floor((seconds % 3_600) / 60);
const rest = seconds % 60;
return [hours, minutes, rest]
.map((part) => String(part).padStart(2, "0"))
.join(":");
}
export function GameplayHistoryDialog({
open,
history,
replayMomentId,
onClose,
onCreateSavepoint,
onRestoreSavepoint,
onDeleteSavepoint,
onStartHypothesis,
onFinishHypothesis,
onReplayMoment,
onReturnLive,
}: GameplayHistoryDialogProps) {
const [hypothesisName, setHypothesisName] = useState("");
const [savepointName, setSavepointName] = useState("");
const hypothesis = activeHypothesis(history);
const replayMoment = replayMomentId
? gameplayMoment(history, replayMomentId)
: undefined;
const replayIndex = replayMoment
? history.moments.findIndex((moment) => moment.id === replayMoment.id)
: history.moments.length - 1;
const branchNames = useMemo(
() =>
new Map([
[MAIN_BRANCH_ID, "Main solve"],
...history.branches.map((branch) => [branch.id, branch.name] as const),
]),
[history.branches],
);
const submitHypothesis = (event: FormEvent) => {
event.preventDefault();
const name = hypothesisName.trim();
if (!name) return;
onStartHypothesis(name, replayMoment?.id);
setHypothesisName("");
};
const submitSavepoint = (event: FormEvent) => {
event.preventDefault();
const name = savepointName.trim();
if (!name) return;
onCreateSavepoint(name);
setSavepointName("");
};
return (
<Modal
open={open}
title="Branches, savepoints and replay"
onClose={onClose}
wide
>
<div className="history-dialog-grid">
<div className="stack">
<section className="panel-section history-section">
<div>
<p className="eyebrow">Hypotheses</p>
<h3>Try a path without losing your solve</h3>
</div>
{hypothesis ? (
<div className="hypothesis-active" role="status">
<div>
<strong>{hypothesis.name}</strong>
<span>Every move is being kept on this branch.</span>
</div>
<div className="action-row">
<button
type="button"
onClick={() => onFinishHypothesis("keep")}
>
Keep changes
</button>
<button
type="button"
className="danger"
onClick={() => onFinishHypothesis("discard")}
>
Discard and return
</button>
</div>
</div>
) : (
<form className="history-name-form" onSubmit={submitHypothesis}>
<label>
Hypothesis name
<input
value={hypothesisName}
maxLength={80}
placeholder="e.g. Assume r4c7 is 8"
onChange={(event) => setHypothesisName(event.target.value)}
/>
</label>
<button type="submit" disabled={!hypothesisName.trim()}>
{replayMoment ? "Branch from this step" : "Start hypothesis"}
</button>
</form>
)}
{history.branches.length > 0 && (
<ul className="branch-list" aria-label="Hypothesis branches">
{history.branches.map((branch) => (
<li key={branch.id}>
<span>{branch.name}</span>
<span
className={`branch-status branch-status--${branch.status}`}
>
{branch.status}
</span>
</li>
))}
</ul>
)}
</section>
<section className="panel-section history-section">
<div>
<p className="eyebrow">Named savepoints</p>
<h3>Return to a deliberate checkpoint</h3>
</div>
<form className="history-name-form" onSubmit={submitSavepoint}>
<label>
Savepoint name
<input
value={savepointName}
maxLength={80}
disabled={replayMoment !== undefined}
placeholder="e.g. Before the long chain"
onChange={(event) => setSavepointName(event.target.value)}
/>
</label>
<button
type="submit"
disabled={!savepointName.trim() || replayMoment !== undefined}
>
Save current grid
</button>
</form>
{history.savepoints.length === 0 ? (
<p className="muted">No savepoints yet.</p>
) : (
<ul className="savepoint-list" aria-label="Named savepoints">
{history.savepoints.map((savepoint) => (
<li key={savepoint.id}>
<div>
<strong>{savepoint.name}</strong>
<span>
{formatElapsed(savepoint.state.elapsedSeconds)}
</span>
</div>
<div className="action-row">
<button
type="button"
disabled={replayMoment !== undefined}
onClick={() => onRestoreSavepoint(savepoint.id)}
>
Restore
</button>
<button
type="button"
className="danger"
aria-label={`Delete savepoint ${savepoint.name}`}
onClick={() => onDeleteSavepoint(savepoint.id)}
>
Delete
</button>
</div>
</li>
))}
</ul>
)}
</section>
</div>
<section className="panel-section history-section history-replay">
<div className="section-heading">
<div>
<p className="eyebrow">Replay</p>
<h3>Review every recorded state</h3>
</div>
<span className="status-pill">
{String(Math.max(0, history.moments.length - 1))} changes
</span>
</div>
{replayMoment ? (
<p className="callout" role="status">
Viewing {replayMoment.label}. The board is read-only until you
return live or branch from this step.
</p>
) : (
<p className="muted">
Choose a step to inspect its complete grid, notes, colours and
elapsed time.
</p>
)}
<div className="action-row history-replay-controls">
<button
type="button"
disabled={hypothesis !== undefined || replayIndex <= 0}
onClick={() =>
onReplayMoment(history.moments[replayIndex - 1]!.id)
}
>
Previous step
</button>
<button
type="button"
disabled={
replayMoment === undefined ||
hypothesis !== undefined ||
replayIndex >= history.moments.length - 1
}
onClick={() =>
onReplayMoment(history.moments[replayIndex + 1]!.id)
}
>
Next step
</button>
<button
type="button"
disabled={replayMoment === undefined}
onClick={onReturnLive}
>
Return to live grid
</button>
</div>
<ol className="history-list" aria-label="Solve history">
{[...history.moments].reverse().map((moment) => {
const isReplay = moment.id === replayMoment?.id;
const isCurrent =
replayMoment === undefined &&
moment.id === history.currentMomentId;
return (
<li key={moment.id}>
<button
type="button"
className={isReplay || isCurrent ? "is-current" : ""}
disabled={hypothesis !== undefined}
aria-current={isReplay || isCurrent ? "step" : undefined}
onClick={() =>
isCurrent ? onReturnLive() : onReplayMoment(moment.id)
}
>
<span className="history-step-number">
{String(moment.sequence).padStart(2, "0")}
</span>
<span className="history-step-copy">
<strong>{moment.label}</strong>
<span>
{branchNames.get(moment.branchId) ?? "Hypothesis"} ·{" "}
{formatElapsed(moment.state.elapsedSeconds)}
</span>
</span>
</button>
</li>
);
})}
</ol>
</section>
</div>
</Modal>
);
}
+66 -2
View File
@@ -1,11 +1,13 @@
import { useMemo, useState, type FormEvent } from "react"; import { useMemo, useState, type FormEvent } from "react";
import { import {
GENERATOR_VARIANTS, GENERATOR_VARIANTS,
PRACTICE_TECHNIQUES,
type DifficultyAssessment, type DifficultyAssessment,
type GeneratedVariantPuzzle, type GeneratedVariantPuzzle,
type GenerationDifficultyTarget, type GenerationDifficultyTarget,
type GeneratorVariant, type GeneratorVariant,
type GenerateVariantOptions, type GenerateVariantOptions,
type PracticeTechnique,
} from "../solver"; } from "../solver";
const difficultyTargets: readonly GenerationDifficultyTarget[] = [ const difficultyTargets: readonly GenerationDifficultyTarget[] = [
@@ -49,6 +51,10 @@ export function GeneratorWorkspace({
const [symmetry, setSymmetry] = useState<"none" | "rotational">("rotational"); const [symmetry, setSymmetry] = useState<"none" | "rotational">("rotational");
const [constraintCount, setConstraintCount] = useState(8); const [constraintCount, setConstraintCount] = useState(8);
const [seed, setSeed] = useState(""); const [seed, setSeed] = useState("");
const [requiredTechnique, setRequiredTechnique] = useState<
PracticeTechnique | ""
>("");
const [maxTechniqueAttempts, setMaxTechniqueAttempts] = useState(10);
const usesMarkingCount = ![ const usesMarkingCount = ![
"classic", "classic",
"diagonal", "diagonal",
@@ -66,6 +72,9 @@ export function GeneratorWorkspace({
targetDifficulty, targetDifficulty,
symmetry, symmetry,
...(usesMarkingCount ? { constraintCount } : {}), ...(usesMarkingCount ? { constraintCount } : {}),
...(requiredTechnique === ""
? {}
: { requiredTechnique, maxTechniqueAttempts }),
seed: seed.trim() || `local-${Date.now().toString(36)}`, seed: seed.trim() || `local-${Date.now().toString(36)}`,
}); });
}; };
@@ -101,6 +110,11 @@ export function GeneratorWorkspace({
) { ) {
setSize(nextDefinition.supportedSizes[0]); setSize(nextDefinition.supportedSizes[0]);
} }
if (next !== "killer") {
setRequiredTechnique((current) =>
current === "killer-cage" ? "" : current,
);
}
}} }}
> >
{GENERATOR_VARIANTS.map((item) => ( {GENERATOR_VARIANTS.map((item) => (
@@ -175,6 +189,41 @@ export function GeneratorWorkspace({
onChange={(event) => setSeed(event.target.value)} onChange={(event) => setSeed(event.target.value)}
/> />
</label> </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> </div>
<p className="generator-description">{definition.description}</p> <p className="generator-description">{definition.description}</p>
<div className="action-row"> <div className="action-row">
@@ -187,7 +236,9 @@ export function GeneratorWorkspace({
</div> </div>
<p className="muted"> <p className="muted">
Difficulty is an estimate from reproducible solver evidence, not a 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> </p>
</form> </form>
@@ -229,11 +280,24 @@ export function GeneratorWorkspace({
Markings <strong>{generation.generatedConstraintCount}</strong> Markings <strong>{generation.generatedConstraintCount}</strong>
</span> </span>
)} )}
{generation?.requestedTechnique && (
<span>
Practice{" "}
<strong>
{techniqueLabel(generation.requestedTechnique)} ×
{generation.difficulty.techniqueCounts[
generation.requestedTechnique
] ?? 0}
</strong>
</span>
)}
</div> </div>
<p>{assessment.summary}</p> <p>{assessment.summary}</p>
{generation && ( {generation && (
<p className="muted"> <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> </p>
)} )}
</section> </section>
+83 -10
View File
@@ -14,11 +14,12 @@ export function HelpDialog({
<h3>Five complementary workspaces</h3> <h3>Five complementary workspaces</h3>
<p> <p>
<strong>Play</strong> keeps values, two kinds of notes, colours, <strong>Play</strong> keeps values, two kinds of notes, colours,
history and elapsed time. <strong>Set</strong> edits clues and branches, replay and elapsed time. <strong>Set</strong> edits clues
constraints. <strong>Generate</strong> constructs and rates bounded, and constraints. <strong>Generate</strong> constructs and rates
seedable variants. <strong>Solve</strong> explains logical steps and bounded, seedable variants. <strong>Solve</strong> explains logical
can verify uniqueness. <strong>Helpers</strong> answers focused steps and can verify uniqueness. <strong>Helpers</strong> answers
questions without changing the board. focused sum, candidate and relation questions without changing the
board.
</p> </p>
</section> </section>
<section> <section>
@@ -32,6 +33,18 @@ export function HelpDialog({
<dt>Shift + arrows</dt> <dt>Shift + arrows</dt>
<dd>Extend the selection</dd> <dd>Extend the selection</dd>
</div> </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> <div>
<dt>19 / AG</dt> <dt>19 / AG</dt>
<dd>Enter the selected symbol</dd> <dd>Enter the selected symbol</dd>
@@ -64,13 +77,62 @@ export function HelpDialog({
explanation. explanation.
</p> </p>
</section> </section>
<section>
<h3>Branches, savepoints and replay</h3>
<p>
Open <strong>History &amp; branches</strong> while playing to name a
checkpoint, isolate a hypothesis, or inspect an earlier grid.
Discarding a hypothesis restores its exact starting state but keeps
the abandoned path available in replay. Replayed grids are read-only
until you return live or deliberately branch from that step. This
working history stays in the current browser session; save the
puzzle to the Library for durable puzzle progress.
</p>
</section>
<section>
<h3>Sum and Candidate Labs</h3>
<p>
Sum Lab enumerates bounded combinations and, when selected board
cells are enabled, candidate-compatible assignments per position.
Required, excluded and manually eliminated combinations remain local
helper state. Candidate Lab inspects selected cells and houses,
filters one or more digits, and can overlay strong and weak links
without changing handwritten notes.
</p>
</section>
<section>
<h3>Aid-mémoire scratch cells</h3>
<p>
Enable the optional aid-mémoire in Play for a non-constraining row
or compact grid of scratch cells. Each cell has its own label and
accepts values, corner marks, centre marks and colours through the
regular keypad or keyboard. Arrow keys stay within the configured
grid; Home/End move within a row, Ctrl/ + Home/End reach the first
or last cell, and Page Up/Down move to the first or last row. The
layout and entries are preserved with local Library progress.
</p>
</section>
<section>
<h3>Variant and false clues</h3>
<p>
The setter supports cages, lines, pair clues, X-sums, skyscrapers,
quadruples and maximum cells. Enable Wrogn mode to make new local
clues false, or switch existing clues individually or as a batch.
Red dashed artwork and a mark identify false clues; Σ and
identify X-sum and skyscraper readings. A false multi-cell clue
often stays undecided until enough of its cells are known, so exact
searches for dense liar puzzles can be substantially slower.
</p>
</section>
<section> <section>
<h3>Generation and ratings</h3> <h3>Generation and ratings</h3>
<p> <p>
Generation runs in a worker with explicit time and search limits. A Generation runs in a worker with explicit time and search limits. A
requested level guides clue removal; the reported rating is then requested level guides clue removal; the reported rating is then
calculated independently from logical techniques, clue load and 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. uniqueness claim.
</p> </p>
</section> </section>
@@ -78,10 +140,21 @@ export function HelpDialog({
<h3>Import and privacy</h3> <h3>Import and privacy</h3>
<p> <p>
Files, text, solving and generation stay in this browser. Compact Files, text, solving and generation stay in this browser. Compact
grids, project JSON, share fragments and supported f-puzzles data grids, project JSON, share fragments, supported f-puzzles,
are decoded locally. SudokuPad short IDs need its server and are SudokuPad/CTC inline data and supported Penpa+ long links are
intentionally rejected. Review an export before sharing: titles, decoded locally. Server short IDs are intentionally rejected. SVG,
authors, rules, solutions and progress may be included. 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> </p>
</section> </section>
</div> </div>
+339 -4
View File
@@ -1,11 +1,15 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { NormalizedPuzzle } from "../domain";
import { import {
analyzeKillerCage, analyzeKillerCage,
analyzeSumLab,
calculateResidual, calculateResidual,
relationPairs, relationPairs,
type CandidateOverlay,
type RelationSpec, type RelationSpec,
} from "../helpers"; } from "../helpers";
import { maskValues, symbolFor } from "../state/session"; import { maskValues, symbolFor } from "../state/session";
import { CandidateLab } from "./CandidateLab";
function digits(text: string) { function digits(text: string) {
return [ return [
@@ -25,18 +29,24 @@ function numbers(text: string) {
export function HelpersWorkspace({ export function HelpersWorkspace({
size, size,
puzzle,
values,
selectedCells, selectedCells,
candidateMasks, candidateMasks,
onCandidateOverlayChange,
}: { }: {
size: number; size: number;
puzzle: NormalizedPuzzle;
values: readonly number[];
selectedCells: readonly number[]; selectedCells: readonly number[];
candidateMasks: readonly number[]; candidateMasks: readonly number[];
onCandidateOverlayChange?: (overlay: CandidateOverlay | undefined) => void;
}) { }) {
const [helper, setHelper] = useState<"killer" | "residual" | "relations">( const [helper, setHelper] = useState<
"killer", "candidates" | "killer" | "sum" | "residual" | "relations"
); >("killer");
const [cellCount, setCellCount] = useState(2); const [cellCount, setCellCount] = useState(2);
const [sum, setSum] = useState(10); const [sum, setSum] = useState(Math.min(10, size + 1));
const [allowed, setAllowed] = useState(""); const [allowed, setAllowed] = useState("");
const [required, setRequired] = useState(""); const [required, setRequired] = useState("");
const [excluded, setExcluded] = useState(""); const [excluded, setExcluded] = useState("");
@@ -47,6 +57,19 @@ export function HelpersWorkspace({
const [relation, setRelation] = useState("white"); const [relation, setRelation] = useState("white");
const [firstCandidates, setFirstCandidates] = useState(""); const [firstCandidates, setFirstCandidates] = useState("");
const [secondCandidates, setSecondCandidates] = 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 = const effectiveCount =
useBoardCandidates && selectedCells.length > 0 useBoardCandidates && selectedCells.length > 0
@@ -137,6 +160,84 @@ export function HelpersWorkspace({
} }
}, [firstCandidates, relation, secondCandidates, size]); }, [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 ( return (
<div className="helpers-workspace stack"> <div className="helpers-workspace stack">
<div> <div>
@@ -151,6 +252,8 @@ export function HelpersWorkspace({
{( {(
[ [
["killer", "Killer combinations"], ["killer", "Killer combinations"],
["sum", "Sum Lab"],
["candidates", "Candidate links"],
["residual", `${String((size * (size + 1)) / 2)}-rule residual`], ["residual", `${String((size * (size + 1)) / 2)}-rule residual`],
["relations", "Pair relations"], ["relations", "Pair relations"],
] as const ] as const
@@ -168,6 +271,238 @@ export function HelpersWorkspace({
))} ))}
</div> </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" && ( {helper === "killer" && (
<section className="helper-card"> <section className="helper-card">
<div className="helper-controls"> <div className="helper-controls">
+192 -68
View File
@@ -1,15 +1,15 @@
import { useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import type { PuzzleDefinition } from "../domain/types"; import type { PuzzleDefinition } from "../domain/types";
import { normalizePuzzle } from "../domain/validation";
import { import {
decodePuzzleHash,
encodePuzzleHash, encodePuzzleHash,
exportFpuzzlesJson, exportFpuzzlesJson,
exportFpuzzlesUrl, exportFpuzzlesUrl,
fromDomainPuzzle, fromDomainPuzzle,
importFpuzzles, importPuzzle,
parseFpuzzles, renderPuzzlePdf,
parsePlainGrid, renderPuzzlePng,
parseSudokuDocument, renderPuzzleSvg,
serializePlainGrid, serializePlainGrid,
serializeSudokuDocument, serializeSudokuDocument,
toDomainPuzzle, toDomainPuzzle,
@@ -17,31 +17,13 @@ import {
} from "../formats"; } from "../formats";
import type { PlaySession } from "../state/session"; import type { PlaySession } from "../state/session";
import { maskValues } from "../state/session"; import { maskValues } from "../state/session";
import type { PortableAidMemoire } from "../state/aidMemoire";
import { Modal } from "./Modal"; 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( function withProgress(
puzzle: PuzzleDefinition, puzzle: PuzzleDefinition,
session: PlaySession, session: PlaySession,
aidMemoire?: PortableAidMemoire,
): SudokuDocument { ): SudokuDocument {
const base = fromDomainPuzzle(puzzle); const base = fromDomainPuzzle(puzzle);
return { return {
@@ -55,22 +37,38 @@ function withProgress(
), ),
colors: [...session.colors], colors: [...session.colors],
elapsedMs: session.elapsedSeconds * 1_000, elapsedMs: session.elapsedSeconds * 1_000,
...(aidMemoire === undefined ? {} : { aidMemoire }),
}; };
} }
function download(name: string, contents: string, type: string) { function downloadBlob(name: string, blob: Blob) {
const url = URL.createObjectURL(new Blob([contents], { type })); const url = URL.createObjectURL(blob);
const anchor = document.createElement("a"); const anchor = document.createElement("a");
anchor.href = url; anchor.href = url;
anchor.download = name; anchor.download = name;
document.body.append(anchor);
anchor.click(); 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 { interface ImportExportDialogProps {
open: boolean; open: boolean;
puzzle: PuzzleDefinition; puzzle: PuzzleDefinition;
session: PlaySession; session: PlaySession;
aidMemoire?: PortableAidMemoire;
onClose: () => void; onClose: () => void;
onImport: ( onImport: (
puzzle: PuzzleDefinition, puzzle: PuzzleDefinition,
@@ -82,6 +80,7 @@ interface ImportExportDialogProps {
| "candidates" | "candidates"
| "colors" | "colors"
| "elapsedMs" | "elapsedMs"
| "aidMemoire"
>, >,
) => void; ) => void;
} }
@@ -90,19 +89,21 @@ export function ImportExportDialog({
open, open,
puzzle, puzzle,
session, session,
aidMemoire,
onClose, onClose,
onImport, onImport,
}: ImportExportDialogProps) { }: ImportExportDialogProps) {
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [feedback, setFeedback] = useState(""); const [feedback, setFeedback] = useState("");
const [includeProgress, setIncludeProgress] = useState(true); const [includeProgress, setIncludeProgress] = useState(true);
const [busy, setBusy] = useState(false);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const documentValue = useMemo( const documentValue = useMemo(
() => () =>
includeProgress includeProgress
? withProgress(puzzle, session) ? withProgress(puzzle, session, aidMemoire)
: fromDomainPuzzle(puzzle), : fromDomainPuzzle(puzzle),
[includeProgress, puzzle, session], [aidMemoire, includeProgress, puzzle, session],
); );
const copy = async (value: string, label: string) => { 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 ( return (
<Modal open={open} title="Import and export" onClose={onClose} wide> <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"> <section className="stack">
<div> <div>
<p className="eyebrow">Import</p> <p className="eyebrow">Import</p>
<h3>Paste puzzle data</h3> <h3>Paste puzzle data</h3>
</div> </div>
<p className="muted"> <p className="muted">
Accepts a plain grid, Sudoku Tools JSON/share link, raw f-puzzles Accepts a plain grid, Sudoku Tools JSON/share link, f-puzzles or
JSON, or a self-contained f-puzzles/SudokuPad link. Server-only SudokuPad/CTC data, and supported Penpa+ long links. Everything is
short IDs are never fetched. decoded locally. Server-only short IDs are never fetched.
Unsupported constructs stop the import instead of being discarded.
</p> </p>
<textarea <textarea
rows={12} rows={12}
@@ -135,7 +237,10 @@ export function ImportExportDialog({
maxLength={1_048_576} maxLength={1_048_576}
spellCheck={false} spellCheck={false}
placeholder="Paste 81 characters, JSON or a puzzle URL…" 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"> <div className="action-row">
<button type="button" onClick={() => fileRef.current?.click()}> <button type="button" onClick={() => fileRef.current?.click()}>
@@ -145,7 +250,7 @@ export function ImportExportDialog({
ref={fileRef} ref={fileRef}
className="sr-only" className="sr-only"
type="file" type="file"
accept="application/json,text/plain,.json,.txt,.sdk" accept="application/json,text/plain,.json,.txt,.sdk,.scl,.ctc,.penpa"
onChange={(event) => { onChange={(event) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
event.target.value = ""; event.target.value = "";
@@ -169,28 +274,15 @@ export function ImportExportDialog({
/> />
<button <button
type="button" type="button"
disabled={!input.trim()} disabled={busy || !input.trim()}
onClick={() => { onClick={() => void inspectImport()}
try { >
const parsed = parseImport(input); Check compatibility
onImport(toDomainPuzzle(parsed) as PuzzleDefinition, { </button>
values: parsed.values, <button
cornerMarks: parsed.cornerMarks, type="button"
centerMarks: parsed.centerMarks, disabled={busy || !input.trim()}
candidates: parsed.candidates, onClick={() => void applyImport()}
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.",
);
}
}}
> >
Import locally Import locally
</button> </button>
@@ -213,11 +305,13 @@ export function ImportExportDialog({
<div className="export-actions"> <div className="export-actions">
<button <button
type="button" type="button"
disabled={busy}
onClick={() => onClick={() =>
download( exportText(
"sudoku-tools-puzzle.json", "sudoku-tools-puzzle.json",
serializeSudokuDocument(documentValue, true),
"application/json", "application/json",
"Project JSON",
() => serializeSudokuDocument(documentValue, true),
) )
} }
> >
@@ -225,14 +319,13 @@ export function ImportExportDialog({
</button> </button>
<button <button
type="button" type="button"
disabled={busy}
onClick={() => onClick={() =>
download( exportText("sudoku.txt", "text/plain", "Grid text", () =>
"sudoku.txt",
serializePlainGrid( serializePlainGrid(
documentValue, documentValue,
includeProgress ? "values" : "givens", includeProgress ? "values" : "givens",
), ),
"text/plain",
) )
} }
> >
@@ -240,11 +333,13 @@ export function ImportExportDialog({
</button> </button>
<button <button
type="button" type="button"
disabled={busy}
onClick={() => onClick={() =>
download( exportText(
"sudoku.fpuzzles.json", "sudoku.fpuzzles.json",
exportFpuzzlesJson(documentValue, true),
"application/json", "application/json",
"f-puzzles JSON",
() => exportFpuzzlesJson(documentValue, true),
) )
} }
> >
@@ -252,21 +347,50 @@ export function ImportExportDialog({
</button> </button>
<button <button
type="button" type="button"
disabled={busy}
onClick={() => onClick={() =>
void copy(exportFpuzzlesUrl(documentValue), "f-puzzles URL") void copyExport(
() => exportFpuzzlesUrl(documentValue),
"f-puzzles URL",
)
} }
> >
Copy f-puzzles URL Copy f-puzzles URL
</button> </button>
<button <button
type="button" type="button"
disabled={busy}
onClick={() => { onClick={() => {
const share = `${location.href.split("#", 1)[0]}${encodePuzzleHash(documentValue)}`; void copyExport(
void copy(share, "Local share URL"); () =>
`${location.href.split("#", 1)[0]}${encodePuzzleHash(documentValue)}`,
"Local share URL",
);
}} }}
> >
Copy local share URL Copy local share URL
</button> </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> </div>
<p className="callout"> <p className="callout">
Share links are self-contained. They may expose title, setter, Share links are self-contained. They may expose title, setter,
+9 -7
View File
@@ -141,13 +141,15 @@ export function SolveWorkspace({
</span> </span>
</div> </div>
<p> <p>
{exact.count === 0 {exact.count >= 2
? "No completion satisfies every supported rule." ? "At least two solutions exist; the puzzle is not unique."
: exact.count === 1 && !exact.truncated : exact.truncated
? "The current puzzle has exactly one solution within the configured search bounds." ? exact.count === 0
: exact.count >= 2 ? "Search stopped at a safety limit before finding a solution, so solvability is not established."
? "At least two solutions exist; the puzzle is not unique." : "Search stopped at a safety limit, so uniqueness 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> </p>
{exact.solutions[0] && ( {exact.solutions[0] && (
<button <button
+461 -57
View File
@@ -1,5 +1,11 @@
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react"; import {
useId,
type CSSProperties,
type KeyboardEvent,
type PointerEvent,
} from "react";
import type { NormalizedPuzzle, VariantConstraint } from "../domain/types"; import type { NormalizedPuzzle, VariantConstraint } from "../domain/types";
import type { CandidateNode, CandidateOverlay } from "../helpers";
import { maskValues, symbolFor } from "../state/session"; import { maskValues, symbolFor } from "../state/session";
interface SudokuBoardProps { interface SudokuBoardProps {
@@ -14,6 +20,7 @@ interface SudokuBoardProps {
conflicts?: ReadonlySet<number>; conflicts?: ReadonlySet<number>;
activeCell: number; activeCell: number;
showCandidates?: boolean; showCandidates?: boolean;
candidateOverlay?: CandidateOverlay;
onCellPointerDown: ( onCellPointerDown: (
cell: number, cell: number,
event: PointerEvent<HTMLButtonElement>, event: PointerEvent<HTMLButtonElement>,
@@ -25,6 +32,67 @@ interface SudokuBoardProps {
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void; 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) { function point(size: number, cell: number) {
return { return {
x: (cell % size) + 0.5, x: (cell % size) + 0.5,
@@ -41,6 +109,23 @@ function polyline(size: number, cells: readonly number[]) {
.join(" "); .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) { function boundaryPath(size: number, cells: ReadonlySet<number>, inset: number) {
const commands: string[] = []; const commands: string[] = [];
for (const cell of cells) { for (const cell of cells) {
@@ -62,12 +147,168 @@ function boundaryPath(size: number, cells: ReadonlySet<number>, inset: number) {
return commands.join(" "); 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 }) { function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
const { size } = puzzle; const { size } = puzzle;
const regionCells = Array.from({ length: size }, () => new Set<number>()); const regionCells = Array.from({ length: size }, () => new Set<number>());
puzzle.regions.forEach((region, cell) => regionCells[region]?.add(cell)); puzzle.regions.forEach((region, cell) => regionCells[region]?.add(cell));
const renderConstraint = (constraint: VariantConstraint, index: number) => { const renderConstraint = (constraint: VariantConstraint, index: number) => {
const polarityClass =
"negated" in constraint && constraint.negated === true
? " is-negated"
: "";
if (constraint.type === "diagonal") { if (constraint.type === "diagonal") {
return ( return (
<line <line
@@ -85,9 +326,10 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
const first = Math.min(...constraint.cells); const first = Math.min(...constraint.cells);
const position = point(size, first); const position = point(size, first);
return ( return (
<g key={index} className="constraint-cage"> <g key={index} className={`constraint-cage${polarityClass}`}>
<path d={boundaryPath(size, cells, 0.09)} /> <path d={boundaryPath(size, cells, 0.09)} />
<text x={position.x - 0.34} y={position.y - 0.27}> <text x={position.x - 0.34} y={position.y - 0.27}>
{constraint.negated === true ? "≠" : ""}
{constraint.sum} {constraint.sum}
</text> </text>
</g> </g>
@@ -96,9 +338,14 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
if (constraint.type === "thermo") { if (constraint.type === "thermo") {
const bulb = point(size, constraint.cells[0]!); const bulb = point(size, constraint.cells[0]!);
return ( return (
<g key={index} className="constraint-thermo"> <g key={index} className={`constraint-thermo${polarityClass}`}>
<polyline points={polyline(size, constraint.cells)} /> <polyline points={polyline(size, constraint.cells)} />
<circle cx={bulb.x} cy={bulb.y} r="0.31" /> <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> </g>
); );
} }
@@ -107,16 +354,104 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
const end = point(size, constraint.line.at(-1)!); const end = point(size, constraint.line.at(-1)!);
const connectedLine = [constraint.bulb.at(-1)!, ...constraint.line]; const connectedLine = [constraint.bulb.at(-1)!, ...constraint.line];
return ( return (
<g key={index} className="constraint-arrow"> <g key={index} className={`constraint-arrow${polarityClass}`}>
<circle cx={bulb.x} cy={bulb.y} r="0.32" /> <circle cx={bulb.x} cy={bulb.y} r="0.32" />
<polyline points={polyline(size, connectedLine)} /> <polyline points={polyline(size, connectedLine)} />
<circle cx={end.x} cy={end.y} r="0.08" /> <circle cx={end.x} cy={end.y} r="0.08" />
</g> </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") { if (constraint.type === "renban" || constraint.type === "palindrome") {
return ( return (
<g key={index} className={`constraint-${constraint.type}`}> <g
key={index}
className={`constraint-${constraint.type}${polarityClass}`}
>
<polyline points={polyline(size, constraint.cells)} /> <polyline points={polyline(size, constraint.cells)} />
{constraint.type === "palindrome" && {constraint.type === "palindrome" &&
constraint.cells.map((cell) => { constraint.cells.map((cell) => {
@@ -145,7 +480,7 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
return ( return (
<circle <circle
key={index} key={index}
className={`constraint-kropki constraint-kropki--${constraint.kind}`} className={`constraint-kropki constraint-kropki--${constraint.kind}${polarityClass}`}
cx={x} cx={x}
cy={y} cy={y}
r="0.115" r="0.115"
@@ -155,10 +490,11 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
return ( return (
<g <g
key={index} 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" /> <circle cx={x} cy={y} r="0.18" />
<text x={x} y={y}> <text x={x} y={y}>
{constraint.negated === true ? "≠" : ""}
{constraint.total} {constraint.total}
</text> </text>
</g> </g>
@@ -167,7 +503,7 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
return ( return (
<g <g
key={index} key={index}
className="constraint-inequality" className={`constraint-inequality${polarityClass}`}
transform={`translate(${String(x)} ${String(y)}) rotate(${String(rotation)})`} transform={`translate(${String(x)} ${String(y)}) rotate(${String(rotation)})`}
> >
<path d="M0.12 -0.17L-0.12 0L0.12 0.17" /> <path d="M0.12 -0.17L-0.12 0L0.12 0.17" />
@@ -211,72 +547,140 @@ export function SudokuBoard({
conflicts = new Set<number>(), conflicts = new Set<number>(),
activeCell, activeCell,
showCandidates, showCandidates,
candidateOverlay,
onCellPointerDown, onCellPointerDown,
onCellPointerEnter, onCellPointerEnter,
onKeyDown, onKeyDown,
}: SudokuBoardProps) { }: SudokuBoardProps) {
const { size } = puzzle; 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 ( return (
<div <div
className="sudoku-board-frame" className={`sudoku-board-frame${hasOutsideClues ? " has-outside-clues" : ""}`}
style={{ "--sudoku-size": size } as CSSProperties} 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 <div
className="sudoku-board" className="sudoku-board"
role="grid" role="grid"
aria-label={`${String(size)} by ${String(size)} Sudoku 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} onKeyDown={onKeyDown}
> >
{values.map((value, cell) => { {Array.from({ length: size }, (_, rowIndex) => (
const isGiven = Boolean(puzzle.givens[cell]); <div
const corner = maskValues(cornerMarks[cell] ?? 0, size); key={rowIndex}
const center = maskValues( className="sudoku-row"
centerMarks[cell] || (showCandidates ? (candidates[cell] ?? 0) : 0), role="row"
size, aria-rowindex={rowIndex + 1}
); >
const row = Math.floor(cell / size) + 1; {Array.from({ length: size }, (_, columnIndex) => {
const column = (cell % size) + 1; const cell = rowIndex * size + columnIndex;
return ( const value = values[cell] ?? 0;
<button const isGiven = Boolean(puzzle.givens[cell]);
key={cell} const corner = maskValues(cornerMarks[cell] ?? 0, size);
type="button" const userCenterMask = centerMarks[cell] ?? 0;
role="gridcell" const automaticMask = showCandidates
aria-label={`Row ${String(row)}, column ${String(column)}${value ? `, ${symbolFor(value, size)}` : ", empty"}`} ? (candidates[cell] ?? 0)
aria-selected={selected.has(cell)} : 0;
tabIndex={cell === activeCell ? 0 : -1} const centerIsAutomatic =
className={[ userCenterMask === 0 && automaticMask !== 0;
"sudoku-cell", const center = maskValues(userCenterMask || automaticMask, size);
isGiven ? "is-given" : "", const candidateHighlighted =
highlighted.has(cell) ? "is-digit-highlighted" : "", candidateOverlay?.candidateCells.includes(cell) ?? false;
selected.has(cell) ? "is-selected" : "", const candidateMask = candidates[cell] ?? 0;
conflicts.has(cell) ? "has-conflict" : "", const candidateHighlights = candidateHighlighted
colors[cell] ? `has-color-${String(colors[cell])}` : "", ? (candidateOverlay?.activeValues.filter(
] (digit) =>
.filter(Boolean) candidateMask === 0 ||
.join(" ")} (candidateMask & (1 << (digit - 1))) !== 0,
onPointerDown={(event) => onCellPointerDown(cell, event)} ) ?? [])
onPointerEnter={(event) => onCellPointerEnter(cell, event)} : [];
data-cell={cell} const conflict = conflicts.has(cell);
> const accessibleDescription = cellAccessibleDescription({
{value ? ( puzzle,
<span className="cell-value">{symbolFor(value, size)}</span> cell,
) : ( value,
<> corner,
<span className="corner-marks" aria-hidden="true"> center,
{corner.map((mark) => ( centerIsAutomatic,
<span key={mark}>{symbolFor(mark, size)}</span> color: colors[cell] ?? 0,
))} conflict,
</span> candidateHighlights,
<span className="center-marks" aria-hidden="true"> });
{center.map((mark) => symbolFor(mark, size)).join("")} return (
</span> <button
</> key={cell}
)} type="button"
</button> role="gridcell"
); aria-rowindex={rowIndex + 1}
})} aria-colindex={columnIndex + 1}
aria-label={`Row ${String(rowIndex + 1)}, column ${String(columnIndex + 1)}${value ? `, ${symbolFor(value, size)}` : ", empty"}`}
aria-description={accessibleDescription}
aria-selected={selected.has(cell)}
aria-readonly={isGiven}
aria-invalid={conflict || undefined}
tabIndex={cell === activeCell ? 0 : -1}
className={[
"sudoku-cell",
isGiven ? "is-given" : "",
highlighted.has(cell) ? "is-digit-highlighted" : "",
candidateHighlighted ? "is-candidate-highlighted" : "",
selected.has(cell) ? "is-selected" : "",
conflict ? "has-conflict" : "",
colors[cell] ? `has-color-${String(colors[cell])}` : "",
]
.filter(Boolean)
.join(" ")}
onPointerDown={(event) => onCellPointerDown(cell, event)}
onPointerEnter={(event) => onCellPointerEnter(cell, event)}
data-cell={cell}
>
{value ? (
<span className="cell-value">{symbolFor(value, size)}</span>
) : (
<>
<span className="corner-marks" aria-hidden="true">
{corner.map((mark) => (
<span key={mark}>{symbolFor(mark, size)}</span>
))}
</span>
<span className="center-marks" aria-hidden="true">
{center.map((mark) => symbolFor(mark, size)).join("")}
</span>
</>
)}
</button>
);
})}
</div>
))}
</div> </div>
<ConstraintLayer puzzle={puzzle} /> <ConstraintLayer puzzle={puzzle} />
{candidateOverlay !== undefined && candidateOverlay.links.length > 0 && (
<CandidateLinkLayer size={size} overlay={candidateOverlay} />
)}
</div> </div>
); );
} }
+557 -73
View File
@@ -24,6 +24,7 @@ import {
toDomainPuzzle, toDomainPuzzle,
type SudokuDocument, type SudokuDocument,
} from "../formats"; } from "../formats";
import type { CandidateOverlay } from "../helpers";
import { CLASSIC_SAMPLE, SAMPLE_CATALOG } from "../data/samples"; import { CLASSIC_SAMPLE, SAMPLE_CATALOG } from "../data/samples";
import type { import type {
DifficultyAssessment, DifficultyAssessment,
@@ -57,9 +58,37 @@ import {
matchingDigitCells, matchingDigitCells,
toggledDigitHighlight, toggledDigitHighlight,
} from "../state/gameplayHelpers"; } 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 { createSolverWorkerClient, type SolverWorkerClient } from "../workers";
import { ConstraintEditor } from "./ConstraintEditor"; import { ConstraintEditor } from "./ConstraintEditor";
import { AidMemoire } from "./AidMemoire";
import { DigitCompletionBar } from "./DigitCompletionBar"; import { DigitCompletionBar } from "./DigitCompletionBar";
import { GameplayHistoryDialog } from "./GameplayHistoryDialog";
import { GeneratorWorkspace } from "./GeneratorWorkspace"; import { GeneratorWorkspace } from "./GeneratorWorkspace";
import { HelpersWorkspace } from "./HelpersWorkspace"; import { HelpersWorkspace } from "./HelpersWorkspace";
import { ImportExportDialog } from "./ImportExportDialog"; import { ImportExportDialog } from "./ImportExportDialog";
@@ -79,6 +108,7 @@ interface Feedback {
interface HistoryEntry { interface HistoryEntry {
readonly puzzle: PuzzleDefinition; readonly puzzle: PuzzleDefinition;
readonly session: PlaySnapshot; readonly session: PlaySnapshot;
readonly aidMemoire: AidMemoireState;
} }
const library = createProjectLibrary(); const library = createProjectLibrary();
@@ -143,6 +173,7 @@ function progressFromSession(
puzzle: PuzzleDefinition, puzzle: PuzzleDefinition,
session: PlaySession, session: PlaySession,
completed: boolean, completed: boolean,
aidMemoire: AidMemoireState,
): SudokuProgress { ): SudokuProgress {
return { return {
version: 1, version: 1,
@@ -156,6 +187,7 @@ function progressFromSession(
colors: [...session.colors], colors: [...session.colors],
elapsedMs: session.elapsedSeconds * 1_000, elapsedMs: session.elapsedSeconds * 1_000,
completed, completed,
aidMemoire: aidMemoireToPortable(aidMemoire, puzzle.size),
}; };
} }
@@ -195,18 +227,27 @@ function safeNormalize(puzzle: PuzzleDefinition): {
function initialProject(): { function initialProject(): {
readonly puzzle: PuzzleDefinition; readonly puzzle: PuzzleDefinition;
readonly session: PlaySession; readonly session: PlaySession;
readonly aidMemoire: AidMemoireState;
} { } {
if (typeof location !== "undefined" && location.hash.includes("sudoku=")) { if (typeof location !== "undefined" && location.hash.includes("sudoku=")) {
try { try {
const document = decodePuzzleHash(location.href); const document = decodePuzzleHash(location.href);
const puzzle = normalizePuzzle(toDomainPuzzle(document)); const puzzle = normalizePuzzle(toDomainPuzzle(document));
return { puzzle, session: sessionFromDocument(puzzle, document) }; return {
puzzle,
session: sessionFromDocument(puzzle, document),
aidMemoire: aidMemoireFromPortable(document.aidMemoire, puzzle.size),
};
} catch { } catch {
// An invalid hash is non-fatal; the import dialog can report details. // An invalid hash is non-fatal; the import dialog can report details.
} }
} }
const puzzle = normalizePuzzle(CLASSIC_SAMPLE); 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 { function formatTime(seconds: number): string {
@@ -245,6 +286,8 @@ function errorMessage(error: unknown): string {
function constraintLabel(type: string): string { function constraintLabel(type: string): string {
if (type === "xv") return "Sum pairs (5/10)"; if (type === "xv") return "Sum pairs (5/10)";
if (type === "x-sum") return "X-sums";
if (type === "false-clues") return "False clues";
return type return type
.split("-") .split("-")
.map((part) => part[0]?.toUpperCase() + part.slice(1)) .map((part) => part[0]?.toUpperCase() + part.slice(1))
@@ -264,8 +307,19 @@ export function Workbench() {
const [showDigitCompletion, setShowDigitCompletion] = useState(true); const [showDigitCompletion, setShowDigitCompletion] = useState(true);
const [enableDigitHighlight, setEnableDigitHighlight] = useState(true); const [enableDigitHighlight, setEnableDigitHighlight] = useState(true);
const [highlightedDigit, setHighlightedDigit] = useState<number | null>(null); 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 [past, setPast] = useState<HistoryEntry[]>([]);
const [future, setFuture] = 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 [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState<Feedback>(); const [feedback, setFeedback] = useState<Feedback>();
const [logical, setLogical] = useState<LogicalSolveResult>(); const [logical, setLogical] = useState<LogicalSolveResult>();
@@ -286,9 +340,30 @@ export function Workbench() {
const workerRef = useRef<SolverWorkerClient | null>(null); const workerRef = useRef<SolverWorkerClient | null>(null);
const draggingRef = useRef(false); const draggingRef = useRef(false);
const workbenchRef = useRef<HTMLElement>(null); const workbenchRef = useRef<HTMLElement>(null);
const replayReturnSessionRef = useRef<PlaySession | null>(null);
const replayReturnAidMemoireRef = useRef<AidMemoireState | null>(null);
const normalized = useMemo(() => safeNormalize(puzzle), [puzzle]); 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 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( const completionData = useMemo(
() => digitCompletions(puzzle.size, session.values), () => digitCompletions(puzzle.size, session.values),
[puzzle.size, session.values], [puzzle.size, session.values],
@@ -345,20 +420,37 @@ export function Workbench() {
}, []); }, []);
const currentHistory = useCallback( const currentHistory = useCallback(
(): HistoryEntry => ({ puzzle, session: snapshotSession(session) }), (): HistoryEntry => ({
[puzzle, session], puzzle,
session: snapshotSession(session),
aidMemoire: cloneAidMemoire(aidMemoire),
}),
[aidMemoire, puzzle, session],
); );
const commit = useCallback( const commit = useCallback(
(nextPuzzle: PuzzleDefinition, nextSession: PlaySession): void => { (nextPuzzle: PuzzleDefinition, nextSession: PlaySession): void => {
setPast((entries) => [...entries, currentHistory()].slice(-MAX_HISTORY)); setPast((entries) => [...entries, currentHistory()].slice(-MAX_HISTORY));
setFuture([]); 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); setPuzzle(nextPuzzle);
setSession(nextSession); setSession(nextSession);
clearAnalysis(); clearAnalysis();
setFeedback(undefined); setFeedback(undefined);
}, },
[clearAnalysis, currentHistory], [aidMemoire, clearAnalysis, currentHistory, puzzle, session, workspace],
); );
const loadPuzzle = useCallback( const loadPuzzle = useCallback(
@@ -372,12 +464,26 @@ export function Workbench() {
| "candidates" | "candidates"
| "colors" | "colors"
| "elapsedMs" | "elapsedMs"
| "aidMemoire"
>, >,
projectId?: string, projectId?: string,
): void => { ): void => {
const valid = normalizePuzzle(nextPuzzle); const valid = normalizePuzzle(nextPuzzle);
const nextSession = sessionFromDocument(valid, progress);
const nextAidMemoire = aidMemoireFromPortable(
progress?.aidMemoire,
valid.size,
);
setPuzzle(valid); 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]); setSelection([0]);
setActiveCell(0); setActiveCell(0);
setHighlightedDigit(null); setHighlightedDigit(null);
@@ -393,8 +499,21 @@ export function Workbench() {
const restoreProject = useCallback( const restoreProject = useCallback(
(record: SudokuProjectRecord): void => { (record: SudokuProjectRecord): void => {
const valid = normalizePuzzle(toDomainPuzzle(record.puzzle)); 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); 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]); setSelection([0]);
setActiveCell(0); setActiveCell(0);
setHighlightedDigit(null); setHighlightedDigit(null);
@@ -451,24 +570,46 @@ export function Workbench() {
const undo = useCallback(() => { const undo = useCallback(() => {
const target = past.at(-1); const target = past.at(-1);
if (target === undefined) return; if (target === undefined) return;
const restored = restoreSnapshot(session, target.session);
setFuture((entries) => setFuture((entries) =>
[currentHistory(), ...entries].slice(0, MAX_HISTORY), [currentHistory(), ...entries].slice(0, MAX_HISTORY),
); );
setPast(past.slice(0, -1)); setPast(past.slice(0, -1));
setPuzzle(target.puzzle); 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();
}, [clearAnalysis, currentHistory, past]); }, [clearAnalysis, currentHistory, past, puzzle, session, workspace]);
const redo = useCallback(() => { const redo = useCallback(() => {
const target = future[0]; const target = future[0];
if (target === undefined) return; if (target === undefined) return;
const restored = restoreSnapshot(session, target.session);
setPast((entries) => [...entries, currentHistory()].slice(-MAX_HISTORY)); setPast((entries) => [...entries, currentHistory()].slice(-MAX_HISTORY));
setFuture(future.slice(1)); setFuture(future.slice(1));
setPuzzle(target.puzzle); 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();
}, [clearAnalysis, currentHistory, future]); }, [clearAnalysis, currentHistory, future, puzzle, session, workspace]);
const changeSelection = useCallback( const changeSelection = useCallback(
(cell: number, additive: boolean, toggle: boolean): void => { (cell: number, additive: boolean, toggle: boolean): void => {
@@ -488,6 +629,7 @@ export function Workbench() {
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
(cell: number, event: PointerEvent<HTMLButtonElement>) => { (cell: number, event: PointerEvent<HTMLButtonElement>) => {
if (session.paused) return; if (session.paused) return;
setAidMemoireActive(false);
event.preventDefault(); event.preventDefault();
event.currentTarget.focus(); event.currentTarget.focus();
const command = event.ctrlKey || event.metaKey; const command = event.ctrlKey || event.metaKey;
@@ -528,9 +670,42 @@ export function Workbench() {
[changeSelection, session.paused], [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( const enterValue = useCallback(
(value: number): void => { (value: number, target: "auto" | "board" = "auto"): void => {
if (session.paused) return; 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") { if (workspace === "set" && entryMode === "value") {
const givens = [...puzzle.givens]; const givens = [...puzzle.givens];
const values = [...session.values]; const values = [...session.values];
@@ -554,37 +729,68 @@ export function Workbench() {
enterSelection(session, selectedSet, entryMode, value, puzzle.givens), 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(
if (session.paused) return; (target: "auto" | "board" = "auto"): void => {
if (workspace === "set" && entryMode === "value") { if (session.paused) return;
const givens = [...puzzle.givens]; if (
const values = [...session.values]; target === "auto" &&
for (const cell of selectedSet) { workspace === "play" &&
givens[cell] = 0; aidMemoire.enabled &&
values[cell] = 0; aidMemoireActive
) {
updateAidMemoire(
eraseAidMemoireCell(aidMemoire, aidMemoireCell, entryMode),
`Erased aid-mémoire cell ${String(aidMemoireCell + 1)}`,
);
return;
} }
commit({ ...puzzle, givens }, { ...session, values }); if (workspace === "set" && entryMode === "value") {
return; const givens = [...puzzle.givens];
} const values = [...session.values];
commit( for (const cell of selectedSet) {
givens[cell] = 0;
values[cell] = 0;
}
commit({ ...puzzle, givens }, { ...session, values });
return;
}
commit(
puzzle,
eraseSelection(session, selectedSet, entryMode, puzzle.givens),
);
},
[
aidMemoire,
aidMemoireActive,
aidMemoireCell,
commit,
entryMode,
puzzle, puzzle,
eraseSelection(session, selectedSet, entryMode, puzzle.givens), selectedSet,
); session,
}, [commit, entryMode, puzzle, selectedSet, session, workspace]); updateAidMemoire,
workspace,
],
);
const moveActive = useCallback( const moveActive = useCallback(
(deltaRow: number, deltaColumn: number, extend: boolean): void => { (key: string, extend: boolean, command: boolean): void => {
const row = Math.floor(activeCell / puzzle.size); const next = navigateGridCell(activeCell, puzzle.size, key, command);
const column = activeCell % puzzle.size; if (next === null) return;
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;
changeSelection(next, extend, false); changeSelection(next, extend, false);
requestAnimationFrame(() => { requestAnimationFrame(() => {
workbenchRef.current workbenchRef.current
@@ -597,6 +803,7 @@ export function Workbench() {
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => { (event: KeyboardEvent<HTMLDivElement>): void => {
setAidMemoireActive(false);
const command = event.ctrlKey || event.metaKey; const command = event.ctrlKey || event.metaKey;
if (command && event.key.toLowerCase() === "z") { if (command && event.key.toLowerCase() === "z") {
event.preventDefault(); event.preventDefault();
@@ -620,16 +827,20 @@ export function Workbench() {
); );
return; return;
} }
const movement: Record<string, readonly [number, number]> = { if (
ArrowUp: [-1, 0], [
ArrowDown: [1, 0], "ArrowUp",
ArrowLeft: [0, -1], "ArrowDown",
ArrowRight: [0, 1], "ArrowLeft",
}; "ArrowRight",
const delta = movement[event.key]; "Home",
if (delta !== undefined) { "End",
"PageUp",
"PageDown",
].includes(event.key)
) {
event.preventDefault(); event.preventDefault();
moveActive(delta[0], delta[1], event.shiftKey); moveActive(event.key, event.shiftKey, command);
return; return;
} }
if ( if (
@@ -638,7 +849,7 @@ export function Workbench() {
event.key === "0" event.key === "0"
) { ) {
event.preventDefault(); event.preventDefault();
erase(); erase("board");
return; return;
} }
if (!command) { if (!command) {
@@ -657,7 +868,7 @@ export function Workbench() {
const value = valueForKey(event.key, puzzle.size); const value = valueForKey(event.key, puzzle.size);
if (value !== null) { if (value !== null) {
event.preventDefault(); event.preventDefault();
enterValue(value); enterValue(value, "board");
} }
} }
}, },
@@ -789,16 +1000,18 @@ export function Workbench() {
kind: kind:
result.count === 1 && !result.truncated result.count === 1 && !result.truncated
? "success" ? "success"
: result.count === 0 || result.count >= 2 : result.count >= 2 || (result.count === 0 && !result.truncated)
? "error" ? "error"
: "info", : "info",
message: message:
result.count === 0 result.count >= 2
? "The definition has no solution." ? "The definition has multiple solutions."
: result.count >= 2 : result.truncated
? "The definition has multiple solutions." ? result.count === 0
: result.truncated ? "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." : "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).`, : `The definition is valid and uniquely solvable (${result.nodes.toLocaleString()} search nodes).`,
}); });
} catch (error) { } catch (error) {
@@ -828,7 +1041,7 @@ export function Workbench() {
setWorkspace("generate"); setWorkspace("generate");
setFeedback({ setFeedback({
kind: "success", 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) { } catch (error) {
setFeedback({ kind: "error", message: errorMessage(error) }); setFeedback({ kind: "error", message: errorMessage(error) });
@@ -899,7 +1112,7 @@ export function Workbench() {
try { try {
const now = Date.now(); const now = Date.now();
const document = fromDomainPuzzle(puzzle); const document = fromDomainPuzzle(puzzle);
const progress = progressFromSession(puzzle, session, solved); const progress = progressFromSession(puzzle, session, solved, aidMemoire);
let record: SudokuProjectRecord; let record: SudokuProjectRecord;
const existing = currentProjectId const existing = currentProjectId
? await library.get(currentProjectId) ? await library.get(currentProjectId)
@@ -928,7 +1141,7 @@ export function Workbench() {
} finally { } finally {
setLibraryBusy(false); setLibraryBusy(false);
} }
}, [currentProjectId, puzzle, refreshLibrary, session, solved]); }, [aidMemoire, currentProjectId, puzzle, refreshLibrary, session, solved]);
const openProject = useCallback( const openProject = useCallback(
async (id: string) => { async (id: string) => {
@@ -1032,13 +1245,167 @@ export function Workbench() {
if (cells.length === 0) return; if (cells.length === 0) return;
setSelection([...cells]); setSelection([...cells]);
setActiveCell(cells[0]!); setActiveCell(cells[0]!);
setAidMemoireActive(false);
}, []); }, []);
const activeConstraints = useMemo( const createNamedSavepoint = useCallback(
() => [...new Set(normalized.puzzle.constraints.map((item) => item.type))], (name: string) => {
[normalized.puzzle.constraints], 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 ( return (
<main className="sudoku-workbench" ref={workbenchRef}> <main className="sudoku-workbench" ref={workbenchRef}>
<header className="workbench-hero"> <header className="workbench-hero">
@@ -1119,7 +1486,15 @@ export function Workbench() {
type="button" type="button"
className={workspace === id ? "is-active" : ""} className={workspace === id ? "is-active" : ""}
aria-current={workspace === id ? "page" : undefined} 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={() => { onClick={() => {
if (id !== "play") returnToLiveGrid();
if (id !== "play") setAidMemoireActive(false);
setWorkspace(id); setWorkspace(id);
if (id === "set") setEntryMode("value"); if (id === "set") setEntryMode("value");
}} }}
@@ -1169,20 +1544,46 @@ export function Workbench() {
<section className="board-column" aria-label="Puzzle board"> <section className="board-column" aria-label="Puzzle board">
<div className="board-toolbar"> <div className="board-toolbar">
<div className="toolbar-group"> <div className="toolbar-group">
<button type="button" disabled={!past.length} onClick={undo}> <button
type="button"
disabled={!past.length || replayMoment !== undefined}
onClick={undo}
>
Undo Undo
</button> </button>
<button type="button" disabled={!future.length} onClick={redo}> <button
type="button"
disabled={!future.length || replayMoment !== undefined}
onClick={redo}
>
Redo Redo
</button> </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> </div>
<span> <span>
{selection.length === 1 {replayMoment
? cellLabel(selection[0]!, puzzle.size) ? `Replay · ${replayMoment.label}`
: `${String(selection.length)} cells selected`} : 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> </span>
</div> </div>
<div className={`board-surface${session.paused ? " is-paused" : ""}`}> <div
className={`board-surface${session.paused && replayMoment === undefined ? " is-paused" : ""}${replayMoment ? " is-replay" : ""}`}
>
<SudokuBoard <SudokuBoard
puzzle={normalized.puzzle} puzzle={normalized.puzzle}
values={session.values} values={session.values}
@@ -1190,16 +1591,19 @@ export function Workbench() {
centerMarks={session.centerMarks} centerMarks={session.centerMarks}
colors={session.colors} colors={session.colors}
candidates={candidateMasks} candidates={candidateMasks}
selected={selectedSet} selected={boardSelectedSet}
highlighted={highlightedCells} highlighted={highlightedCells}
conflicts={conflictCells} conflicts={conflictCells}
activeCell={activeCell} activeCell={activeCell}
showCandidates={showCandidates} showCandidates={showCandidates}
candidateOverlay={
workspace === "helpers" ? candidateOverlay : undefined
}
onCellPointerDown={handlePointerDown} onCellPointerDown={handlePointerDown}
onCellPointerEnter={handlePointerEnter} onCellPointerEnter={handlePointerEnter}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
/> />
{session.paused && ( {session.paused && replayMoment === undefined && (
<button <button
type="button" type="button"
className="paused-cover" 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"> <div className="board-meta">
<section className="rule-card"> <section className="rule-card">
<p className="eyebrow">Rules</p> <p className="eyebrow">Rules</p>
@@ -1247,13 +1670,37 @@ export function Workbench() {
</section> </section>
<aside className="side-panel"> <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 className="play-panel stack">
<div> <div>
<p className="eyebrow">Play locally</p> <p className="eyebrow">Play locally</p>
<h2>Enter your solve</h2> <h2>Enter your solve</h2>
<p className="muted"> <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> </p>
</div> </div>
<NumberPad <NumberPad
@@ -1323,6 +1770,22 @@ export function Workbench() {
/> />
Enable matching-digit highlighting (Ctrl/-click or bar) Enable matching-digit highlighting (Ctrl/-click or bar)
</label> </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>
<section className="panel-section"> <section className="panel-section">
<button <button
@@ -1334,8 +1797,9 @@ export function Workbench() {
</button> </button>
</section> </section>
<p className="muted shortcut-note"> <p className="muted shortcut-note">
Z/X/C/V change mode · arrows move · Shift extends · Ctrl/ Z Z/X/C/V change mode · arrows move · Home/End move across a row ·
undoes · Ctrl/-click a placed digit highlights its matches. Shift extends · Ctrl/ Z undoes · Ctrl/-click a placed digit
highlights its matches.
</p> </p>
</div> </div>
)} )}
@@ -1401,9 +1865,13 @@ export function Workbench() {
{workspace === "helpers" && ( {workspace === "helpers" && (
<HelpersWorkspace <HelpersWorkspace
key={puzzle.size}
size={puzzle.size} size={puzzle.size}
puzzle={normalized.puzzle}
values={session.values}
selectedCells={selection} selectedCells={selection}
candidateMasks={candidateMasks} candidateMasks={candidateMasks}
onCandidateOverlayChange={handleCandidateOverlayChange}
/> />
)} )}
</aside> </aside>
@@ -1413,6 +1881,7 @@ export function Workbench() {
open={importOpen} open={importOpen}
puzzle={puzzle} puzzle={puzzle}
session={session} session={session}
aidMemoire={aidMemoireToPortable(aidMemoire, puzzle.size)}
onClose={() => setImportOpen(false)} onClose={() => setImportOpen(false)}
onImport={(next, progress) => { onImport={(next, progress) => {
try { try {
@@ -1437,6 +1906,21 @@ export function Workbench() {
onExport={() => void exportLibrary()} onExport={() => void exportLibrary()}
onImport={(file) => void importLibrary(file)} 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> </main>
); );
} }
+51
View File
@@ -237,6 +237,52 @@ export const NON_CONSECUTIVE_SAMPLE: PuzzleDefinition = {
solution: grid("315264", "642531", "264153", "531426", "153642", "426315"), 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 { export interface SamplePuzzleEntry {
readonly id: string; readonly id: string;
readonly label: string; readonly label: string;
@@ -277,6 +323,11 @@ export const SAMPLE_CATALOG: readonly SamplePuzzleEntry[] = [
label: "Non-consecutive 6 × 6", label: "Non-consecutive 6 × 6",
puzzle: NON_CONSECUTIVE_SAMPLE, 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( export const SAMPLE_PUZZLES: readonly PuzzleDefinition[] = SAMPLE_CATALOG.map(
+38 -2
View File
@@ -1,5 +1,10 @@
import { cellColumn, cellRow, orthogonalNeighbours } from "./geometry"; 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"; 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); 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( function cellsForConstraint(
size: number, size: number,
constraint: VariantConstraint, constraint: VariantConstraint,
@@ -52,6 +77,13 @@ function cellsForConstraint(
return [constraint.a, constraint.b]; return [constraint.a, constraint.b];
case "inequality": case "inequality":
return [constraint.lesser, constraint.greater]; 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))) { for (const cell of new Set(cellsForConstraint(size, constraint))) {
constraintsByCell[cell]?.push(constraintIndex); 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 a of constraint.cells) {
for (const b of constraint.cells) addPeerPair(peers, a, b); for (const b of constraint.cells) addPeerPair(peers, a, b);
} }
+33
View File
@@ -126,3 +126,36 @@ export function orthogonalNeighbours(size: number, cell: CellId): CellId[] {
if (column + 1 < size) result.push(cell + 1); if (column + 1 < size) result.push(cell + 1);
return result; 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
View File
@@ -1,4 +1,9 @@
import { compilePuzzle, type CompiledPuzzle } from "./compile"; import {
compilePuzzle,
outsideLineCells,
type CompiledPuzzle,
} from "./compile";
import { orthogonalNeighbours } from "./geometry";
import type { import type {
CellId, CellId,
NormalizedPuzzle, NormalizedPuzzle,
@@ -21,8 +26,9 @@ function sumRange(
size: number, size: number,
noRepeat: boolean, noRepeat: boolean,
): readonly [number, number] { ): readonly [number, number] {
const current = assigned.reduce((sum, value) => sum + value, 0);
if (blanks === 0) return [current, current];
if (!noRepeat) { if (!noRepeat) {
const current = assigned.reduce((sum, value) => sum + value, 0);
return [current + blanks, current + blanks * size]; return [current + blanks, current + blanks * size];
} }
const used = new Set(assigned); const used = new Set(assigned);
@@ -32,7 +38,6 @@ function sumRange(
).filter((value) => !used.has(value)); ).filter((value) => !used.has(value));
if (available.length < blanks) if (available.length < blanks)
return [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]; return [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
const current = assigned.reduce((sum, value) => sum + value, 0);
const low = available const low = available
.slice(0, blanks) .slice(0, blanks)
.reduce((sum, value) => sum + value, current); .reduce((sum, value) => sum + value, current);
@@ -57,7 +62,217 @@ function sumsCanMeet(
return [sum + blanks, sum + blanks * size]; 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, constraint: VariantConstraint,
values: readonly number[], values: readonly number[],
size: number, size: number,
@@ -168,9 +383,147 @@ export function constraintIsFeasible(
} }
return true; 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( function asCompiled(
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle, puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
): CompiledPuzzle { ): CompiledPuzzle {
@@ -308,6 +661,7 @@ export function findConflicts(
case "thermo": case "thermo":
case "renban": case "renban":
case "palindrome": case "palindrome":
case "quadruple":
return constraint.cells; return constraint.cells;
case "arrow": case "arrow":
return [...constraint.bulb, ...constraint.line]; return [...constraint.bulb, ...constraint.line];
@@ -316,12 +670,27 @@ export function findConflicts(
return [constraint.a, constraint.b]; return [constraint.a, constraint.b];
case "inequality": case "inequality":
return [constraint.lesser, constraint.greater]; 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({ conflicts.push({
kind: "constraint", kind: "constraint",
cells, 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, constraintIndex,
}); });
} }
+52 -1
View File
@@ -29,12 +29,15 @@ export interface KillerCageConstraint {
readonly sum: number; readonly sum: number;
/** Killer cages normally do not repeat digits. Defaults to true. */ /** Killer cages normally do not repeat digits. Defaults to true. */
readonly noRepeat?: boolean; readonly noRepeat?: boolean;
/** If true, the completed clue must be false instead of true. */
readonly negated?: boolean;
} }
export interface ThermoConstraint { export interface ThermoConstraint {
readonly type: "thermo"; readonly type: "thermo";
/** Ordered from bulb to tip. Values must increase strictly. */ /** Ordered from bulb to tip. Values must increase strictly. */
readonly cells: readonly CellId[]; readonly cells: readonly CellId[];
readonly negated?: boolean;
} }
export interface ArrowConstraint { export interface ArrowConstraint {
@@ -42,6 +45,7 @@ export interface ArrowConstraint {
/** Bulb cells. Their values sum to the values on the line. */ /** Bulb cells. Their values sum to the values on the line. */
readonly bulb: readonly CellId[]; readonly bulb: readonly CellId[];
readonly line: readonly CellId[]; readonly line: readonly CellId[];
readonly negated?: boolean;
} }
export interface KropkiConstraint { export interface KropkiConstraint {
@@ -50,6 +54,7 @@ export interface KropkiConstraint {
readonly b: CellId; readonly b: CellId;
/** White means consecutive; black means a 1:2 ratio. */ /** White means consecutive; black means a 1:2 ratio. */
readonly kind: "white" | "black"; readonly kind: "white" | "black";
readonly negated?: boolean;
} }
export interface XvConstraint { export interface XvConstraint {
@@ -57,22 +62,64 @@ export interface XvConstraint {
readonly a: CellId; readonly a: CellId;
readonly b: CellId; readonly b: CellId;
readonly total: 5 | 10; readonly total: 5 | 10;
readonly negated?: boolean;
} }
export interface InequalityConstraint { export interface InequalityConstraint {
readonly type: "inequality"; readonly type: "inequality";
readonly lesser: CellId; readonly lesser: CellId;
readonly greater: CellId; readonly greater: CellId;
readonly negated?: boolean;
} }
export interface RenbanConstraint { export interface RenbanConstraint {
readonly type: "renban"; readonly type: "renban";
readonly cells: readonly CellId[]; readonly cells: readonly CellId[];
readonly negated?: boolean;
} }
export interface PalindromeConstraint { export interface PalindromeConstraint {
readonly type: "palindrome"; readonly type: "palindrome";
readonly cells: readonly CellId[]; 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 = export type VariantConstraint =
@@ -87,7 +134,11 @@ export type VariantConstraint =
| XvConstraint | XvConstraint
| InequalityConstraint | InequalityConstraint
| RenbanConstraint | RenbanConstraint
| PalindromeConstraint; | PalindromeConstraint
| XSumConstraint
| SkyscraperConstraint
| QuadrupleConstraint
| MaximumConstraint;
export interface PuzzleDefinition { export interface PuzzleDefinition {
readonly version: 1; readonly version: 1;
+240 -12
View File
@@ -1,4 +1,4 @@
import { classicRegions } from "./geometry"; import { cellsFormQuadruple, classicRegions } from "./geometry";
import { compilePuzzle } from "./compile"; import { compilePuzzle } from "./compile";
import { findConflicts } from "./rules"; import { findConflicts } from "./rules";
import { import {
@@ -15,6 +15,8 @@ import {
const MAX_CONSTRAINTS = 4_096; const MAX_CONSTRAINTS = 4_096;
const MAX_SHORT_TEXT = 256; const MAX_SHORT_TEXT = 256;
const MAX_RULES_TEXT = 16_384; 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([ const ROOT_KEYS = new Set([
"version", "version",
"size", "size",
@@ -35,16 +37,22 @@ const CONSTRAINT_KEYS: Readonly<
"anti-knight": new Set(["type"]), "anti-knight": new Set(["type"]),
"anti-king": new Set(["type"]), "anti-king": new Set(["type"]),
"non-consecutive": new Set(["type"]), "non-consecutive": new Set(["type"]),
"killer-cage": new Set(["type", "cells", "sum", "noRepeat"]), "killer-cage": new Set(["type", "cells", "sum", "noRepeat", "negated"]),
thermo: new Set(["type", "cells"]), thermo: new Set(["type", "cells", "negated"]),
arrow: new Set(["type", "bulb", "line"]), arrow: new Set(["type", "bulb", "line", "negated"]),
kropki: new Set(["type", "a", "b", "kind"]), kropki: new Set(["type", "a", "b", "kind", "negated"]),
xv: new Set(["type", "a", "b", "total"]), xv: new Set(["type", "a", "b", "total", "negated"]),
inequality: new Set(["type", "lesser", "greater"]), inequality: new Set(["type", "lesser", "greater", "negated"]),
renban: new Set(["type", "cells"]), renban: new Set(["type", "cells", "negated"]),
palindrome: new Set(["type", "cells"]), 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> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === "object" && value !== null && !Array.isArray(value);
} }
@@ -108,6 +116,72 @@ function validateCells(
return true; 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( function validateConstraint(
value: unknown, value: unknown,
index: number, index: number,
@@ -129,6 +203,13 @@ function validateConstraint(
if (!allowed.has(key)) if (!allowed.has(key))
add(issues, `${path}.${key}`, "is not a recognized field"); 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; const cellCount = size * size;
switch (type) { switch (type) {
case "diagonal": case "diagonal":
@@ -166,8 +247,17 @@ function validateConstraint(
? (length * (2 * size - length + 1)) / 2 ? (length * (2 * size - length + 1)) / 2
: length * size; : length * size;
if ( if (
(value.sum as number) < minimum || value.negated === true &&
(value.sum as number) > maximum ((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( add(
issues, issues,
@@ -242,6 +332,86 @@ function validateConstraint(
issues, issues,
); );
break; 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 ...(constraint.noRepeat === undefined
? {} ? {}
: { noRepeat: constraint.noRepeat }), : { noRepeat: constraint.noRepeat }),
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
}; };
case "thermo": case "thermo":
case "renban": case "renban":
case "palindrome": case "palindrome":
return { type: constraint.type, cells: [...constraint.cells] }; return {
type: constraint.type,
cells: [...constraint.cells],
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
};
case "arrow": case "arrow":
return { return {
type: constraint.type, type: constraint.type,
bulb: [...constraint.bulb], bulb: [...constraint.bulb],
line: [...constraint.line], line: [...constraint.line],
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
}; };
case "kropki": case "kropki":
return { return {
@@ -304,6 +486,9 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
a: constraint.a, a: constraint.a,
b: constraint.b, b: constraint.b,
kind: constraint.kind, kind: constraint.kind,
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
}; };
case "xv": case "xv":
return { return {
@@ -311,12 +496,55 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
a: constraint.a, a: constraint.a,
b: constraint.b, b: constraint.b,
total: constraint.total, total: constraint.total,
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
}; };
case "inequality": case "inequality":
return { return {
type: constraint.type, type: constraint.type,
lesser: constraint.lesser, lesser: constraint.lesser,
greater: constraint.greater, 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 }),
}; };
} }
} }
+247
View File
@@ -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 062 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
View File
@@ -1,7 +1,10 @@
import { cellsFormQuadruple } from "../domain/geometry";
import { normalizePortableAidMemoire } from "../state/aidMemoire";
import { import {
SUDOKU_DOCUMENT_SCHEMA, SUDOKU_DOCUMENT_SCHEMA,
SUDOKU_DOCUMENT_VERSION, SUDOKU_DOCUMENT_VERSION,
cloneConstraint, cloneConstraint,
type PortableAidMemoire,
type PortableConstraint, type PortableConstraint,
type SudokuDocument, type SudokuDocument,
} from "./types"; } from "./types";
@@ -121,6 +124,34 @@ function pair(
return { a, b }; 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( function parseConstraint(
value: unknown, value: unknown,
cellCount: number, cellCount: number,
@@ -131,6 +162,7 @@ function parseConstraint(
"Each constraint must be an object with a type.", "Each constraint must be an object with a type.",
); );
} }
const size = Math.sqrt(cellCount);
switch (value.type) { switch (value.type) {
case "diagonal": { case "diagonal": {
if (value.direction !== "main" && value.direction !== "anti") { if (value.direction !== "main" && value.direction !== "anti") {
@@ -164,6 +196,7 @@ function parseConstraint(
cells: cageCells, cells: cageCells,
sum, sum,
...(value.noRepeat === undefined ? {} : { noRepeat: value.noRepeat }), ...(value.noRepeat === undefined ? {} : { noRepeat: value.noRepeat }),
...cluePolarity(value, "killer-cage"),
}; };
} }
case "thermo": case "thermo":
@@ -172,12 +205,14 @@ function parseConstraint(
return { return {
type: value.type, type: value.type,
cells: cells(value.cells, `${value.type}.cells`, cellCount, 2), cells: cells(value.cells, `${value.type}.cells`, cellCount, 2),
...cluePolarity(value, value.type),
}; };
case "arrow": case "arrow":
return { return {
type: "arrow", type: "arrow",
bulb: cells(value.bulb, "arrow.bulb", cellCount), bulb: cells(value.bulb, "arrow.bulb", cellCount),
line: cells(value.line, "arrow.line", cellCount), line: cells(value.line, "arrow.line", cellCount),
...cluePolarity(value, "arrow"),
}; };
case "kropki": { case "kropki": {
const related = pair(value, cellCount); const related = pair(value, cellCount);
@@ -187,7 +222,12 @@ function parseConstraint(
"A Kropki kind must be white or black.", "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": { case "xv": {
const related = pair(value, cellCount); const related = pair(value, cellCount);
@@ -198,6 +238,7 @@ function parseConstraint(
type: "xv", type: "xv",
...related, ...related,
total: value.total, total: value.total,
...cluePolarity(value, "xv"),
}; };
} }
case "inequality": { case "inequality": {
@@ -209,8 +250,83 @@ function parseConstraint(
"An inequality must join two different cells.", "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: default:
return fail( return fail(
"UNSUPPORTED_CONSTRAINT", "UNSUPPORTED_CONSTRAINT",
@@ -316,6 +432,19 @@ export function normalizeSudokuDocument(value: unknown): SudokuDocument {
} }
elapsedMs = value.elapsedMs; 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 ( if (
!Array.isArray(value.constraints) || !Array.isArray(value.constraints) ||
@@ -348,6 +477,7 @@ export function normalizeSudokuDocument(value: unknown): SudokuDocument {
...(candidates === undefined ? {} : { candidates }), ...(candidates === undefined ? {} : { candidates }),
...(colors === undefined ? {} : { colors }), ...(colors === undefined ? {} : { colors }),
...(elapsedMs === undefined ? {} : { elapsedMs }), ...(elapsedMs === undefined ? {} : { elapsedMs }),
...(aidMemoire === undefined ? {} : { aidMemoire }),
...(regions === undefined ? {} : { regions }), ...(regions === undefined ? {} : { regions }),
...(title === undefined ? {} : { title }), ...(title === undefined ? {} : { title }),
...(author === undefined ? {} : { author }), ...(author === undefined ? {} : { author }),
@@ -416,6 +546,14 @@ export function cloneSudokuDocument(value: SudokuDocument): SudokuDocument {
...(normalized.colors === undefined ...(normalized.colors === undefined
? {} ? {}
: { colors: [...normalized.colors] }), : { colors: [...normalized.colors] }),
...(normalized.aidMemoire === undefined
? {}
: {
aidMemoire: normalizePortableAidMemoire(
normalized.aidMemoire,
normalized.size,
),
}),
...(normalized.regions === undefined ...(normalized.regions === undefined
? {} ? {}
: { regions: [...normalized.regions] }), : { regions: [...normalized.regions] }),
+179 -30
View File
@@ -1,14 +1,16 @@
import { compressToBase64 } from "lz-string";
import { cellsFormQuadruple } from "../domain/geometry";
import { import {
compressToBase64, LzStringOutputLimitError,
decompressFromBase64, decompressFromBase64OrUriComponentBounded,
decompressFromEncodedURIComponent, } from "./boundedLz";
} from "lz-string";
import { import {
MAX_BOARD_SIZE, MAX_BOARD_SIZE,
MAX_DOCUMENT_BYTES, MAX_DOCUMENT_BYTES,
MIN_BOARD_SIZE, MIN_BOARD_SIZE,
SudokuFormatError, SudokuFormatError,
} from "./document"; } from "./document";
import { UnsupportedPuzzleConstructsError } from "./interoperability";
import { import {
SUDOKU_DOCUMENT_SCHEMA, SUDOKU_DOCUMENT_SCHEMA,
SUDOKU_DOCUMENT_VERSION, SUDOKU_DOCUMENT_VERSION,
@@ -38,6 +40,10 @@ const SUPPORTED_ROOT_FIELDS = new Set([
"ratio", "ratio",
"xv", "xv",
"inequality", "inequality",
"xsum",
"skyscraper",
"quadruple",
"maximum",
"renban", "renban",
"palindrome", "palindrome",
"disabledlogic", "disabledlogic",
@@ -54,10 +60,8 @@ const UNSUPPORTED_RULE_FIELDS: Readonly<Record<string, string>> = {
odd: "odd cells", odd: "odd cells",
extraregion: "extra regions", extraregion: "extra regions",
clone: "clone regions", clone: "clone regions",
quadruple: "quadruples",
betweenline: "between lines", betweenline: "between lines",
minimum: "minimum cells", minimum: "minimum cells",
maximum: "maximum cells",
whispers: "whisper lines", whispers: "whisper lines",
regionsumline: "region-sum lines", regionsumline: "region-sum lines",
entropicline: "entropic lines", entropicline: "entropic lines",
@@ -69,8 +73,6 @@ const UNSUPPORTED_RULE_FIELDS: Readonly<Record<string, string>> = {
rowindexer: "row indexers", rowindexer: "row indexers",
columnindexer: "column indexers", columnindexer: "column indexers",
boxindexer: "box indexers", boxindexer: "box indexers",
xsum: "X-sums",
skyscraper: "skyscrapers",
fogofwar: "fog of war", fogofwar: "fog of war",
foglight: "fog lights", foglight: "fog lights",
cage: "generic cages", cage: "generic cages",
@@ -115,32 +117,29 @@ function present(value: unknown): boolean {
} }
function assertSupportedRootFields(value: JsonRecord): void { function assertSupportedRootFields(value: JsonRecord): void {
const constructs: string[] = [];
for (const [field, raw] of Object.entries(value)) { for (const [field, raw] of Object.entries(value)) {
const unsupported = UNSUPPORTED_RULE_FIELDS[field]; const unsupported = UNSUPPORTED_RULE_FIELDS[field];
if (unsupported !== undefined && present(raw)) { if (unsupported !== undefined && present(raw)) {
return fail( constructs.push(unsupported);
"UNSUPPORTED_FPUZZLES", continue;
`This puzzle uses ${unsupported}, which Sudoku Tools cannot enforce yet. Import stopped rather than silently weakening the puzzle.`,
);
} }
const decoration = DECORATION_FIELDS[field]; const decoration = DECORATION_FIELDS[field];
if (decoration !== undefined && present(raw)) { if (decoration !== undefined && present(raw)) {
return fail( constructs.push(decoration);
"UNSUPPORTED_FPUZZLES", continue;
`This puzzle contains ${decoration}, which cannot be preserved yet. Import stopped rather than discarding them.`,
);
} }
if ( if (
!SUPPORTED_ROOT_FIELDS.has(field) && !SUPPORTED_ROOT_FIELDS.has(field) &&
unsupported === undefined && unsupported === undefined &&
decoration === undefined decoration === undefined
) { ) {
return fail( constructs.push(`Unknown fpuzzles field “${field}`);
"UNSUPPORTED_FPUZZLES",
`Unknown fpuzzles field “${field}”. Import stopped so a rule or visual cannot be silently lost.`,
);
} }
} }
if (constructs.length > 0) {
throw new UnsupportedPuzzleConstructsError("f-puzzles", constructs);
}
} }
function boundedJson(input: string): unknown { 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}`; 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[] { function fpCells(value: unknown, size: number, minimum = 1): number[] {
if ( if (
!Array.isArray(value) || !Array.isArray(value) ||
@@ -470,7 +519,62 @@ export function parseFpuzzles(value: unknown): SudokuDocument {
const [a, b] = inequalityCells as [number, number]; const [a, b] = inequalityCells as [number, number];
if (inequality.value === ">") if (inequality.value === ">")
constraints.push({ type: "inequality", lesser: b, greater: a }); 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); const regions = readRegions(grid, size);
@@ -548,6 +652,12 @@ export function exportFpuzzles(document: SudokuDocument): JsonRecord {
}; };
for (const constraint of document.constraints) { 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) { switch (constraint.type) {
case "diagonal": case "diagonal":
output[constraint.direction === "main" ? "diagonal+" : "diagonal-"] = output[constraint.direction === "main" ? "diagonal+" : "diagonal-"] =
@@ -621,6 +731,39 @@ export function exportFpuzzles(document: SudokuDocument): JsonRecord {
value: "<", value: "<",
}); });
break; 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 { } catch {
// URLSearchParams already decodes input. Keep the original if a literal % is malformed. // URLSearchParams already decodes input. Keep the original if a literal % is malformed.
} }
const base64 = decompressFromBase64(decodedPayload.replaceAll(" ", "+")); let uriEncoded: string | null;
const uriEncoded = try {
base64 || decompressFromEncodedURIComponent(decodedPayload); 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 === "") { if (uriEncoded === null || uriEncoded === "") {
return fail( return fail(
"INVALID_FPUZZLES", "INVALID_FPUZZLES",
"The inline fpuzzles payload could not be decompressed.", "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; return uriEncoded;
} }
+189
View File
@@ -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",
};
}
+5
View File
@@ -1,5 +1,10 @@
export * from "./document"; export * from "./document";
export * from "./fpuzzles"; export * from "./fpuzzles";
export * from "./grid"; export * from "./grid";
export * from "./import";
export * from "./interoperability";
export * from "./penpa";
export * from "./share"; export * from "./share";
export * from "./sudokupad";
export * from "./types"; export * from "./types";
export * from "./visual";
+37
View File
@@ -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";
}
}
+581
View File
@@ -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
View File
@@ -1,7 +1,8 @@
import { compressToEncodedURIComponent } from "lz-string";
import { import {
compressToEncodedURIComponent, LzStringOutputLimitError,
decompressFromEncodedURIComponent, decompressFromEncodedURIComponentBounded,
} from "lz-string"; } from "./boundedLz";
import { import {
MAX_DOCUMENT_BYTES, MAX_DOCUMENT_BYTES,
SudokuFormatError, SudokuFormatError,
@@ -49,18 +50,27 @@ export function decodePuzzleHash(hashOrUrl: string): SudokuDocument {
); );
} }
const compressed = hash.slice(PUZZLE_HASH_PREFIX.length); 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 === "") { if (json === null || json === "") {
throw new SudokuFormatError( throw new SudokuFormatError(
"INVALID_SHARE", "INVALID_SHARE",
"The share payload could not be decompressed.", "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); return parseSudokuDocument(json);
} }
+489
View File
@@ -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
View File
@@ -1,45 +1,84 @@
import type { PortableAidMemoire } from "../state/aidMemoire";
export type {
PortableAidMemoire,
PortableAidMemoireCell,
} from "../state/aidMemoire";
export const SUDOKU_DOCUMENT_SCHEMA = export const SUDOKU_DOCUMENT_SCHEMA =
"de.add-ideas.sudoku-tools.puzzle" as const; "de.add-ideas.sudoku-tools.puzzle" as const;
export const SUDOKU_DOCUMENT_VERSION = 1 as const; export const SUDOKU_DOCUMENT_VERSION = 1 as const;
export type CellIndex = number; 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 = export type PortableConstraint =
| { readonly type: "diagonal"; readonly direction: "main" | "anti" } | { readonly type: "diagonal"; readonly direction: "main" | "anti" }
| { readonly type: "anti-knight" } | { readonly type: "anti-knight" }
| { readonly type: "anti-king" } | { readonly type: "anti-king" }
| { readonly type: "non-consecutive" } | { readonly type: "non-consecutive" }
| { | ({
readonly type: "killer-cage"; readonly type: "killer-cage";
readonly cells: readonly CellIndex[]; readonly cells: readonly CellIndex[];
readonly sum: number; readonly sum: number;
readonly noRepeat?: boolean; readonly noRepeat?: boolean;
} } & CluePolarity)
| { readonly type: "thermo"; readonly cells: readonly CellIndex[] } | ({
| { readonly type: "thermo";
readonly cells: readonly CellIndex[];
} & CluePolarity)
| ({
readonly type: "arrow"; readonly type: "arrow";
readonly bulb: readonly CellIndex[]; readonly bulb: readonly CellIndex[];
readonly line: readonly CellIndex[]; readonly line: readonly CellIndex[];
} } & CluePolarity)
| { | ({
readonly type: "kropki"; readonly type: "kropki";
readonly a: CellIndex; readonly a: CellIndex;
readonly b: CellIndex; readonly b: CellIndex;
readonly kind: "white" | "black"; readonly kind: "white" | "black";
} } & CluePolarity)
| { | ({
readonly type: "xv"; readonly type: "xv";
readonly a: CellIndex; readonly a: CellIndex;
readonly b: CellIndex; readonly b: CellIndex;
readonly total: 5 | 10; readonly total: 5 | 10;
} } & CluePolarity)
| { | ({
readonly type: "inequality"; readonly type: "inequality";
readonly lesser: CellIndex; readonly lesser: CellIndex;
readonly greater: CellIndex; readonly greater: CellIndex;
} } & CluePolarity)
| { readonly type: "renban"; readonly cells: readonly CellIndex[] } | ({
| { readonly type: "palindrome"; readonly cells: readonly CellIndex[] }; 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 * Stable, versioned interchange format owned by Sudoku Tools. Cell indices are
@@ -57,6 +96,8 @@ export interface SudokuDocument {
readonly candidates?: readonly (readonly number[])[]; readonly candidates?: readonly (readonly number[])[];
readonly colors?: readonly number[]; readonly colors?: readonly number[];
readonly elapsedMs?: number; readonly elapsedMs?: number;
/** Optional, non-constraining scratch cells used while solving. */
readonly aidMemoire?: PortableAidMemoire;
readonly solution?: readonly number[]; readonly solution?: readonly number[];
readonly regions?: readonly number[]; readonly regions?: readonly number[];
readonly constraints: readonly PortableConstraint[]; readonly constraints: readonly PortableConstraint[];
@@ -136,6 +177,12 @@ export function cloneConstraint(
case "renban": case "renban":
case "palindrome": case "palindrome":
return { ...constraint, cells: [...constraint.cells] }; return { ...constraint, cells: [...constraint.cells] };
case "quadruple":
return {
...constraint,
cells: [...constraint.cells],
digits: [...constraint.digits],
};
case "arrow": case "arrow":
return { return {
...constraint, ...constraint,
+669
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
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" });
}
+379
View File
@@ -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)}`;
}
+2
View File
@@ -1,3 +1,5 @@
export * from "./candidates";
export * from "./killer"; export * from "./killer";
export * from "./relations"; export * from "./relations";
export * from "./residual"; export * from "./residual";
export * from "./sumLab";
+373
View File
@@ -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 }),
};
}
+16
View File
@@ -111,6 +111,13 @@ export function solveExact(
"timeoutMs", "timeoutMs",
); );
const compiled = compilePuzzle(normalized); 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 = const random: RandomSource | undefined =
options.seed === undefined ? undefined : seededRandom(options.seed); options.seed === undefined ? undefined : seededRandom(options.seed);
const solutions: number[][] = []; const solutions: number[][] = [];
@@ -157,6 +164,15 @@ export function solveExact(
const chosen = selection[0]; const chosen = selection[0];
let choices = selection[1]; let choices = selection[1];
if (random !== undefined) choices = shuffled(choices, random); 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) { for (const value of choices) {
board[chosen] = value; board[chosen] = value;
search(); search();
+2 -1
View File
@@ -553,7 +553,8 @@ function findXyzWing(state: LogicalState): LogicalStep | undefined {
function findKillerReduction(state: LogicalState): LogicalStep | undefined { function findKillerReduction(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size; const size = state.compiled.puzzle.size;
for (const constraint of state.compiled.puzzle.constraints) { 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); const empty = constraint.cells.filter((cell) => state.values[cell] === 0);
if (empty.length === 0) continue; if (empty.length === 0) continue;
const assigned = constraint.cells const assigned = constraint.cells
+85 -1
View File
@@ -12,6 +12,7 @@ import {
type DifficultyOptions, type DifficultyOptions,
} from "./difficulty"; } from "./difficulty";
import { solveExact } from "./exact"; import { solveExact } from "./exact";
import type { LogicalTechnique } from "./logical";
import { import {
generateClassic, generateClassic,
minimizePuzzle, minimizePuzzle,
@@ -104,6 +105,27 @@ export type GeneratorVariant = (typeof GENERATOR_VARIANTS)[number]["id"];
export type GenerationDifficultyTarget = export type GenerationDifficultyTarget =
"beginner" | "easy" | "medium" | "hard" | "expert"; "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 { export interface GenerateVariantOptions {
readonly variant?: GeneratorVariant; readonly variant?: GeneratorVariant;
readonly size?: number; readonly size?: number;
@@ -120,6 +142,10 @@ export interface GenerateVariantOptions {
readonly solveMaxNodes?: number; readonly solveMaxNodes?: number;
readonly solveTimeoutMs?: number; readonly solveTimeoutMs?: number;
readonly difficulty?: DifficultyOptions; 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 { export interface GeneratedVariantPuzzle {
@@ -128,6 +154,8 @@ export interface GeneratedVariantPuzzle {
readonly variant: GeneratorVariant; readonly variant: GeneratorVariant;
readonly seed: string | number; readonly seed: string | number;
readonly generatedConstraintCount: number; readonly generatedConstraintCount: number;
readonly generationAttempts: number;
readonly requestedTechnique?: PracticeTechnique;
} }
const DEFAULT_CONSTRAINT_COUNTS: Readonly< const DEFAULT_CONSTRAINT_COUNTS: Readonly<
@@ -557,7 +585,7 @@ function fullSolution(
* Worker: every exact search has explicit node/time caps and worker termination * Worker: every exact search has explicit node/time caps and worker termination
* can cancel the complete operation. * can cancel the complete operation.
*/ */
export function generateVariant( function generateVariantAttempt(
options: GenerateVariantOptions = {}, options: GenerateVariantOptions = {},
): GeneratedVariantPuzzle { ): GeneratedVariantPuzzle {
const variant = options.variant ?? "classic"; const variant = options.variant ?? "classic";
@@ -676,5 +704,61 @@ export function generateVariant(
variant, variant,
seed, seed,
generatedConstraintCount: constraints.length, 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.`,
);
}
+404
View File
@@ -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,
})),
};
}
+54
View File
@@ -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;
}
}
+352
View File
@@ -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}`;
}
+26
View File
@@ -4,6 +4,10 @@ import {
cloneSudokuDocument, cloneSudokuDocument,
normalizeSudokuDocument, normalizeSudokuDocument,
} from "../formats"; } from "../formats";
import {
normalizePortableAidMemoire,
type PortableAidMemoire,
} from "../state/aidMemoire";
import { import {
PROJECT_RECORD_SCHEMA, PROJECT_RECORD_SCHEMA,
PROJECT_RECORD_VERSION, PROJECT_RECORD_VERSION,
@@ -126,6 +130,19 @@ function progress(value: unknown, size: number): SudokuProgress | undefined {
if (value.completed !== undefined && typeof value.completed !== "boolean") { if (value.completed !== undefined && typeof value.completed !== "boolean") {
return storageError("INVALID_PROJECT", "completed must be true or false."); 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 { return {
version: 1, version: 1,
values, values,
@@ -137,6 +154,7 @@ function progress(value: unknown, size: number): SudokuProgress | undefined {
? {} ? {}
: { elapsedMs: value.elapsedMs as number }), : { elapsedMs: value.elapsedMs as number }),
...(value.completed === undefined ? {} : { completed: value.completed }), ...(value.completed === undefined ? {} : { completed: value.completed }),
...(aidMemoire === undefined ? {} : { aidMemoire }),
}; };
} }
@@ -274,6 +292,14 @@ export function cloneProjectRecord(
...entry, ...entry,
]), ]),
}), }),
...(normalized.progress.aidMemoire === undefined
? {}
: {
aidMemoire: normalizePortableAidMemoire(
normalized.progress.aidMemoire,
normalized.puzzle.size,
),
}),
}, },
}), }),
}; };
+2 -1
View File
@@ -1,4 +1,4 @@
import type { SudokuDocument } from "../formats"; import type { PortableAidMemoire, SudokuDocument } from "../formats";
export const PROJECT_RECORD_SCHEMA = export const PROJECT_RECORD_SCHEMA =
"de.add-ideas.sudoku-tools.project" as const; "de.add-ideas.sudoku-tools.project" as const;
@@ -14,6 +14,7 @@ export interface SudokuProgress {
readonly candidates?: readonly (readonly number[])[]; readonly candidates?: readonly (readonly number[])[];
readonly elapsedMs?: number; readonly elapsedMs?: number;
readonly completed?: boolean; readonly completed?: boolean;
readonly aidMemoire?: PortableAidMemoire;
} }
export interface SudokuProjectRecord { export interface SudokuProjectRecord {
+858 -12
View File
@@ -644,6 +644,17 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
filter: saturate(0.6); 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 { .digit-completion {
width: min(100%, 43rem); width: min(100%, 43rem);
display: grid; 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); 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 { .side-panel {
padding: clamp(0.85rem, 1.7vw, 1.2rem); padding: clamp(0.85rem, 1.7vw, 1.2rem);
} }
@@ -896,6 +1106,162 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
display: flex; 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 { .generator-form {
background: color-mix( background: color-mix(
in srgb, in srgb,
@@ -1011,11 +1377,16 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
-webkit-user-select: none; -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 { .sudoku-board {
position: relative; position: relative;
width: 100%; width: 100%;
display: grid; 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)); grid-template-rows: repeat(var(--sudoku-size), minmax(0, 1fr));
aspect-ratio: 1; aspect-ratio: 1;
overflow: hidden; overflow: hidden;
@@ -1026,6 +1397,12 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
touch-action: none; touch-action: none;
} }
.sudoku-row {
min-height: 0;
display: grid;
grid-template-columns: repeat(var(--sudoku-size), minmax(0, 1fr));
}
.sudoku-cell { .sudoku-cell {
--cell-fill: var(--toolbox-surface); --cell-fill: var(--toolbox-surface);
position: relative; position: relative;
@@ -1067,6 +1444,14 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
) !important; ) !important;
} }
.sudoku-cell.is-candidate-highlighted {
background: color-mix(
in srgb,
var(--toolbox-focus) 18%,
var(--cell-fill)
) !important;
}
.sudoku-cell.is-selected { .sudoku-cell.is-selected {
background: color-mix( background: color-mix(
in srgb, in srgb,
@@ -1189,7 +1574,51 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
shape-rendering: geometricPrecision; 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; 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); 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 and colour entry */
.number-pad { .number-pad {
@@ -1454,8 +1979,32 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
overflow-wrap: anywhere; 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 { .subtabs {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
} }
.helper-controls { .helper-controls {
@@ -1545,6 +2094,42 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
font-weight: 720; 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 { .residual-equation {
color: var(--toolbox-muted); color: var(--toolbox-muted);
font-family: font-family:
@@ -1558,6 +2143,226 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
color: var(--toolbox-accent); 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, .analysis-summary,
.logical-results { .logical-results {
display: grid; display: grid;
@@ -1999,6 +2804,10 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
.helper-controls { .helper-controls {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.candidate-lab__controls {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (max-width: 48rem) { @media (max-width: 48rem) {
@@ -2039,7 +2848,24 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
.subtabs, .subtabs,
.import-export-grid, .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); grid-template-columns: minmax(0, 1fr);
} }
@@ -2054,7 +2880,8 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
} }
.field-grid, .field-grid,
.helper-controls { .helper-controls,
.candidate-lab__controls {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
@@ -2138,6 +2965,22 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
border-radius: 0.55rem; 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 { .mode-switcher {
grid-template-columns: repeat(2, minmax(0, 1fr)); 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), .constraint-layer :where(path, polyline, line, circle),
.candidate-link-layer :where(line, circle),
.region-boundaries path { .region-boundaries path {
stroke: CanvasText; stroke: CanvasText;
} }
@@ -2238,6 +3082,10 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
display: none !important; display: none !important;
} }
.candidate-link-layer {
display: none !important;
}
.toolbox-shell__main, .toolbox-shell__main,
.sudoku-workbench, .sudoku-workbench,
.workbench-grid, .workbench-grid,
@@ -2291,6 +3139,11 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
print-color-adjust: exact; print-color-adjust: exact;
} }
.sudoku-board-frame.has-outside-clues {
--board-pad: 0.85cm;
width: min(100%, 19.7cm);
}
.sudoku-cell, .sudoku-cell,
.sudoku-cell.is-selected, .sudoku-cell.is-selected,
.sudoku-cell.has-conflict, .sudoku-cell.has-conflict,
@@ -2305,13 +3158,6 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
display: none !important; display: none !important;
} }
.constraint-layer {
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.board-meta, .board-meta,
.rule-card { .rule-card {
width: min(100%, 18cm); width: min(100%, 18cm);
+111
View File
@@ -116,6 +116,12 @@ test("replaces setter cages and generates a rated variant", async ({
test("uses distinct numbered sum badges and directional inequalities", async ({ test("uses distinct numbered sum badges and directional inequalities", async ({
page, 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/"); await page.goto("/deep/nested/sudoku/");
const examples = page.getByLabel("Open built-in puzzle"); const examples = page.getByLabel("Open built-in puzzle");
@@ -136,4 +142,109 @@ test("uses distinct numbered sum badges and directional inequalities", async ({
await expect( await expect(
page.locator(".constraint-inequality-tip").first(), page.locator(".constraint-inequality-tip").first(),
).toBeVisible(); ).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([]);
}); });
+148
View File
@@ -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();
});
});
+100
View File
@@ -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(); 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();
});
}); });
+117
View File
@@ -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();
});
});
+44
View File
@@ -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();
});
});
+175 -1
View File
@@ -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 { describe, expect, it, vi } from "vitest";
import { normalizePuzzle } from "../../src/domain"; import { normalizePuzzle } from "../../src/domain";
import { SudokuBoard } from "../../src/components/SudokuBoard"; import { SudokuBoard } from "../../src/components/SudokuBoard";
@@ -41,4 +41,178 @@ describe("Sudoku board constraint visuals", () => {
container.querySelector(".constraint-inequality-tip"), container.querySelector(".constraint-inequality-tip"),
).toBeInTheDocument(); ).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");
});
}); });
+144
View File
@@ -63,6 +63,34 @@ describe("Sudoku workbench", () => {
expect(undo).toBeDisabled(); 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 () => { it("shows digit progress and toggles matching-digit highlights separately from selection", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<Workbench />); render(<Workbench />);
@@ -124,6 +152,30 @@ describe("Sudoku workbench", () => {
screen.getByRole("button", { name: "Remove selected cage" }), screen.getByRole("button", { name: "Remove selected cage" }),
); );
expect(screen.queryByText("4 cage · 2 cells")).not.toBeInTheDocument(); 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 () => { it("opens the expanded built-in variant examples", async () => {
@@ -157,6 +209,66 @@ describe("Sudoku workbench", () => {
expect( expect(
container.querySelector(".constraint-xv--10 text"), container.querySelector(".constraint-xv--10 text"),
).toHaveTextContent("10"); ).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 () => { it("switches between setting, solving, playing and helper workspaces", async () => {
@@ -206,6 +318,38 @@ describe("Sudoku workbench", () => {
).toBeInTheDocument(); ).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 () => { it("makes the import boundary and self-contained local sharing explicit", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const fetchSpy = vi.fn(); const fetchSpy = vi.fn();
+14 -5
View File
@@ -11,11 +11,16 @@ import {
} from "../../src/solver"; } from "../../src/solver";
describe("bundled original samples", () => { describe("bundled original samples", () => {
it.each(SAMPLE_PUZZLES)("ships $title as a unique puzzle", (puzzle) => { const fastUniquenessSamples = SAMPLE_CATALOG.map(({ puzzle }) => puzzle);
const result = solveExact(puzzle, { maxSolutions: 2 });
expect(result.count).toBe(1); it.each(fastUniquenessSamples)(
expect(result.truncated).toBe(false); "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", () => { it("solves the generated classic with the supported logical techniques", () => {
expect(solveLogically(CLASSIC_SAMPLE).status).toBe("solved"); expect(solveLogically(CLASSIC_SAMPLE).status).toBe("solved");
@@ -50,6 +55,10 @@ describe("bundled original samples", () => {
"inequality", "inequality",
"renban", "renban",
"palindrome", "palindrome",
"x-sum",
"skyscraper",
"quadruple",
"maximum",
]), ]),
); );
}); });
+505
View File
@@ -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.");
});
});
+75
View File
@@ -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);
});
});
+30 -1
View File
@@ -35,7 +35,30 @@ describe("Sudoku Tools document format", () => {
centerMarks: Array.from({ length: 81 }, () => [7, 4]), centerMarks: Array.from({ length: 81 }, () => [7, 4]),
colors: Array.from({ length: 81 }, (_, index) => index % 9), colors: Array.from({ length: 81 }, (_, index) => index % 9),
elapsedMs: 12_345, 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)); const parsed = parseSudokuDocument(serializeSudokuDocument(source));
expect(parsed).toEqual({ expect(parsed).toEqual({
@@ -59,6 +82,12 @@ describe("Sudoku Tools document format", () => {
values: [2, ...Array<number>(15).fill(0)], values: [2, ...Array<number>(15).fill(0)],
}), }),
).toThrow(/preserve its given digit/u); ).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", () => { it("round-trips compact share hashes", () => {
+95
View File
@@ -52,6 +52,12 @@ describe("fpuzzles interoperability", () => {
inequality: [{ cells: ["R6C1", "R6C2"], value: ">" }], inequality: [{ cells: ["R6C1", "R6C2"], value: ">" }],
renban: [{ lines: [["R7C1", "R7C2"]] }], renban: [{ lines: [["R7C1", "R7C2"]] }],
palindrome: [{ lines: [["R8C1", "R8C2"]] }], 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.givens[0]).toBe(5);
expect(puzzle.values?.[1]).toBe(3); expect(puzzle.values?.[1]).toBe(3);
@@ -64,6 +70,10 @@ describe("fpuzzles interoperability", () => {
{ type: "kropki", a: 27, b: 28, kind: "white" }, { type: "kropki", a: 27, b: 28, kind: "white" },
{ type: "kropki", a: 28, b: 29, kind: "black" }, { type: "kropki", a: 28, b: 29, kind: "black" },
{ type: "inequality", lesser: 46, greater: 45 }, { 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(); expect(() => normalizePuzzle(toDomainPuzzle(puzzle))).not.toThrow();
@@ -85,6 +95,10 @@ describe("fpuzzles interoperability", () => {
{ type: "inequality", lesser: 45, greater: 46 }, { type: "inequality", lesser: 45, greater: 46 },
{ type: "renban", cells: [54, 55] }, { type: "renban", cells: [54, 55] },
{ type: "palindrome", cells: [63, 64] }, { 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", title: "Round trip",
}; };
@@ -110,6 +124,53 @@ describe("fpuzzles interoperability", () => {
).toThrow(/local-only app cannot fetch/u); ).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", () => { it("rejects unsupported generalized dots instead of weakening them", () => {
expect(() => expect(() =>
parseFpuzzles({ parseFpuzzles({
@@ -120,6 +181,40 @@ describe("fpuzzles interoperability", () => {
).toThrow(/Difference-2 dots are not supported/u); ).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", () => { it("rejects unsupported or unknown rules instead of silently weakening them", () => {
expect(() => expect(() =>
parseFpuzzles({ parseFpuzzles({
+160
View File
@@ -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 });
});
});
+175
View File
@@ -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();
});
});
+117
View File
@@ -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 &lt; B &amp; &quot;C&quot;");
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);
});
});
+97
View File
@@ -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]);
});
});
+146
View File
@@ -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 },
);
});
});
+22
View File
@@ -38,6 +38,28 @@ describe("logical solver", () => {
expect(result.steps).toHaveLength(1); 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", () => { it("returns bounded killer combinations", () => {
expect( expect(
killerDigitCombinations({ size: 9, count: 2, sum: 10 }).combinations, killerDigitCombinations({ size: 9, count: 2, sum: 10 }).combinations,
+29
View File
@@ -196,6 +196,35 @@ describe("variant generator", () => {
expect(generated.puzzle.rules).not.toMatch(/Kropki|negative/i); 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", () => { it("keeps the public variant ID type exhaustive", () => {
const ids: GeneratorVariant[] = GENERATOR_VARIANTS.map(({ id }) => id); const ids: GeneratorVariant[] = GENERATOR_VARIANTS.map(({ id }) => id);
expect(new Set(ids).size).toBe(GENERATOR_VARIANTS.length); expect(new Set(ids).size).toBe(GENERATOR_VARIANTS.length);
+149
View File
@@ -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",
);
});
});
+27
View File
@@ -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);
});
});
+176
View File
@@ -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",
);
});
});
+72
View File
@@ -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);
});
});