feat: launch local-first Sudoku workbench

This commit is contained in:
2026-08-30 14:14:11 +02:00
commit 659640b231
97 changed files with 19111 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
import { lazy, Suspense, useState } from "react";
import { AppShell } from "@add-ideas/toolbox-shell-react";
import "@add-ideas/toolbox-shell-react/styles.css";
import "./styles.css";
import { AppErrorBoundary } from "./components/AppErrorBoundary";
import { HelpDialog } from "./components/HelpDialog";
import { manifest } from "./toolbox/manifest";
const Workbench = lazy(async () => ({
default: (await import("./components/Workbench")).Workbench,
}));
export function App() {
const [helpOpen, setHelpOpen] = useState(false);
return (
<AppErrorBoundary>
<AppShell
app={manifest}
manifestUrl="./toolbox-app.json"
helpAction={{ onClick: () => setHelpOpen(true) }}
onContextError={(error) =>
console.warn(
"Toolbox context unavailable; continuing standalone.",
error,
)
}
>
<Suspense
fallback={
<p className="workbench-loading" role="status">
Preparing the local Sudoku workbench
</p>
}
>
<Workbench />
</Suspense>
</AppShell>
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
</AppErrorBoundary>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
export class AppErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error(
"Sudoku Tools encountered an unrecoverable interface error",
error,
info,
);
}
render(): ReactNode {
if (!this.state.error) return this.props.children;
return (
<main className="fatal-error" role="alert">
<h1>Sudoku Tools could not continue</h1>
<p>{this.state.error.message}</p>
<button
type="button"
className="primary-button"
onClick={() => globalThis.location.reload()}
>
Reload application
</button>
</main>
);
}
}
+387
View File
@@ -0,0 +1,387 @@
import { useState } from "react";
import type { PuzzleDefinition, VariantConstraint } from "../domain/types";
interface ConstraintEditorProps {
puzzle: PuzzleDefinition;
selection: readonly number[];
onChange: (puzzle: PuzzleDefinition) => void;
onNewGrid: (size: number) => void;
onCheck: () => void;
onGenerate: () => void;
busy: boolean;
}
function describeConstraint(constraint: VariantConstraint, size: number) {
const cell = (index: number) =>
`r${String(Math.floor(index / size) + 1)}c${String((index % size) + 1)}`;
switch (constraint.type) {
case "diagonal":
return `${constraint.direction} diagonal`;
case "anti-knight":
return "anti-knight";
case "anti-king":
return "anti-king";
case "non-consecutive":
return "non-consecutive";
case "killer-cage":
return `${String(constraint.sum)} cage · ${String(constraint.cells.length)} cells`;
case "thermo":
case "renban":
case "palindrome":
return `${constraint.type} · ${String(constraint.cells.length)} cells`;
case "arrow":
return `arrow · ${String(constraint.bulb.length)} bulb / ${String(constraint.line.length)} line`;
case "kropki":
return `${constraint.kind} dot · ${cell(constraint.a)}${cell(constraint.b)}`;
case "xv":
return `${constraint.total === 5 ? "V" : "X"} · ${cell(constraint.a)}${cell(constraint.b)}`;
case "inequality":
return `${cell(constraint.lesser)} < ${cell(constraint.greater)}`;
}
}
export function ConstraintEditor({
puzzle,
selection,
onChange,
onNewGrid,
onCheck,
onGenerate,
busy,
}: ConstraintEditorProps) {
const [cageSum, setCageSum] = useState(10);
const [region, setRegion] = useState(1);
const constraints = puzzle.constraints ?? [];
const append = (constraint: VariantConstraint) =>
onChange({ ...puzzle, constraints: [...constraints, constraint] });
const need = (count: number) => selection.length === count;
const atLeast = (count: number) => selection.length >= count;
const toggleGlobal = (
type: "anti-knight" | "anti-king" | "non-consecutive",
) => {
const exists = constraints.some((constraint) => constraint.type === type);
onChange({
...puzzle,
constraints: exists
? constraints.filter((constraint) => constraint.type !== type)
: [...constraints, { type }],
});
};
return (
<div className="setter-panel stack">
<section className="panel-section">
<div className="section-heading">
<div>
<p className="eyebrow">Puzzle definition</p>
<h2>Set a puzzle</h2>
</div>
<label className="compact-field">
Grid
<select
value={puzzle.size}
onChange={(event) => onNewGrid(Number(event.target.value))}
>
<option value="4">4 × 4</option>
<option value="6">6 × 6</option>
<option value="9">9 × 9</option>
<option value="12">12 × 12</option>
<option value="16">16 × 16</option>
</select>
</label>
</div>
<div className="field-grid">
<label>
Title
<input
value={puzzle.title ?? ""}
maxLength={256}
onChange={(event) =>
onChange({ ...puzzle, title: event.target.value })
}
/>
</label>
<label>
Setter
<input
value={puzzle.author ?? ""}
maxLength={256}
onChange={(event) =>
onChange({ ...puzzle, author: event.target.value })
}
/>
</label>
</div>
<label>
Rules
<textarea
rows={3}
maxLength={16_384}
value={puzzle.rules ?? ""}
onChange={(event) =>
onChange({ ...puzzle, rules: event.target.value })
}
/>
</label>
</section>
<section className="panel-section">
<p className="eyebrow">Selected cells</p>
<h3>
{selection.length
? `${String(selection.length)} selected`
: "Select cells on the grid"}
</h3>
<p className="muted">
Selection order defines lines and pair direction. Shift-click or drag
to build a selection.
</p>
<div className="inline-fields">
<label className="compact-field">
Cage sum
<input
type="number"
min="1"
max={puzzle.size * puzzle.size}
value={cageSum}
onChange={(event) => setCageSum(Number(event.target.value))}
/>
</label>
<button
type="button"
disabled={!atLeast(1) || !Number.isInteger(cageSum)}
onClick={() =>
append({ type: "killer-cage", cells: selection, sum: cageSum })
}
>
Add cage
</button>
</div>
<div className="button-grid">
<button
type="button"
disabled={!atLeast(2)}
onClick={() => append({ type: "thermo", cells: selection })}
>
Thermo
</button>
<button
type="button"
disabled={!atLeast(2)}
onClick={() => append({ type: "renban", cells: selection })}
>
Renban
</button>
<button
type="button"
disabled={!atLeast(2)}
onClick={() => append({ type: "palindrome", cells: selection })}
>
Palindrome
</button>
<button
type="button"
disabled={!atLeast(2)}
onClick={() =>
append({
type: "arrow",
bulb: [selection[0]!],
line: selection.slice(1),
})
}
>
Arrow
</button>
<button
type="button"
disabled={!need(2)}
onClick={() =>
append({
type: "kropki",
a: selection[0]!,
b: selection[1]!,
kind: "white",
})
}
>
White dot
</button>
<button
type="button"
disabled={!need(2)}
onClick={() =>
append({
type: "kropki",
a: selection[0]!,
b: selection[1]!,
kind: "black",
})
}
>
Black dot
</button>
<button
type="button"
disabled={!need(2)}
onClick={() =>
append({
type: "xv",
a: selection[0]!,
b: selection[1]!,
total: 5,
})
}
>
V pair
</button>
<button
type="button"
disabled={!need(2)}
onClick={() =>
append({
type: "xv",
a: selection[0]!,
b: selection[1]!,
total: 10,
})
}
>
X pair
</button>
<button
type="button"
disabled={!need(2)}
onClick={() =>
append({
type: "inequality",
lesser: selection[0]!,
greater: selection[1]!,
})
}
>
First &lt; second
</button>
</div>
<div className="inline-fields">
<label className="compact-field">
Region
<input
type="number"
min="1"
max={puzzle.size}
value={region}
onChange={(event) => setRegion(Number(event.target.value))}
/>
</label>
<button
type="button"
disabled={!atLeast(1) || region < 1 || region > puzzle.size}
onClick={() => {
const regions = [...(puzzle.regions ?? [])];
for (const cell of selection) regions[cell] = region - 1;
onChange({ ...puzzle, regions });
}}
>
Paint region
</button>
</div>
</section>
<section className="panel-section">
<p className="eyebrow">Global rules</p>
<div className="button-grid">
{(["anti-knight", "anti-king", "non-consecutive"] as const).map(
(type) => (
<button
key={type}
type="button"
className={
constraints.some((constraint) => constraint.type === type)
? "is-active"
: ""
}
aria-pressed={constraints.some(
(constraint) => constraint.type === type,
)}
onClick={() => toggleGlobal(type)}
>
{type}
</button>
),
)}
{(["main", "anti"] as const).map((direction) => {
const active = constraints.some(
(constraint) =>
constraint.type === "diagonal" &&
constraint.direction === direction,
);
return (
<button
key={direction}
type="button"
className={active ? "is-active" : ""}
aria-pressed={active}
onClick={() =>
onChange({
...puzzle,
constraints: active
? constraints.filter(
(constraint) =>
constraint.type !== "diagonal" ||
constraint.direction !== direction,
)
: [...constraints, { type: "diagonal", direction }],
})
}
>
{direction} diagonal
</button>
);
})}
</div>
</section>
{constraints.length > 0 && (
<section className="panel-section">
<p className="eyebrow">Constraints</p>
<ul className="constraint-list">
{constraints.map((constraint, index) => (
<li key={`${constraint.type}-${String(index)}`}>
<span>{describeConstraint(constraint, puzzle.size)}</span>
<button
className="text-button danger"
type="button"
onClick={() =>
onChange({
...puzzle,
constraints: constraints.filter(
(_, item) => item !== index,
),
})
}
>
Remove
</button>
</li>
))}
</ul>
</section>
)}
<section className="panel-section action-row">
<button type="button" onClick={onCheck} disabled={busy}>
Check definition &amp; uniqueness
</button>
<button
type="button"
onClick={onGenerate}
disabled={busy || puzzle.size > 9}
>
Generate classic
</button>
</section>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { Modal } from "./Modal";
export function HelpDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
return (
<Modal open={open} title="Sudoku Tools help" onClose={onClose} wide>
<div className="help-grid">
<section>
<h3>Four complementary workspaces</h3>
<p>
<strong>Play</strong> keeps values, two kinds of notes, colours,
history and elapsed time. <strong>Set</strong> edits clues and
constraints. <strong>Solve</strong> explains logical steps and can
verify uniqueness. <strong>Helpers</strong> answers focused
questions without changing the board.
</p>
</section>
<section>
<h3>Keyboard</h3>
<dl className="shortcut-list">
<div>
<dt>Arrow keys</dt>
<dd>Move the active cell</dd>
</div>
<div>
<dt>Shift + arrows</dt>
<dd>Extend the selection</dd>
</div>
<div>
<dt>19 / AG</dt>
<dd>Enter the selected symbol</dd>
</div>
<div>
<dt>Z / X / C / V</dt>
<dd>Value, corner, centre or colour mode</dd>
</div>
<div>
<dt>Backspace</dt>
<dd>Erase in the current mode</dd>
</div>
<div>
<dt>Ctrl/ + Z/Y</dt>
<dd>Undo or redo</dd>
</div>
</dl>
</section>
<section>
<h3>Hints and solutions</h3>
<p>
Candidate legality is computed independently from handwritten notes.
Logical deductions report their premises, affected houses,
placements and eliminations. Exact search is separately labelled; it
proves feasibility or uniqueness but is not presented as a human
explanation.
</p>
</section>
<section>
<h3>Import and privacy</h3>
<p>
Files, text, solving and generation stay in this browser. Compact
grids, project JSON, share fragments and supported f-puzzles data
are decoded locally. SudokuPad short IDs need its server and are
intentionally rejected. Review an export before sharing: titles,
authors, rules, solutions and progress may be included.
</p>
</section>
</div>
</Modal>
);
}
+407
View File
@@ -0,0 +1,407 @@
import { useMemo, useState } from "react";
import {
analyzeKillerCage,
calculateResidual,
relationPairs,
type RelationSpec,
} from "../helpers";
import { maskValues, symbolFor } from "../state/session";
function digits(text: string) {
return [
...new Set(
(text.match(/\d+|[A-P]/giu) ?? []).map((token) =>
/^\d+$/u.test(token)
? Number(token)
: token.toUpperCase().charCodeAt(0) - 55,
),
),
];
}
function numbers(text: string) {
return (text.match(/-?\d+(?:\.\d+)?/gu) ?? []).map(Number);
}
export function HelpersWorkspace({
size,
selectedCells,
candidateMasks,
}: {
size: number;
selectedCells: readonly number[];
candidateMasks: readonly number[];
}) {
const [helper, setHelper] = useState<"killer" | "residual" | "relations">(
"killer",
);
const [cellCount, setCellCount] = useState(2);
const [sum, setSum] = useState(10);
const [allowed, setAllowed] = useState("");
const [required, setRequired] = useState("");
const [excluded, setExcluded] = useState("");
const [allowRepeats, setAllowRepeats] = useState(false);
const [useBoardCandidates, setUseBoardCandidates] = useState(false);
const [knownParts, setKnownParts] = useState("15, 12");
const [unknownCount, setUnknownCount] = useState(2);
const [relation, setRelation] = useState("white");
const [firstCandidates, setFirstCandidates] = useState("");
const [secondCandidates, setSecondCandidates] = useState("");
const effectiveCount =
useBoardCandidates && selectedCells.length > 0
? selectedCells.length
: cellCount;
const killer = useMemo(() => {
try {
const candidates =
useBoardCandidates && selectedCells.length > 0
? selectedCells.map((cell) =>
maskValues(candidateMasks[cell] ?? 0, size),
)
: undefined;
return {
result: analyzeKillerCage({
size,
cellCount: effectiveCount,
sum,
allowRepeats,
...(allowed.trim() ? { allowedDigits: digits(allowed) } : {}),
...(required.trim() ? { requiredDigits: digits(required) } : {}),
...(excluded.trim() ? { excludedDigits: digits(excluded) } : {}),
...(candidates === undefined ? {} : { candidates }),
}),
};
} catch (error) {
return {
error: error instanceof Error ? error.message : "Invalid helper input.",
};
}
}, [
allowRepeats,
allowed,
candidateMasks,
effectiveCount,
excluded,
required,
selectedCells,
size,
sum,
useBoardCandidates,
]);
const residual = useMemo(() => {
try {
return {
result: calculateResidual({
size,
knownSums: numbers(knownParts),
unknownCount,
allowRepeats,
}),
};
} catch (error) {
return {
error:
error instanceof Error ? error.message : "Invalid residual input.",
};
}
}, [allowRepeats, knownParts, size, unknownCount]);
const pairs = useMemo(() => {
try {
const pairSpec: RelationSpec =
relation === "white"
? { type: "kropki", kind: "white" }
: relation === "black"
? { type: "kropki", kind: "black" }
: relation === "v"
? { type: "xv", total: 5 }
: relation === "x"
? { type: "xv", total: 10 }
: relation === "less"
? { type: "inequality", relation: "<" }
: { type: "inequality", relation: ">" };
return {
result: relationPairs(
pairSpec,
size,
firstCandidates.trim() ? digits(firstCandidates) : undefined,
secondCandidates.trim() ? digits(secondCandidates) : undefined,
),
};
} catch (error) {
return {
error: error instanceof Error ? error.message : "Invalid pair input.",
};
}
}, [firstCandidates, relation, secondCandidates, size]);
return (
<div className="helpers-workspace stack">
<div>
<p className="eyebrow">Focused calculators</p>
<h2>Sudoku helpers</h2>
<p className="muted">
Answer a local question without changing the puzzle. Values are
bounded to the current {size}×{size} symbol set.
</p>
</div>
<div className="subtabs" role="tablist" aria-label="Helper">
{(
[
["killer", "Killer combinations"],
["residual", `${String((size * (size + 1)) / 2)}-rule residual`],
["relations", "Pair relations"],
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
role="tab"
aria-selected={helper === id}
className={helper === id ? "is-active" : ""}
onClick={() => setHelper(id)}
>
{label}
</button>
))}
</div>
{helper === "killer" && (
<section className="helper-card">
<div className="helper-controls">
<label>
Cells
<input
type="number"
min="1"
max={size}
value={cellCount}
disabled={useBoardCandidates && selectedCells.length > 0}
onChange={(event) => setCellCount(Number(event.target.value))}
/>
</label>
<label>
Sum
<input
type="number"
min="1"
max={size * size}
value={sum}
onChange={(event) => setSum(Number(event.target.value))}
/>
</label>
<label>
Allowed digits
<input
value={allowed}
placeholder="all"
onChange={(event) => setAllowed(event.target.value)}
/>
</label>
<label>
Must include
<input
value={required}
placeholder="e.g. 1, 7"
onChange={(event) => setRequired(event.target.value)}
/>
</label>
<label>
Exclude
<input
value={excluded}
placeholder="e.g. 5"
onChange={(event) => setExcluded(event.target.value)}
/>
</label>
<label className="check-row">
<input
type="checkbox"
checked={allowRepeats}
onChange={(event) => setAllowRepeats(event.target.checked)}
/>
Allow repeated digits
</label>
<label className="check-row">
<input
type="checkbox"
checked={useBoardCandidates}
disabled={selectedCells.length === 0}
onChange={(event) =>
setUseBoardCandidates(event.target.checked)
}
/>
Use {selectedCells.length || "selected"} board cell
{selectedCells.length === 1 ? "" : "s"} and candidates
</label>
</div>
{killer.error ? (
<p className="error-callout" role="alert">
{killer.error}
</p>
) : killer.result ? (
<div className="helper-result">
<div className="metric-row">
<span>
<strong>{killer.result.combinations.length}</strong>{" "}
combinations
</span>
<span>
Possible{" "}
<strong>
{killer.result.possibleDigits
.map((value) => symbolFor(value, size))
.join(" ") || "none"}
</strong>
</span>
<span>
Necessary{" "}
<strong>
{killer.result.necessaryDigits
.map((value) => symbolFor(value, size))
.join(" ") || "none"}
</strong>
</span>
</div>
{killer.result.possibleByCell.some(
(entry) => entry.length > 0,
) && (
<ol className="cell-possibilities">
{killer.result.possibleByCell.map((entry, index) => (
<li key={index}>
Cell {index + 1}:{" "}
{entry.map((value) => symbolFor(value, size)).join(" ") ||
"—"}
</li>
))}
</ol>
)}
<div className="combination-cloud" aria-label="Combinations">
{killer.result.combinations.slice(0, 300).map((combination) => (
<code key={combination.join("-")}>
{combination
.map((value) => symbolFor(value, size))
.join("")}
</code>
))}
</div>
{(killer.result.combinations.length > 300 ||
killer.result.truncated) && (
<p className="muted">
The visible list is bounded; refine the filters to inspect
fewer results.
</p>
)}
</div>
) : null}
</section>
)}
{helper === "residual" && (
<section className="helper-card">
<div className="helper-controls">
<label>
Accounted values or cage sums
<input
value={knownParts}
onChange={(event) => setKnownParts(event.target.value)}
/>
</label>
<label>
Unaccounted cells
<input
type="number"
min="0"
max={size}
value={unknownCount}
onChange={(event) =>
setUnknownCount(Number(event.target.value))
}
/>
</label>
</div>
{residual.error ? (
<p className="error-callout" role="alert">
{residual.error}
</p>
) : residual.result ? (
<div className="helper-result">
<p className="residual-equation">
{residual.result.total} {residual.result.accounted} ={" "}
<strong>{residual.result.residual}</strong>
</p>
<p>
{residual.result.analysis?.combinations.length ?? 0} possible
digit combinations for {unknownCount} residual cell
{unknownCount === 1 ? "" : "s"}.
</p>
<div className="combination-cloud">
{residual.result.analysis?.combinations
.slice(0, 300)
.map((combination) => (
<code key={combination.join("-")}>
{combination
.map((value) => symbolFor(value, size))
.join("")}
</code>
))}
</div>
</div>
) : null}
</section>
)}
{helper === "relations" && (
<section className="helper-card">
<div className="helper-controls">
<label>
Relation
<select
value={relation}
onChange={(event) => setRelation(event.target.value)}
>
<option value="white">White Kropki · difference 1</option>
<option value="black">Black Kropki · ratio 1:2</option>
<option value="v">V · sum 5</option>
<option value="x">X · sum 10</option>
<option value="less">First &lt; second</option>
<option value="greater">First &gt; second</option>
</select>
</label>
<label>
First-cell candidates
<input
value={firstCandidates}
placeholder="all"
onChange={(event) => setFirstCandidates(event.target.value)}
/>
</label>
<label>
Second-cell candidates
<input
value={secondCandidates}
placeholder="all"
onChange={(event) => setSecondCandidates(event.target.value)}
/>
</label>
</div>
{pairs.error ? (
<p className="error-callout" role="alert">
{pairs.error}
</p>
) : (
<div className="pair-table">
{pairs.result?.map(([first, second]) => (
<code key={`${String(first)}-${String(second)}`}>
{symbolFor(first, size)} {symbolFor(second, size)}
</code>
))}
</div>
)}
</section>
)}
</div>
);
}
+284
View File
@@ -0,0 +1,284 @@
import { useMemo, useRef, useState } from "react";
import type { PuzzleDefinition } from "../domain/types";
import {
decodePuzzleHash,
encodePuzzleHash,
exportFpuzzlesJson,
exportFpuzzlesUrl,
fromDomainPuzzle,
importFpuzzles,
parseFpuzzles,
parsePlainGrid,
parseSudokuDocument,
serializePlainGrid,
serializeSudokuDocument,
toDomainPuzzle,
type SudokuDocument,
} from "../formats";
import type { PlaySession } from "../state/session";
import { maskValues } from "../state/session";
import { Modal } from "./Modal";
function parseImport(input: string): SudokuDocument {
const trimmed = input.trim();
if (trimmed.startsWith("#sudoku=") || trimmed.includes("#sudoku="))
return decodePuzzleHash(trimmed);
if (
/^(?:https?:\/\/)?(?:www\.)?(?:f-puzzles\.com|sudokupad\.app)\//iu.test(
trimmed,
)
)
return importFpuzzles(trimmed);
if (trimmed.startsWith("fpuzzles")) return importFpuzzles(trimmed);
if (trimmed.startsWith("{")) {
const parsed = JSON.parse(trimmed) as unknown;
if (typeof parsed === "object" && parsed !== null && "schema" in parsed)
return parseSudokuDocument(trimmed);
return parseFpuzzles(parsed);
}
return parsePlainGrid(trimmed);
}
function withProgress(
puzzle: PuzzleDefinition,
session: PlaySession,
): SudokuDocument {
const base = fromDomainPuzzle(puzzle);
return {
...base,
values: [...session.values],
cornerMarks: session.cornerMarks.map((mask) =>
maskValues(mask, puzzle.size),
),
centerMarks: session.centerMarks.map((mask) =>
maskValues(mask, puzzle.size),
),
colors: [...session.colors],
elapsedMs: session.elapsedSeconds * 1_000,
};
}
function download(name: string, contents: string, type: string) {
const url = URL.createObjectURL(new Blob([contents], { type }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = name;
anchor.click();
URL.revokeObjectURL(url);
}
interface ImportExportDialogProps {
open: boolean;
puzzle: PuzzleDefinition;
session: PlaySession;
onClose: () => void;
onImport: (
puzzle: PuzzleDefinition,
progress?: Pick<
SudokuDocument,
| "values"
| "cornerMarks"
| "centerMarks"
| "candidates"
| "colors"
| "elapsedMs"
>,
) => void;
}
export function ImportExportDialog({
open,
puzzle,
session,
onClose,
onImport,
}: ImportExportDialogProps) {
const [input, setInput] = useState("");
const [feedback, setFeedback] = useState("");
const [includeProgress, setIncludeProgress] = useState(true);
const fileRef = useRef<HTMLInputElement>(null);
const documentValue = useMemo(
() =>
includeProgress
? withProgress(puzzle, session)
: fromDomainPuzzle(puzzle),
[includeProgress, puzzle, session],
);
const copy = async (value: string, label: string) => {
try {
await navigator.clipboard.writeText(value);
setFeedback(`${label} copied.`);
} catch {
setFeedback(
"Clipboard access was denied; use the download option instead.",
);
}
};
return (
<Modal open={open} title="Import and export" onClose={onClose} wide>
<div className="import-export-grid">
<section className="stack">
<div>
<p className="eyebrow">Import</p>
<h3>Paste puzzle data</h3>
</div>
<p className="muted">
Accepts a plain grid, Sudoku Tools JSON/share link, raw f-puzzles
JSON, or a self-contained f-puzzles/SudokuPad link. Server-only
short IDs are never fetched.
</p>
<textarea
rows={12}
value={input}
maxLength={1_048_576}
spellCheck={false}
placeholder="Paste 81 characters, JSON or a puzzle URL…"
onChange={(event) => setInput(event.target.value)}
/>
<div className="action-row">
<button type="button" onClick={() => fileRef.current?.click()}>
Choose puzzle file
</button>
<input
ref={fileRef}
className="sr-only"
type="file"
accept="application/json,text/plain,.json,.txt,.sdk"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file === undefined) return;
if (file.size > 1_048_576) {
setFeedback("Puzzle files are limited to 1 MiB.");
return;
}
void file
.text()
.then((contents) => {
setInput(contents);
setFeedback(
`${file.name} loaded locally; review and import it.`,
);
})
.catch(() =>
setFeedback("The puzzle file could not be read."),
);
}}
/>
<button
type="button"
disabled={!input.trim()}
onClick={() => {
try {
const parsed = parseImport(input);
onImport(toDomainPuzzle(parsed) as PuzzleDefinition, {
values: parsed.values,
cornerMarks: parsed.cornerMarks,
centerMarks: parsed.centerMarks,
candidates: parsed.candidates,
colors: parsed.colors,
elapsedMs: parsed.elapsedMs,
});
setFeedback("Puzzle imported locally.");
onClose();
} catch (error) {
setFeedback(
error instanceof Error
? error.message
: "The puzzle could not be imported.",
);
}
}}
>
Import locally
</button>
</div>
</section>
<section className="stack">
<div>
<p className="eyebrow">Export</p>
<h3>Choose a portable representation</h3>
</div>
<label className="check-row">
<input
type="checkbox"
checked={includeProgress}
onChange={(event) => setIncludeProgress(event.target.checked)}
/>
Include values, notes, colours and elapsed time
</label>
<div className="export-actions">
<button
type="button"
onClick={() =>
download(
"sudoku-tools-puzzle.json",
serializeSudokuDocument(documentValue, true),
"application/json",
)
}
>
Download project JSON
</button>
<button
type="button"
onClick={() =>
download(
"sudoku.txt",
serializePlainGrid(
documentValue,
includeProgress ? "values" : "givens",
),
"text/plain",
)
}
>
Download grid text
</button>
<button
type="button"
onClick={() =>
download(
"sudoku.fpuzzles.json",
exportFpuzzlesJson(documentValue, true),
"application/json",
)
}
>
Download f-puzzles JSON
</button>
<button
type="button"
onClick={() =>
void copy(exportFpuzzlesUrl(documentValue), "f-puzzles URL")
}
>
Copy f-puzzles URL
</button>
<button
type="button"
onClick={() => {
const share = `${location.href.split("#", 1)[0]}${encodePuzzleHash(documentValue)}`;
void copy(share, "Local share URL");
}}
>
Copy local share URL
</button>
</div>
<p className="callout">
Share links are self-contained. They may expose title, setter,
rules, solution progress and notes to anyone receiving the URL.
</p>
</section>
</div>
{feedback && (
<p className="status-line" role="status">
{feedback}
</p>
)}
</Modal>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { useRef } from "react";
import type { SudokuProjectSummary } from "../storage";
import { Modal } from "./Modal";
function date(value: number) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
export function LibraryDialog({
open,
summaries,
mode,
busy,
feedback,
onClose,
onSave,
onOpen,
onDelete,
onClear,
onExport,
onImport,
}: {
open: boolean;
summaries: readonly SudokuProjectSummary[];
mode: "indexeddb" | "memory";
busy: boolean;
feedback?: string;
onClose: () => void;
onSave: () => void;
onOpen: (id: string) => void;
onDelete: (id: string) => void;
onClear: () => void;
onExport: () => void;
onImport: (file: File) => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
return (
<Modal open={open} title="Local puzzle library" onClose={onClose} wide>
<div className="library-toolbar">
<div>
<p>
{mode === "indexeddb"
? "Saved in this browser profile."
: "IndexedDB is unavailable; saves last only for this open tab."}
</p>
<p className="muted">
Export important puzzles before clearing browser data.
</p>
</div>
<div className="action-row">
<button type="button" disabled={busy} onClick={onSave}>
Save current
</button>
<button
type="button"
disabled={busy || summaries.length === 0}
onClick={onExport}
>
Export library
</button>
<button
type="button"
disabled={busy}
onClick={() => fileRef.current?.click()}
>
Import library
</button>
<input
ref={fileRef}
className="sr-only"
type="file"
accept="application/json,.json"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) onImport(file);
event.target.value = "";
}}
/>
</div>
</div>
{feedback && (
<p className="status-line" role="status">
{feedback}
</p>
)}
{summaries.length === 0 ? (
<div className="empty-state">
<h3>No saved puzzles</h3>
<p>Save the current puzzle to build a local library.</p>
</div>
) : (
<ul className="library-list">
{summaries.map((item) => (
<li key={item.id}>
<button
className="library-open"
type="button"
onClick={() => onOpen(item.id)}
>
<strong>{item.title || "Untitled puzzle"}</strong>
<span>
{item.size}×{item.size} · {date(item.updatedAt)}
{item.completed ? " · complete" : ""}
</span>
</button>
<button
className="text-button danger"
type="button"
onClick={() => onDelete(item.id)}
>
Delete
</button>
</li>
))}
</ul>
)}
{summaries.length > 0 && (
<div className="danger-zone">
<button type="button" className="danger" onClick={onClear}>
Clear local library
</button>
</div>
)}
</Modal>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useId, useRef, type ReactNode } from "react";
interface ModalProps {
open: boolean;
title: string;
children: ReactNode;
onClose: () => void;
wide?: boolean;
}
export function Modal({ open, title, children, onClose, wide }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
const titleId = useId();
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
}, [open]);
return (
<dialog
ref={dialogRef}
className={`modal${wide ? " modal--wide" : ""}`}
aria-labelledby={titleId}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
onClick={(event) => {
if (event.target === dialogRef.current) onClose();
}}
>
<div className="modal__surface">
<header className="modal__header">
<h2 id={titleId}>{title}</h2>
<button className="icon-button" type="button" onClick={onClose}>
<span aria-hidden="true">×</span>
<span className="sr-only">Close</span>
</button>
</header>
<div className="modal__body">{children}</div>
</div>
</dialog>
);
}
+73
View File
@@ -0,0 +1,73 @@
import type { EntryMode } from "../state/session";
import { symbolFor } from "../state/session";
const modes: Array<{ 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 NumberPad({
size,
mode,
onMode,
onValue,
onErase,
}: {
size: number;
mode: EntryMode;
onMode: (mode: EntryMode) => void;
onValue: (value: number) => void;
onErase: () => void;
}) {
return (
<div className="number-pad">
<div className="mode-switcher" role="group" aria-label="Entry mode">
{modes.map((item) => (
<button
key={item.mode}
type="button"
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={`digit-pad${size > 9 ? " digit-pad--wide" : ""}`}
role="group"
aria-label={mode === "color" ? "Colours" : "Digits"}
>
{Array.from({ length: mode === "color" ? 8 : size }, (_, index) => {
const value = index + 1;
return (
<button
key={value}
type="button"
className={
mode === "color" ? `color-choice color-${String(value)}` : ""
}
onClick={() => onValue(value)}
>
{mode === "color" ? (
<>
<span aria-hidden="true" />{" "}
<span className="sr-only">Colour {value}</span>
</>
) : (
symbolFor(value, size)
)}
</button>
);
})}
<button type="button" className="erase-key" onClick={onErase}>
Erase
</button>
</div>
</div>
);
}
+207
View File
@@ -0,0 +1,207 @@
import { useEffect, useState } from "react";
import type {
ExactSolveResult,
LogicalSolveResult,
LogicalStep,
} from "../solver";
import { symbolFor } from "../state/session";
function cellName(cell: number, size: number) {
return `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
}
function techniqueName(value: string) {
return value
.split("-")
.map((part) => part[0]?.toUpperCase() + part.slice(1))
.join(" ");
}
function StepCard({
step,
index,
size,
active,
onSelect,
}: {
step: LogicalStep;
index: number;
size: number;
active: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
className={`solve-step${active ? " is-active" : ""}`}
onClick={onSelect}
>
<span className="step-number">{index + 1}</span>
<span>
<strong>{techniqueName(step.technique)}</strong>
<small>{step.explanation}</small>
{(step.placements.length > 0 || step.eliminations.length > 0) && (
<span className="step-effects">
{step.placements
.map(
(placement) =>
`${cellName(placement.cell, size)}=${symbolFor(placement.value, size)}`,
)
.join(", ")}
{step.placements.length > 0 && step.eliminations.length > 0
? " · "
: ""}
{step.eliminations
.map(
(elimination) =>
`${cellName(elimination.cell, size)} ${elimination.values.map((value) => symbolFor(value, size)).join("")}`,
)
.join(", ")}
</span>
)}
</span>
</button>
);
}
export function SolveWorkspace({
size,
busy,
logical,
exact,
error,
onLogical,
onExact,
onApplyValues,
onFocusCells,
}: {
size: number;
busy: boolean;
logical?: LogicalSolveResult;
exact?: ExactSolveResult;
error?: string;
onLogical: () => void;
onExact: () => void;
onApplyValues: (values: readonly number[]) => void;
onFocusCells: (cells: readonly number[]) => void;
}) {
const [stepSelection, setStepSelection] = useState<{
logical?: LogicalSolveResult;
index: number;
}>({ index: 0 });
const activeStep =
stepSelection.logical === logical ? stepSelection.index : 0;
useEffect(() => {
const step = logical?.steps[activeStep];
onFocusCells(step?.focusCells ?? []);
}, [activeStep, logical, onFocusCells]);
return (
<div className="solve-workspace stack">
<div>
<p className="eyebrow">Explainable analysis</p>
<h2>Solve and verify</h2>
<p className="muted">
Human logic and exact search are deliberately separate. A uniqueness
proof is not described as a human deduction.
</p>
</div>
<div className="action-row">
<button type="button" disabled={busy} onClick={onLogical}>
Build logical solve path
</button>
<button type="button" disabled={busy} onClick={onExact}>
Count solutions (up to 2)
</button>
</div>
{busy && (
<p className="status-line" role="status">
Analysing in a local worker
</p>
)}
{error && (
<p className="error-callout" role="alert">
{error}
</p>
)}
{exact && (
<section className="analysis-summary">
<p className="eyebrow">Exact search</p>
<div className="metric-row">
<span>
<strong>{exact.count}</strong> solution
{exact.count === 1 ? "" : "s"} found
</span>
<span>
<strong>{exact.nodes.toLocaleString()}</strong> nodes
</span>
<span>
<strong>{exact.elapsedMs.toLocaleString()}</strong> ms
</span>
</div>
<p>
{exact.count === 0
? "No completion satisfies every supported rule."
: exact.count === 1 && !exact.truncated
? "The current puzzle has exactly one solution within the configured search bounds."
: exact.count >= 2
? "At least two solutions exist; the puzzle is not unique."
: "Search stopped at a safety limit, so uniqueness is not established."}
</p>
{exact.solutions[0] && (
<button
type="button"
onClick={() => onApplyValues(exact.solutions[0]!)}
>
Show first exact solution on board
</button>
)}
</section>
)}
{logical && (
<section className="logical-results">
<div className="section-heading">
<div>
<p className="eyebrow">Logical path</p>
<h3>
{logical.steps.length} explained step
{logical.steps.length === 1 ? "" : "s"}
</h3>
</div>
<span className={`status-pill status-${logical.status}`}>
{logical.status}
</span>
</div>
<p>
{logical.status === "solved"
? "The configured human techniques complete this puzzle."
: logical.status === "stuck"
? "No supported next deduction was found. Exact search may still solve it."
: logical.status === "invalid"
? "A contradiction was reached."
: "The logical-step safety limit was reached."}
</p>
{logical.status === "solved" && (
<button type="button" onClick={() => onApplyValues(logical.values)}>
Show logical result on board
</button>
)}
<div className="solve-steps">
{logical.steps.map((step, index) => (
<StepCard
key={`${step.technique}-${String(index)}`}
step={step}
index={index}
size={size}
active={activeStep === index}
onSelect={() => setStepSelection({ logical, index })}
/>
))}
</div>
</section>
)}
</div>
);
}
+271
View File
@@ -0,0 +1,271 @@
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
import type { NormalizedPuzzle, VariantConstraint } from "../domain/types";
import { maskValues, symbolFor } from "../state/session";
interface SudokuBoardProps {
puzzle: NormalizedPuzzle;
values: readonly number[];
cornerMarks?: readonly number[];
centerMarks?: readonly number[];
colors?: readonly number[];
candidates?: readonly number[];
selected: ReadonlySet<number>;
conflicts?: ReadonlySet<number>;
activeCell: number;
showCandidates?: boolean;
onCellPointerDown: (
cell: number,
event: PointerEvent<HTMLButtonElement>,
) => void;
onCellPointerEnter: (
cell: number,
event: PointerEvent<HTMLButtonElement>,
) => void;
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
}
function point(size: number, cell: number) {
return {
x: (cell % size) + 0.5,
y: Math.floor(cell / size) + 0.5,
};
}
function polyline(size: number, cells: readonly number[]) {
return cells
.map((cell) => {
const { x, y } = point(size, cell);
return `${x},${y}`;
})
.join(" ");
}
function boundaryPath(size: number, cells: ReadonlySet<number>, inset: number) {
const commands: string[] = [];
for (const cell of cells) {
const row = Math.floor(cell / size);
const column = cell % size;
const x0 = column + inset;
const x1 = column + 1 - inset;
const y0 = row + inset;
const y1 = row + 1 - inset;
if (!cells.has(cell - size) || row === 0)
commands.push(`M${x0} ${y0}H${x1}`);
if (!cells.has(cell + size) || row === size - 1)
commands.push(`M${x0} ${y1}H${x1}`);
if (!cells.has(cell - 1) || column === 0)
commands.push(`M${x0} ${y0}V${y1}`);
if (!cells.has(cell + 1) || column === size - 1)
commands.push(`M${x1} ${y0}V${y1}`);
}
return commands.join(" ");
}
function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
const { size } = puzzle;
const regionCells = Array.from({ length: size }, () => new Set<number>());
puzzle.regions.forEach((region, cell) => regionCells[region]?.add(cell));
const renderConstraint = (constraint: VariantConstraint, index: number) => {
if (constraint.type === "diagonal") {
return (
<line
key={index}
className="constraint-diagonal"
x1={constraint.direction === "main" ? 0.12 : size - 0.12}
y1={0.12}
x2={constraint.direction === "main" ? size - 0.12 : 0.12}
y2={size - 0.12}
/>
);
}
if (constraint.type === "killer-cage") {
const cells = new Set(constraint.cells);
const first = Math.min(...constraint.cells);
const position = point(size, first);
return (
<g key={index} className="constraint-cage">
<path d={boundaryPath(size, cells, 0.09)} />
<text x={position.x - 0.34} y={position.y - 0.27}>
{constraint.sum}
</text>
</g>
);
}
if (constraint.type === "thermo") {
const bulb = point(size, constraint.cells[0]!);
return (
<g key={index} className="constraint-thermo">
<polyline points={polyline(size, constraint.cells)} />
<circle cx={bulb.x} cy={bulb.y} r="0.31" />
</g>
);
}
if (constraint.type === "arrow") {
const bulb = point(size, constraint.bulb[0]!);
const end = point(size, constraint.line.at(-1)!);
const connectedLine = [constraint.bulb.at(-1)!, ...constraint.line];
return (
<g key={index} className="constraint-arrow">
<circle cx={bulb.x} cy={bulb.y} r="0.32" />
<polyline points={polyline(size, connectedLine)} />
<circle cx={end.x} cy={end.y} r="0.08" />
</g>
);
}
if (constraint.type === "renban" || constraint.type === "palindrome") {
return (
<g key={index} className={`constraint-${constraint.type}`}>
<polyline points={polyline(size, constraint.cells)} />
{constraint.type === "palindrome" &&
constraint.cells.map((cell) => {
const p = point(size, cell);
return <circle key={cell} cx={p.x} cy={p.y} r="0.14" />;
})}
</g>
);
}
if (
constraint.type === "kropki" ||
constraint.type === "xv" ||
constraint.type === "inequality"
) {
const a = point(
size,
constraint.type === "inequality" ? constraint.lesser : constraint.a,
);
const b = point(
size,
constraint.type === "inequality" ? constraint.greater : constraint.b,
);
const x = (a.x + b.x) / 2;
const y = (a.y + b.y) / 2;
if (constraint.type === "kropki")
return (
<circle
key={index}
className={`constraint-kropki constraint-kropki--${constraint.kind}`}
cx={x}
cy={y}
r="0.115"
/>
);
const rotation = Math.atan2(b.y - a.y, b.x - a.x) * (180 / Math.PI);
return (
<text
key={index}
className={`constraint-label constraint-label--${constraint.type}`}
x={x}
y={y}
transform={
constraint.type === "inequality"
? `rotate(${String(rotation)} ${String(x)} ${String(y)})`
: undefined
}
>
{constraint.type === "xv"
? constraint.total === 5
? "V"
: "X"
: "<"}
</text>
);
}
return null;
};
return (
<svg
className="constraint-layer"
viewBox={`0 0 ${String(size)} ${String(size)}`}
aria-hidden="true"
>
<g className="region-boundaries">
{regionCells.map((cells, index) => (
<path key={index} d={boundaryPath(size, cells, 0)} />
))}
</g>
{puzzle.constraints.map(renderConstraint)}
</svg>
);
}
export function SudokuBoard({
puzzle,
values,
cornerMarks = [],
centerMarks = [],
colors = [],
candidates = [],
selected,
conflicts = new Set<number>(),
activeCell,
showCandidates,
onCellPointerDown,
onCellPointerEnter,
onKeyDown,
}: SudokuBoardProps) {
const { size } = puzzle;
return (
<div
className="sudoku-board-frame"
style={{ "--sudoku-size": size } as CSSProperties}
>
<div
className="sudoku-board"
role="grid"
aria-label={`${String(size)} by ${String(size)} Sudoku grid`}
onKeyDown={onKeyDown}
>
{values.map((value, cell) => {
const isGiven = Boolean(puzzle.givens[cell]);
const corner = maskValues(cornerMarks[cell] ?? 0, size);
const center = maskValues(
centerMarks[cell] || (showCandidates ? (candidates[cell] ?? 0) : 0),
size,
);
const row = Math.floor(cell / size) + 1;
const column = (cell % size) + 1;
return (
<button
key={cell}
type="button"
role="gridcell"
aria-label={`Row ${String(row)}, column ${String(column)}${value ? `, ${symbolFor(value, size)}` : ", empty"}`}
aria-selected={selected.has(cell)}
tabIndex={cell === activeCell ? 0 : -1}
className={[
"sudoku-cell",
isGiven ? "is-given" : "",
selected.has(cell) ? "is-selected" : "",
conflicts.has(cell) ? "has-conflict" : "",
colors[cell] ? `has-color-${String(colors[cell])}` : "",
]
.filter(Boolean)
.join(" ")}
onPointerDown={(event) => onCellPointerDown(cell, event)}
onPointerEnter={(event) => onCellPointerEnter(cell, event)}
data-cell={cell}
>
{value ? (
<span className="cell-value">{symbolFor(value, size)}</span>
) : (
<>
<span className="corner-marks" aria-hidden="true">
{corner.map((mark) => (
<span key={mark}>{symbolFor(mark, size)}</span>
))}
</span>
<span className="center-marks" aria-hidden="true">
{center.map((mark) => symbolFor(mark, size)).join("")}
</span>
</>
)}
</button>
);
})}
</div>
<ConstraintLayer puzzle={puzzle} />
</div>
);
}
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
import type { PuzzleDefinition } from "../domain/types";
const classicGrid =
"460000001000300476009060050804000607000854000102000805030040900928001000500000013";
export const CLASSIC_SAMPLE: PuzzleDefinition = {
version: 1,
size: 9,
givens: [...classicGrid].map(Number),
title: "A first classic",
author: "Sudoku Tools",
rules: "Place 19 exactly once in every row, column and outlined 3×3 region.",
};
export const KILLER_SAMPLE: PuzzleDefinition = {
version: 1,
size: 4,
givens: new Array<number>(16).fill(0),
title: "Pocket Killer",
author: "Sudoku Tools",
rules:
"Place 14 once per row, column and 2×2 region. Digits in a dashed cage sum to its clue and do not repeat.",
constraints: [
{ type: "killer-cage", cells: [0, 1], sum: 3 },
{ type: "killer-cage", cells: [2, 3, 7], sum: 9 },
{ type: "killer-cage", cells: [4, 8], sum: 4 },
{ type: "killer-cage", cells: [5, 6], sum: 5 },
{ type: "killer-cage", cells: [9, 10], sum: 5 },
{ type: "killer-cage", cells: [11, 15], sum: 5 },
{ type: "killer-cage", cells: [12, 13], sum: 6 },
{ type: "killer-cage", cells: [14], sum: 3 },
],
};
export const SAMPLE_PUZZLES = [CLASSIC_SAMPLE, KILLER_SAMPLE] as const;
+174
View File
@@ -0,0 +1,174 @@
import { cellColumn, cellRow, orthogonalNeighbours } from "./geometry";
import type { CellId, NormalizedPuzzle, VariantConstraint } from "./types";
export type UnitKind = "row" | "column" | "region" | "diagonal";
export interface SudokuUnit {
readonly kind: UnitKind;
readonly index: number;
readonly cells: readonly CellId[];
}
export interface CompiledPuzzle {
readonly puzzle: NormalizedPuzzle;
readonly units: readonly SudokuUnit[];
readonly unitsByCell: readonly (readonly number[])[];
/** Cells which may not contain an equal value. */
readonly peers: readonly ReadonlySet<CellId>[];
readonly constraintsByCell: readonly (readonly number[])[];
readonly orthogonalByCell: readonly (readonly CellId[])[];
}
function addPeerPair(peers: Set<CellId>[], a: CellId, b: CellId): void {
if (a === b) return;
peers[a]?.add(b);
peers[b]?.add(a);
}
function cellsForConstraint(
size: number,
constraint: VariantConstraint,
): readonly CellId[] {
switch (constraint.type) {
case "diagonal":
return Array.from({ length: size }, (_, index) =>
constraint.direction === "main"
? index * size + index
: index * size + size - index - 1,
);
case "anti-knight":
case "anti-king":
case "non-consecutive":
return Array.from({ length: size * size }, (_, cell) => cell);
case "killer-cage":
case "thermo":
case "renban":
case "palindrome":
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];
}
}
export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle {
const { size } = puzzle;
const count = size * size;
const units: SudokuUnit[] = [];
for (let index = 0; index < size; index += 1) {
units.push({
kind: "row",
index,
cells: Array.from({ length: size }, (_, column) => index * size + column),
});
units.push({
kind: "column",
index,
cells: Array.from({ length: size }, (_, row) => row * size + index),
});
units.push({
kind: "region",
index,
cells: Array.from({ length: count }, (_, cell) => cell).filter(
(cell) => puzzle.regions[cell] === index,
),
});
}
for (const constraint of puzzle.constraints) {
if (constraint.type !== "diagonal") continue;
units.push({
kind: "diagonal",
index: constraint.direction === "main" ? 0 : 1,
cells: cellsForConstraint(size, constraint),
});
}
const peers = Array.from({ length: count }, () => new Set<CellId>());
const unitsByCell = Array.from({ length: count }, () => [] as number[]);
units.forEach((unit, unitIndex) => {
for (const cell of unit.cells) {
unitsByCell[cell]?.push(unitIndex);
for (const other of unit.cells) addPeerPair(peers, cell, other);
}
});
const constraintsByCell = Array.from({ length: count }, () => [] as number[]);
puzzle.constraints.forEach((constraint, constraintIndex) => {
for (const cell of new Set(cellsForConstraint(size, constraint))) {
constraintsByCell[cell]?.push(constraintIndex);
}
if (constraint.type === "killer-cage" && constraint.noRepeat !== false) {
for (const a of constraint.cells) {
for (const b of constraint.cells) addPeerPair(peers, a, b);
}
}
});
const hasAntiKnight = puzzle.constraints.some(
({ type }) => type === "anti-knight",
);
const hasAntiKing = puzzle.constraints.some(
({ type }) => type === "anti-king",
);
if (hasAntiKnight || hasAntiKing) {
for (let cell = 0; cell < count; cell += 1) {
const row = cellRow(size, cell);
const column = cellColumn(size, cell);
if (hasAntiKnight) {
for (const [dr, dc] of [
[-2, -1],
[-2, 1],
[-1, -2],
[-1, 2],
[1, -2],
[1, 2],
[2, -1],
[2, 1],
] as const) {
const otherRow = row + dr;
const otherColumn = column + dc;
if (
otherRow >= 0 &&
otherRow < size &&
otherColumn >= 0 &&
otherColumn < size
) {
addPeerPair(peers, cell, otherRow * size + otherColumn);
}
}
}
if (hasAntiKing) {
for (let dr = -1; dr <= 1; dr += 1) {
for (let dc = -1; dc <= 1; dc += 1) {
if (dr === 0 && dc === 0) continue;
const otherRow = row + dr;
const otherColumn = column + dc;
if (
otherRow >= 0 &&
otherRow < size &&
otherColumn >= 0 &&
otherColumn < size
) {
addPeerPair(peers, cell, otherRow * size + otherColumn);
}
}
}
}
}
}
return {
puzzle,
units,
unitsByCell,
peers,
constraintsByCell,
orthogonalByCell: Array.from({ length: count }, (_, cell) =>
orthogonalNeighbours(size, cell),
),
};
}
+128
View File
@@ -0,0 +1,128 @@
import {
EMPTY_VALUE,
MAX_PUZZLE_SIZE,
MIN_PUZZLE_SIZE,
type CellId,
type PuzzleDefinition,
} from "./types";
function assertSize(size: number): void {
if (
!Number.isInteger(size) ||
size < MIN_PUZZLE_SIZE ||
size > MAX_PUZZLE_SIZE
) {
throw new RangeError(
`Puzzle size must be an integer from ${MIN_PUZZLE_SIZE} to ${MAX_PUZZLE_SIZE}.`,
);
}
}
export function cellId(size: number, row: number, column: number): CellId {
assertSize(size);
if (!Number.isInteger(row) || row < 0 || row >= size) {
throw new RangeError("Row is outside the grid.");
}
if (!Number.isInteger(column) || column < 0 || column >= size) {
throw new RangeError("Column is outside the grid.");
}
return row * size + column;
}
export function cellRow(size: number, cell: CellId): number {
assertCell(size, cell);
return Math.floor(cell / size);
}
export function cellColumn(size: number, cell: CellId): number {
assertCell(size, cell);
return cell % size;
}
export function assertCell(size: number, cell: CellId): void {
assertSize(size);
if (!Number.isInteger(cell) || cell < 0 || cell >= size * size) {
throw new RangeError(`Cell ${String(cell)} is outside the grid.`);
}
}
/**
* Builds conventional rectangular regions. For non-composite sizes this falls
* back to 1 x size regions, which is still a valid Latin-square topology.
*/
export function classicRegions(
size: number,
boxRows?: number,
boxColumns?: number,
): number[] {
assertSize(size);
let rows = boxRows;
let columns = boxColumns;
if (rows === undefined && columns === undefined) {
rows = Math.floor(Math.sqrt(size));
while (rows > 1 && size % rows !== 0) rows -= 1;
columns = size / rows;
} else if (
rows === undefined &&
columns !== undefined &&
size % columns === 0
) {
rows = size / columns;
} else if (columns === undefined && rows !== undefined && size % rows === 0) {
columns = size / rows;
}
if (
!Number.isInteger(rows) ||
!Number.isInteger(columns) ||
rows === undefined ||
columns === undefined ||
rows <= 0 ||
columns <= 0 ||
rows * columns !== size
) {
throw new RangeError(
"Box rows and columns must be positive factors whose product is size.",
);
}
const regions = new Array<number>(size * size);
const boxesPerRow = size / columns;
for (let row = 0; row < size; row += 1) {
for (let column = 0; column < size; column += 1) {
regions[row * size + column] =
Math.floor(row / rows) * boxesPerRow + Math.floor(column / columns);
}
}
return regions;
}
export function createEmptyPuzzle(
size = 9,
options: {
readonly boxRows?: number;
readonly boxColumns?: number;
readonly title?: string;
readonly author?: string;
} = {},
): PuzzleDefinition {
const puzzle: PuzzleDefinition = {
version: 1,
size,
givens: new Array<number>(size * size).fill(EMPTY_VALUE),
regions: classicRegions(size, options.boxRows, options.boxColumns),
constraints: [],
...(options.title === undefined ? {} : { title: options.title }),
...(options.author === undefined ? {} : { author: options.author }),
};
return puzzle;
}
export function orthogonalNeighbours(size: number, cell: CellId): CellId[] {
const row = cellRow(size, cell);
const column = cellColumn(size, cell);
const result: CellId[] = [];
if (row > 0) result.push(cell - size);
if (row + 1 < size) result.push(cell + size);
if (column > 0) result.push(cell - 1);
if (column + 1 < size) result.push(cell + 1);
return result;
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./compile";
export * from "./geometry";
export * from "./rules";
export * from "./types";
export * from "./validation";
+342
View File
@@ -0,0 +1,342 @@
import { compilePuzzle, type CompiledPuzzle } from "./compile";
import type {
CellId,
NormalizedPuzzle,
PuzzleConflict,
PuzzleDefinition,
VariantConstraint,
} from "./types";
import { normalizePuzzle } from "./validation";
function valuesAt(
values: readonly number[],
cells: readonly CellId[],
): number[] {
return cells.map((cell) => values[cell] ?? 0);
}
function sumRange(
assigned: readonly number[],
blanks: number,
size: number,
noRepeat: boolean,
): readonly [number, number] {
if (!noRepeat) {
const current = assigned.reduce((sum, value) => sum + value, 0);
return [current + blanks, current + blanks * size];
}
const used = new Set(assigned);
const available = Array.from(
{ length: size },
(_, index) => index + 1,
).filter((value) => !used.has(value));
if (available.length < blanks)
return [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
const current = assigned.reduce((sum, value) => sum + value, 0);
const low = available
.slice(0, blanks)
.reduce((sum, value) => sum + value, current);
const high = available
.slice(-blanks)
.reduce((sum, value) => sum + value, current);
return [low, high];
}
function sumsCanMeet(
values: readonly number[],
cells: readonly CellId[],
size: number,
): readonly [number, number] {
let sum = 0;
let blanks = 0;
for (const cell of cells) {
const value = values[cell] ?? 0;
if (value === 0) blanks += 1;
else sum += value;
}
return [sum + blanks, sum + blanks * size];
}
export function constraintIsFeasible(
constraint: VariantConstraint,
values: readonly number[],
size: number,
): boolean {
switch (constraint.type) {
case "diagonal":
case "anti-knight":
case "anti-king":
return true; // Equality conflicts are represented in compiled peers/units.
case "non-consecutive": {
for (let cell = 0; cell < size * size; cell += 1) {
const value = values[cell] ?? 0;
if (value === 0) continue;
const row = Math.floor(cell / size);
const column = cell % size;
if (column + 1 < size) {
const right = values[cell + 1] ?? 0;
if (right !== 0 && Math.abs(value - right) === 1) return false;
}
if (row + 1 < size) {
const below = values[cell + size] ?? 0;
if (below !== 0 && Math.abs(value - below) === 1) return false;
}
}
return true;
}
case "killer-cage": {
const cageValues = valuesAt(values, constraint.cells);
const assigned = cageValues.filter((value) => value !== 0);
if (
constraint.noRepeat !== false &&
new Set(assigned).size !== assigned.length
)
return false;
const [low, high] = sumRange(
assigned,
cageValues.length - assigned.length,
size,
constraint.noRepeat !== false,
);
return constraint.sum >= low && constraint.sum <= high;
}
case "thermo": {
const length = constraint.cells.length;
const fixed: Array<readonly [number, number]> = [];
for (let index = 0; index < length; index += 1) {
const value = values[constraint.cells[index] ?? -1] ?? 0;
if (value === 0) continue;
if (value < index + 1 || value > size - length + index + 1)
return false;
fixed.push([index, value]);
}
for (let left = 0; left < fixed.length; left += 1) {
for (let right = left + 1; right < fixed.length; right += 1) {
const a = fixed[left];
const b = fixed[right];
if (a === undefined || b === undefined || b[1] - a[1] < b[0] - a[0])
return false;
}
}
return true;
}
case "arrow": {
const [bulbLow, bulbHigh] = sumsCanMeet(values, constraint.bulb, size);
const [lineLow, lineHigh] = sumsCanMeet(values, constraint.line, size);
return bulbLow <= lineHigh && lineLow <= bulbHigh;
}
case "kropki": {
const a = values[constraint.a] ?? 0;
const b = values[constraint.b] ?? 0;
if (a === 0 || b === 0) return true;
return constraint.kind === "white"
? Math.abs(a - b) === 1
: a === b * 2 || b === a * 2;
}
case "xv": {
const a = values[constraint.a] ?? 0;
const b = values[constraint.b] ?? 0;
if (a === 0 || b === 0) return true;
return a + b === constraint.total;
}
case "inequality": {
const lesser = values[constraint.lesser] ?? 0;
const greater = values[constraint.greater] ?? 0;
return lesser === 0 || greater === 0 || lesser < greater;
}
case "renban": {
const assigned = valuesAt(values, constraint.cells).filter(
(value) => value !== 0,
);
if (new Set(assigned).size !== assigned.length) return false;
if (assigned.length <= 1) return true;
const low = Math.min(...assigned);
const high = Math.max(...assigned);
return high - low < constraint.cells.length;
}
case "palindrome": {
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 false;
}
return true;
}
}
}
function asCompiled(
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
): CompiledPuzzle {
if ("puzzle" in puzzle && "peers" in puzzle) return puzzle;
return compilePuzzle(normalizePuzzle(puzzle));
}
export function canPlaceValue(
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
values: readonly number[],
cell: CellId,
value: number,
): boolean {
const compiled = asCompiled(puzzle);
const size = compiled.puzzle.size;
if (!Number.isInteger(cell) || cell < 0 || cell >= size * size) return false;
if (!Number.isInteger(value) || value < 1 || value > size) return false;
for (const peer of compiled.peers[cell] ?? []) {
if (values[peer] === value) return false;
}
const next = values.slice();
next[cell] = value;
for (const index of compiled.constraintsByCell[cell] ?? []) {
const constraint = compiled.puzzle.constraints[index];
if (
constraint !== undefined &&
!constraintIsFeasible(constraint, next, size)
)
return false;
}
return true;
}
export function candidatesForCell(
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
values: readonly number[],
cell: CellId,
): number[] {
const compiled = asCompiled(puzzle);
if ((values[cell] ?? 0) !== 0) return [];
const result: number[] = [];
for (let value = 1; value <= compiled.puzzle.size; value += 1) {
if (canPlaceValue(compiled, values, cell, value)) result.push(value);
}
return result;
}
export function allCandidates(
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
values: readonly number[],
): readonly (readonly number[])[] {
const compiled = asCompiled(puzzle);
return values.map((value, cell) =>
value === 0 ? candidatesForCell(compiled, values, cell) : [],
);
}
export function findConflicts(
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
values?: readonly number[],
): PuzzleConflict[] {
const compiled = asCompiled(puzzle);
const board = values ?? compiled.puzzle.givens;
const conflicts: PuzzleConflict[] = [];
if (board.length !== compiled.puzzle.size * compiled.puzzle.size) {
return [
{
kind: "invalid-value",
cells: [],
message: "Board length does not match the grid.",
},
];
}
board.forEach((value, cell) => {
if (!Number.isInteger(value) || value < 0 || value > compiled.puzzle.size) {
conflicts.push({
kind: "invalid-value",
cells: [cell],
message: "Value is outside the grid range.",
});
}
});
compiled.units.forEach((unit) => {
const byValue = new Map<number, CellId[]>();
for (const cell of unit.cells) {
const value = board[cell] ?? 0;
if (value === 0) continue;
const cells = byValue.get(value) ?? [];
cells.push(cell);
byValue.set(value, cells);
}
for (const [value, cells] of byValue) {
if (cells.length > 1) {
conflicts.push({
kind: "duplicate",
cells,
message: `Value ${value} is repeated in ${unit.kind} ${unit.index + 1}.`,
});
}
}
});
// Peer relationships not already represented by a unit (anti chess and cage uniqueness).
const unitPairs = new Set<string>();
for (const unit of compiled.units) {
for (const a of unit.cells)
for (const b of unit.cells)
unitPairs.add(a < b ? `${a}:${b}` : `${b}:${a}`);
}
compiled.peers.forEach((peers, a) => {
for (const b of peers) {
if (a >= b || unitPairs.has(`${a}:${b}`)) continue;
const value = board[a] ?? 0;
if (value !== 0 && value === board[b]) {
conflicts.push({
kind: "duplicate",
cells: [a, b],
message: "Equal values conflict.",
});
}
}
});
compiled.puzzle.constraints.forEach((constraint, constraintIndex) => {
if (!constraintIsFeasible(constraint, board, compiled.puzzle.size)) {
const cells = (() => {
switch (constraint.type) {
case "diagonal":
case "anti-knight":
case "anti-king":
case "non-consecutive":
return Array.from(
{ length: compiled.puzzle.size * compiled.puzzle.size },
(_, cell) => cell,
);
case "killer-cage":
case "thermo":
case "renban":
case "palindrome":
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];
}
})();
conflicts.push({
kind: "constraint",
cells,
message: `${constraint.type} constraint cannot be satisfied.`,
constraintIndex,
});
}
});
return conflicts;
}
export function isSolved(
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
values: readonly number[],
): boolean {
const compiled = asCompiled(puzzle);
return (
values.length === compiled.puzzle.size * compiled.puzzle.size &&
values.every((value) => value !== 0) &&
findConflicts(compiled, values).length === 0
);
}
+141
View File
@@ -0,0 +1,141 @@
export const MIN_PUZZLE_SIZE = 4;
export const MAX_PUZZLE_SIZE = 16;
export const EMPTY_VALUE = 0;
/** A zero-based cell index: `row * size + column`. */
export type CellId = number;
export type CellValue = number;
export interface DiagonalConstraint {
readonly type: "diagonal";
readonly direction: "main" | "anti";
}
export interface AntiKnightConstraint {
readonly type: "anti-knight";
}
export interface AntiKingConstraint {
readonly type: "anti-king";
}
export interface NonConsecutiveConstraint {
readonly type: "non-consecutive";
}
export interface KillerCageConstraint {
readonly type: "killer-cage";
readonly cells: readonly CellId[];
readonly sum: number;
/** Killer cages normally do not repeat digits. Defaults to true. */
readonly noRepeat?: boolean;
}
export interface ThermoConstraint {
readonly type: "thermo";
/** Ordered from bulb to tip. Values must increase strictly. */
readonly cells: readonly CellId[];
}
export interface ArrowConstraint {
readonly type: "arrow";
/** Bulb cells. Their values sum to the values on the line. */
readonly bulb: readonly CellId[];
readonly line: readonly CellId[];
}
export interface KropkiConstraint {
readonly type: "kropki";
readonly a: CellId;
readonly b: CellId;
/** White means consecutive; black means a 1:2 ratio. */
readonly kind: "white" | "black";
}
export interface XvConstraint {
readonly type: "xv";
readonly a: CellId;
readonly b: CellId;
readonly total: 5 | 10;
}
export interface InequalityConstraint {
readonly type: "inequality";
readonly lesser: CellId;
readonly greater: CellId;
}
export interface RenbanConstraint {
readonly type: "renban";
readonly cells: readonly CellId[];
}
export interface PalindromeConstraint {
readonly type: "palindrome";
readonly cells: readonly CellId[];
}
export type VariantConstraint =
| DiagonalConstraint
| AntiKnightConstraint
| AntiKingConstraint
| NonConsecutiveConstraint
| KillerCageConstraint
| ThermoConstraint
| ArrowConstraint
| KropkiConstraint
| XvConstraint
| InequalityConstraint
| RenbanConstraint
| PalindromeConstraint;
export interface PuzzleDefinition {
readonly version: 1;
readonly size: number;
/** Row-major values. Zero denotes an empty cell. */
readonly givens: readonly CellValue[];
/** Row-major region IDs in the range 0..size-1. Omit for rectangular boxes. */
readonly regions?: readonly number[];
readonly constraints?: readonly VariantConstraint[];
readonly id?: string;
readonly title?: string;
readonly author?: string;
readonly rules?: string;
/** Optional trusted solution, stored row-major without zeroes. */
readonly solution?: readonly CellValue[];
}
export interface NormalizedPuzzle extends PuzzleDefinition {
readonly regions: readonly number[];
readonly constraints: readonly VariantConstraint[];
}
export type ConflictKind =
"duplicate" | "constraint" | "impossible" | "invalid-value";
export interface PuzzleConflict {
readonly kind: ConflictKind;
readonly cells: readonly CellId[];
readonly message: string;
readonly constraintIndex?: number;
}
export interface ValidationIssue {
readonly path: string;
readonly message: string;
}
export interface ValidationResult {
readonly valid: boolean;
readonly issues: readonly ValidationIssue[];
}
export class PuzzleValidationError extends Error {
readonly issues: readonly ValidationIssue[];
constructor(issues: readonly ValidationIssue[]) {
super(issues.map((issue) => `${issue.path}: ${issue.message}`).join("; "));
this.name = "PuzzleValidationError";
this.issues = issues;
}
}
+511
View File
@@ -0,0 +1,511 @@
import { classicRegions } from "./geometry";
import { compilePuzzle } from "./compile";
import { findConflicts } from "./rules";
import {
MAX_PUZZLE_SIZE,
MIN_PUZZLE_SIZE,
PuzzleValidationError,
type NormalizedPuzzle,
type PuzzleDefinition,
type ValidationIssue,
type ValidationResult,
type VariantConstraint,
} from "./types";
const MAX_CONSTRAINTS = 4_096;
const MAX_SHORT_TEXT = 256;
const MAX_RULES_TEXT = 16_384;
const ROOT_KEYS = new Set([
"version",
"size",
"givens",
"regions",
"constraints",
"id",
"title",
"author",
"rules",
"solution",
]);
const CONSTRAINT_KEYS: Readonly<
Record<VariantConstraint["type"], ReadonlySet<string>>
> = {
diagonal: new Set(["type", "direction"]),
"anti-knight": new Set(["type"]),
"anti-king": new Set(["type"]),
"non-consecutive": new Set(["type"]),
"killer-cage": new Set(["type", "cells", "sum", "noRepeat"]),
thermo: new Set(["type", "cells"]),
arrow: new Set(["type", "bulb", "line"]),
kropki: new Set(["type", "a", "b", "kind"]),
xv: new Set(["type", "a", "b", "total"]),
inequality: new Set(["type", "lesser", "greater"]),
renban: new Set(["type", "cells"]),
palindrome: new Set(["type", "cells"]),
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function add(issues: ValidationIssue[], path: string, message: string): void {
issues.push({ path, message });
}
function validateText(
value: unknown,
path: string,
maximum: number,
issues: ValidationIssue[],
): void {
if (value === undefined) return;
if (typeof value !== "string") add(issues, path, "must be a string");
else if (value.length > maximum)
add(issues, path, `must contain at most ${maximum} characters`);
}
function validateCell(
value: unknown,
path: string,
cellCount: number,
issues: ValidationIssue[],
): value is number {
if (
!Number.isInteger(value) ||
(value as number) < 0 ||
(value as number) >= cellCount
) {
add(issues, path, `must be an integer from 0 to ${cellCount - 1}`);
return false;
}
return true;
}
function validateCells(
value: unknown,
path: string,
cellCount: number,
minimum: number,
maximum: number,
issues: ValidationIssue[],
): value is readonly number[] {
if (!Array.isArray(value)) {
add(issues, path, "must be an array");
return false;
}
if (value.length < minimum || value.length > maximum) {
add(issues, path, `must contain ${minimum} to ${maximum} cells`);
}
const seen = new Set<number>();
value.forEach((cell, index) => {
if (validateCell(cell, `${path}[${index}]`, cellCount, issues)) {
if (seen.has(cell))
add(issues, `${path}[${index}]`, "must not repeat a cell");
seen.add(cell);
}
});
return true;
}
function validateConstraint(
value: unknown,
index: number,
size: number,
issues: ValidationIssue[],
): void {
const path = `constraints[${index}]`;
if (!isRecord(value) || typeof value.type !== "string") {
add(issues, path, "must be a constraint object with a type");
return;
}
const type = value.type as VariantConstraint["type"];
const allowed = CONSTRAINT_KEYS[type];
if (allowed === undefined) {
add(issues, `${path}.type`, "is not a supported constraint type");
return;
}
for (const key of Object.keys(value)) {
if (!allowed.has(key))
add(issues, `${path}.${key}`, "is not a recognized field");
}
const cellCount = size * size;
switch (type) {
case "diagonal":
if (value.direction !== "main" && value.direction !== "anti") {
add(issues, `${path}.direction`, 'must be "main" or "anti"');
}
break;
case "anti-knight":
case "anti-king":
case "non-consecutive":
break;
case "killer-cage": {
const cellsValid = validateCells(
value.cells,
`${path}.cells`,
cellCount,
1,
size,
issues,
);
if (
typeof value.noRepeat !== "undefined" &&
typeof value.noRepeat !== "boolean"
) {
add(issues, `${path}.noRepeat`, "must be a boolean");
}
if (!Number.isInteger(value.sum)) {
add(issues, `${path}.sum`, "must be an integer");
} else if (cellsValid) {
const cells = value.cells as readonly number[];
const length = cells.length;
const noRepeat = value.noRepeat !== false;
const minimum = noRepeat ? (length * (length + 1)) / 2 : length;
const maximum = noRepeat
? (length * (2 * size - length + 1)) / 2
: length * size;
if (
(value.sum as number) < minimum ||
(value.sum as number) > maximum
) {
add(
issues,
`${path}.sum`,
`must be reachable (${minimum} to ${maximum})`,
);
}
}
break;
}
case "thermo":
validateCells(value.cells, `${path}.cells`, cellCount, 2, size, issues);
break;
case "arrow": {
const bulbValid = validateCells(
value.bulb,
`${path}.bulb`,
cellCount,
1,
size,
issues,
);
const lineValid = validateCells(
value.line,
`${path}.line`,
cellCount,
1,
cellCount,
issues,
);
if (bulbValid && lineValid) {
const bulb = new Set(value.bulb as readonly number[]);
if ((value.line as readonly number[]).some((cell) => bulb.has(cell))) {
add(issues, path, "bulb and line cells must be disjoint");
}
}
break;
}
case "kropki":
validateCell(value.a, `${path}.a`, cellCount, issues);
validateCell(value.b, `${path}.b`, cellCount, issues);
if (value.a === value.b)
add(issues, path, "endpoints must be different cells");
if (value.kind !== "white" && value.kind !== "black") {
add(issues, `${path}.kind`, 'must be "white" or "black"');
}
break;
case "xv":
validateCell(value.a, `${path}.a`, cellCount, issues);
validateCell(value.b, `${path}.b`, cellCount, issues);
if (value.a === value.b)
add(issues, path, "endpoints must be different cells");
if (value.total !== 5 && value.total !== 10)
add(issues, `${path}.total`, "must be 5 or 10");
break;
case "inequality":
validateCell(value.lesser, `${path}.lesser`, cellCount, issues);
validateCell(value.greater, `${path}.greater`, cellCount, issues);
if (value.lesser === value.greater)
add(issues, path, "endpoints must be different cells");
break;
case "renban":
validateCells(value.cells, `${path}.cells`, cellCount, 2, size, issues);
break;
case "palindrome":
validateCells(
value.cells,
`${path}.cells`,
cellCount,
2,
cellCount,
issues,
);
break;
}
}
function validateValueArray(
value: unknown,
path: string,
size: number,
allowEmpty: boolean,
issues: ValidationIssue[],
): value is readonly number[] {
if (!Array.isArray(value)) {
add(issues, path, "must be an array");
return false;
}
if (value.length !== size * size)
add(issues, path, `must contain exactly ${size * size} values`);
value.forEach((entry, index) => {
const minimum = allowEmpty ? 0 : 1;
if (!Number.isInteger(entry) || entry < minimum || entry > size) {
add(
issues,
`${path}[${index}]`,
`must be an integer from ${minimum} to ${size}`,
);
}
});
return true;
}
function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
switch (constraint.type) {
case "diagonal":
return { type: constraint.type, direction: constraint.direction };
case "anti-knight":
case "anti-king":
case "non-consecutive":
return { type: constraint.type };
case "killer-cage":
return {
type: constraint.type,
cells: [...constraint.cells],
sum: constraint.sum,
...(constraint.noRepeat === undefined
? {}
: { noRepeat: constraint.noRepeat }),
};
case "thermo":
case "renban":
case "palindrome":
return { type: constraint.type, cells: [...constraint.cells] };
case "arrow":
return {
type: constraint.type,
bulb: [...constraint.bulb],
line: [...constraint.line],
};
case "kropki":
return {
type: constraint.type,
a: constraint.a,
b: constraint.b,
kind: constraint.kind,
};
case "xv":
return {
type: constraint.type,
a: constraint.a,
b: constraint.b,
total: constraint.total,
};
case "inequality":
return {
type: constraint.type,
lesser: constraint.lesser,
greater: constraint.greater,
};
}
}
function normalizedUnchecked(puzzle: PuzzleDefinition): NormalizedPuzzle {
return {
version: 1,
size: puzzle.size,
givens: [...puzzle.givens],
regions:
puzzle.regions === undefined
? classicRegions(puzzle.size)
: [...puzzle.regions],
constraints: (puzzle.constraints ?? []).map(cloneConstraint),
...(puzzle.id === undefined ? {} : { id: puzzle.id }),
...(puzzle.title === undefined ? {} : { title: puzzle.title }),
...(puzzle.author === undefined ? {} : { author: puzzle.author }),
...(puzzle.rules === undefined ? {} : { rules: puzzle.rules }),
...(puzzle.solution === undefined
? {}
: { solution: [...puzzle.solution] }),
};
}
export function validatePuzzle(puzzle: unknown): ValidationResult {
const issues: ValidationIssue[] = [];
if (!isRecord(puzzle))
return {
valid: false,
issues: [{ path: "$", message: "must be an object" }],
};
for (const key of Object.keys(puzzle)) {
if (!ROOT_KEYS.has(key)) add(issues, key, "is not a recognized field");
}
if (puzzle.version !== 1) add(issues, "version", "must be 1");
const size = puzzle.size;
if (
!Number.isInteger(size) ||
(size as number) < MIN_PUZZLE_SIZE ||
(size as number) > MAX_PUZZLE_SIZE
) {
add(
issues,
"size",
`must be an integer from ${MIN_PUZZLE_SIZE} to ${MAX_PUZZLE_SIZE}`,
);
return { valid: false, issues };
}
const n = size as number;
const givensValid = validateValueArray(
puzzle.givens,
"givens",
n,
true,
issues,
);
let regionsValid = true;
if (puzzle.regions !== undefined) {
if (!Array.isArray(puzzle.regions)) {
add(issues, "regions", "must be an array");
regionsValid = false;
} else {
if (puzzle.regions.length !== n * n) {
add(issues, "regions", `must contain exactly ${n * n} values`);
regionsValid = false;
}
puzzle.regions.forEach((region, index) => {
if (!Number.isInteger(region) || region < 0 || region >= n) {
add(
issues,
`regions[${index}]`,
`must be an integer from 0 to ${n - 1}`,
);
regionsValid = false;
}
});
}
if (Array.isArray(puzzle.regions)) {
const counts = new Array<number>(n).fill(0);
puzzle.regions.forEach((region) => {
if (Number.isInteger(region) && region >= 0 && region < n) {
counts[region] = (counts[region] ?? 0) + 1;
}
});
counts.forEach((count, region) => {
if (count !== n)
add(
issues,
"regions",
`region ${region} must contain exactly ${n} cells`,
);
});
}
}
if (puzzle.constraints !== undefined && !Array.isArray(puzzle.constraints)) {
add(issues, "constraints", "must be an array");
} else if (Array.isArray(puzzle.constraints)) {
if (puzzle.constraints.length > MAX_CONSTRAINTS) {
add(
issues,
"constraints",
`must contain at most ${MAX_CONSTRAINTS} constraints`,
);
}
puzzle.constraints
.slice(0, MAX_CONSTRAINTS + 1)
.forEach((constraint, index) => {
validateConstraint(constraint, index, n, issues);
});
}
validateText(puzzle.id, "id", MAX_SHORT_TEXT, issues);
validateText(puzzle.title, "title", MAX_SHORT_TEXT, issues);
validateText(puzzle.author, "author", MAX_SHORT_TEXT, issues);
validateText(puzzle.rules, "rules", MAX_RULES_TEXT, issues);
const solutionValid =
puzzle.solution === undefined ||
validateValueArray(puzzle.solution, "solution", n, false, issues);
if (
issues.length === 0 &&
givensValid &&
regionsValid &&
solutionValid &&
(puzzle.constraints === undefined || Array.isArray(puzzle.constraints))
) {
const normalized = normalizedUnchecked(
puzzle as unknown as PuzzleDefinition,
);
const compiled = compilePuzzle(normalized);
for (const conflict of findConflicts(compiled, normalized.givens)) {
add(issues, "givens", conflict.message);
}
if (normalized.solution !== undefined) {
for (let cell = 0; cell < normalized.givens.length; cell += 1) {
const given = normalized.givens[cell] ?? 0;
if (given !== 0 && normalized.solution[cell] !== given) {
add(issues, `solution[${cell}]`, "must agree with the given value");
}
}
for (const conflict of findConflicts(compiled, normalized.solution)) {
add(issues, "solution", conflict.message);
}
}
}
return { valid: issues.length === 0, issues };
}
export function assertValidPuzzle(
puzzle: unknown,
): asserts puzzle is PuzzleDefinition {
const result = validatePuzzle(puzzle);
if (!result.valid) throw new PuzzleValidationError(result.issues);
}
export function normalizePuzzle(puzzle: PuzzleDefinition): NormalizedPuzzle {
assertValidPuzzle(puzzle);
return normalizedUnchecked(puzzle);
}
export function validateBoard(
puzzle: PuzzleDefinition | NormalizedPuzzle,
values: unknown,
): ValidationResult {
const normalized = normalizePuzzle(puzzle);
const issues: ValidationIssue[] = [];
const valuesValid = validateValueArray(
values,
"values",
normalized.size,
true,
issues,
);
if (valuesValid) {
values.forEach((value, cell) => {
const given = normalized.givens[cell] ?? 0;
if (given !== 0 && value !== given)
add(issues, `values[${cell}]`, "must preserve the given value");
});
for (const conflict of findConflicts(compilePuzzle(normalized), values)) {
add(issues, "values", conflict.message);
}
}
return { valid: issues.length === 0, issues };
}
export function assertValidBoard(
puzzle: PuzzleDefinition | NormalizedPuzzle,
values: unknown,
): asserts values is readonly number[] {
const result = validateBoard(puzzle, values);
if (!result.valid) throw new PuzzleValidationError(result.issues);
}
+427
View File
@@ -0,0 +1,427 @@
import {
SUDOKU_DOCUMENT_SCHEMA,
SUDOKU_DOCUMENT_VERSION,
cloneConstraint,
type PortableConstraint,
type SudokuDocument,
} from "./types";
export const MAX_DOCUMENT_BYTES = 1_048_576;
export const MIN_BOARD_SIZE = 4;
export const MAX_BOARD_SIZE = 16;
export const MAX_CONSTRAINTS = 5_000;
const MAX_TEXT_LENGTH = 20_000;
const MAX_RULES = 1_000;
export class SudokuFormatError extends Error {
readonly code: string;
constructor(code: string, message: string, options?: ErrorOptions) {
super(message, options);
this.name = "SudokuFormatError";
this.code = code;
}
}
function fail(code: string, message: string): never {
throw new SudokuFormatError(code, message);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function integer(
value: unknown,
label: string,
minimum: number,
maximum: number,
): number {
if (
!Number.isInteger(value) ||
(value as number) < minimum ||
(value as number) > maximum
) {
return fail(
"INVALID_NUMBER",
`${label} must be an integer from ${minimum} to ${maximum}.`,
);
}
return value as number;
}
function optionalText(value: unknown, label: string): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string")
return fail("INVALID_TEXT", `${label} must be text.`);
if (value.length > MAX_TEXT_LENGTH) {
return fail(
"LIMIT_EXCEEDED",
`${label} exceeds ${MAX_TEXT_LENGTH.toLocaleString()} characters.`,
);
}
return value;
}
function numberArray(
value: unknown,
label: string,
expectedLength: number,
minimum: number,
maximum: number,
): number[] {
if (!Array.isArray(value) || value.length !== expectedLength) {
return fail(
"INVALID_GRID",
`${label} must contain exactly ${expectedLength} entries.`,
);
}
return value.map((entry, index) =>
integer(entry, `${label}[${index}]`, minimum, maximum),
);
}
function cell(value: unknown, label: string, cellCount: number): number {
return integer(value, label, 0, cellCount - 1);
}
function cells(
value: unknown,
label: string,
cellCount: number,
minimumLength = 1,
): number[] {
if (
!Array.isArray(value) ||
value.length < minimumLength ||
value.length > cellCount
) {
return fail(
"INVALID_CELLS",
`${label} must contain ${minimumLength} to ${cellCount} cell indices.`,
);
}
const result = value.map((entry, index) =>
cell(entry, `${label}[${index}]`, cellCount),
);
if (new Set(result).size !== result.length) {
return fail("DUPLICATE_CELL", `${label} contains a duplicate cell.`);
}
return result;
}
function pair(
record: Record<string, unknown>,
cellCount: number,
): { a: number; b: number } {
const a = cell(record.a, "constraint.a", cellCount);
const b = cell(record.b, "constraint.b", cellCount);
if (a === b)
return fail("DUPLICATE_CELL", "A relation must join two different cells.");
return { a, b };
}
function parseConstraint(
value: unknown,
cellCount: number,
): PortableConstraint {
if (!isRecord(value) || typeof value.type !== "string") {
return fail(
"INVALID_CONSTRAINT",
"Each constraint must be an object with a type.",
);
}
switch (value.type) {
case "diagonal": {
if (value.direction !== "main" && value.direction !== "anti") {
return fail(
"INVALID_CONSTRAINT",
"A diagonal direction must be main or anti.",
);
}
return { type: "diagonal", direction: value.direction };
}
case "anti-knight":
case "anti-king":
case "non-consecutive":
return { type: value.type };
case "killer-cage": {
const cageCells = cells(value.cells, "killer-cage.cells", cellCount);
const sum = integer(
value.sum,
"killer-cage.sum",
1,
MAX_BOARD_SIZE * cellCount,
);
if (value.noRepeat !== undefined && typeof value.noRepeat !== "boolean") {
return fail(
"INVALID_CONSTRAINT",
"killer-cage.noRepeat must be true or false.",
);
}
return {
type: "killer-cage",
cells: cageCells,
sum,
...(value.noRepeat === undefined ? {} : { noRepeat: value.noRepeat }),
};
}
case "thermo":
case "renban":
case "palindrome":
return {
type: value.type,
cells: cells(value.cells, `${value.type}.cells`, cellCount, 2),
};
case "arrow":
return {
type: "arrow",
bulb: cells(value.bulb, "arrow.bulb", cellCount),
line: cells(value.line, "arrow.line", cellCount),
};
case "kropki": {
const related = pair(value, cellCount);
if (value.kind !== "white" && value.kind !== "black") {
return fail(
"INVALID_CONSTRAINT",
"A Kropki kind must be white or black.",
);
}
return { type: "kropki", ...related, kind: value.kind };
}
case "xv": {
const related = pair(value, cellCount);
if (value.total !== 5 && value.total !== 10) {
return fail("INVALID_CONSTRAINT", "An XV total must be 5 or 10.");
}
return {
type: "xv",
...related,
total: value.total,
};
}
case "inequality": {
const lesser = cell(value.lesser, "inequality.lesser", cellCount);
const greater = cell(value.greater, "inequality.greater", cellCount);
if (lesser === greater) {
return fail(
"DUPLICATE_CELL",
"An inequality must join two different cells.",
);
}
return { type: "inequality", lesser, greater };
}
default:
return fail(
"UNSUPPORTED_CONSTRAINT",
`Unsupported constraint type: ${value.type}.`,
);
}
}
function textList(value: unknown, label: string): string[] | undefined {
if (value === undefined) return undefined;
if (!Array.isArray(value) || value.length > MAX_RULES) {
return fail(
"LIMIT_EXCEEDED",
`${label} must contain at most ${MAX_RULES} entries.`,
);
}
return value.map((entry, index) => {
if (typeof entry !== "string" || entry.length > MAX_TEXT_LENGTH) {
return fail("INVALID_TEXT", `${label}[${index}] is not bounded text.`);
}
return entry;
});
}
export function normalizeSudokuDocument(value: unknown): SudokuDocument {
if (!isRecord(value))
return fail("INVALID_DOCUMENT", "The puzzle document must be an object.");
if (value.schema !== SUDOKU_DOCUMENT_SCHEMA) {
return fail("INVALID_SCHEMA", `Expected schema ${SUDOKU_DOCUMENT_SCHEMA}.`);
}
if (value.version !== SUDOKU_DOCUMENT_VERSION) {
return fail(
"UNSUPPORTED_VERSION",
`Unsupported puzzle document version: ${String(value.version)}.`,
);
}
const size = integer(value.size, "size", MIN_BOARD_SIZE, MAX_BOARD_SIZE);
const cellCount = size * size;
const givens = numberArray(value.givens, "givens", cellCount, 0, size);
const values =
value.values === undefined
? undefined
: numberArray(value.values, "values", cellCount, 0, size);
if (values !== undefined) {
givens.forEach((given, index) => {
if (given !== 0 && values[index] !== given) {
return fail(
"INVALID_GRID",
`values[${index}] must preserve its given digit.`,
);
}
});
}
const solution =
value.solution === undefined
? undefined
: numberArray(value.solution, "solution", cellCount, 1, size);
const regions =
value.regions === undefined
? undefined
: numberArray(value.regions, "regions", cellCount, 0, size - 1);
const marks = (input: unknown, label: string): number[][] | undefined => {
if (input === undefined) return undefined;
if (!Array.isArray(input) || input.length !== cellCount) {
return fail(
"INVALID_GRID",
`${label} must contain exactly ${cellCount} entries.`,
);
}
return input.map((entry, index) => {
if (!Array.isArray(entry) || entry.length > size) {
return fail(
"INVALID_CANDIDATES",
`${label}[${index}] must be an array.`,
);
}
const parsed = entry.map((digit, digitIndex) =>
integer(digit, `${label}[${index}][${digitIndex}]`, 1, size),
);
return [...new Set(parsed)].sort((a, b) => a - b);
});
};
const cornerMarks = marks(value.cornerMarks, "cornerMarks");
const centerMarks = marks(value.centerMarks, "centerMarks");
const candidates = marks(value.candidates, "candidates");
const colors =
value.colors === undefined
? undefined
: numberArray(value.colors, "colors", cellCount, 0, 8);
let elapsedMs: number | undefined;
if (value.elapsedMs !== undefined) {
if (
typeof value.elapsedMs !== "number" ||
!Number.isFinite(value.elapsedMs) ||
value.elapsedMs < 0
) {
return fail(
"INVALID_NUMBER",
"elapsedMs must be a non-negative finite number.",
);
}
elapsedMs = value.elapsedMs;
}
if (
!Array.isArray(value.constraints) ||
value.constraints.length > MAX_CONSTRAINTS
) {
return fail(
"LIMIT_EXCEEDED",
`constraints must contain at most ${MAX_CONSTRAINTS.toLocaleString()} entries.`,
);
}
const constraints = value.constraints.map((constraint) =>
parseConstraint(constraint, cellCount),
);
const title = optionalText(value.title, "title");
const author = optionalText(value.author, "author");
const id = optionalText(value.id, "id");
const rules = textList(value.rules, "rules");
const globalRules = textList(value.globalRules, "globalRules");
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: SUDOKU_DOCUMENT_VERSION,
size,
givens,
constraints,
...(values === undefined ? {} : { values }),
...(solution === undefined ? {} : { solution }),
...(cornerMarks === undefined ? {} : { cornerMarks }),
...(centerMarks === undefined ? {} : { centerMarks }),
...(candidates === undefined ? {} : { candidates }),
...(colors === undefined ? {} : { colors }),
...(elapsedMs === undefined ? {} : { elapsedMs }),
...(regions === undefined ? {} : { regions }),
...(title === undefined ? {} : { title }),
...(author === undefined ? {} : { author }),
...(id === undefined ? {} : { id }),
...(rules === undefined ? {} : { rules }),
...(globalRules === undefined ? {} : { globalRules }),
};
}
export function parseSudokuDocument(input: string): SudokuDocument {
if (new TextEncoder().encode(input).byteLength > MAX_DOCUMENT_BYTES) {
return fail(
"LIMIT_EXCEEDED",
`Puzzle JSON exceeds ${MAX_DOCUMENT_BYTES.toLocaleString()} bytes.`,
);
}
let parsed: unknown;
try {
parsed = JSON.parse(input) as unknown;
} catch (error) {
throw new SudokuFormatError(
"INVALID_JSON",
"The puzzle is not valid JSON.",
{ cause: error },
);
}
return normalizeSudokuDocument(parsed);
}
export function serializeSudokuDocument(
value: SudokuDocument,
pretty = false,
): string {
const normalized = normalizeSudokuDocument(value);
const result = JSON.stringify(normalized, null, pretty ? 2 : undefined);
if (new TextEncoder().encode(result).byteLength > MAX_DOCUMENT_BYTES) {
return fail(
"LIMIT_EXCEEDED",
`Puzzle JSON exceeds ${MAX_DOCUMENT_BYTES.toLocaleString()} bytes.`,
);
}
return result;
}
export function cloneSudokuDocument(value: SudokuDocument): SudokuDocument {
const normalized = normalizeSudokuDocument(value);
return {
...normalized,
givens: [...normalized.givens],
constraints: normalized.constraints.map(cloneConstraint),
...(normalized.values === undefined
? {}
: { values: [...normalized.values] }),
...(normalized.solution === undefined
? {}
: { solution: [...normalized.solution] }),
...(normalized.cornerMarks === undefined
? {}
: { cornerMarks: normalized.cornerMarks.map((entry) => [...entry]) }),
...(normalized.centerMarks === undefined
? {}
: { centerMarks: normalized.centerMarks.map((entry) => [...entry]) }),
...(normalized.candidates === undefined
? {}
: { candidates: normalized.candidates.map((entry) => [...entry]) }),
...(normalized.colors === undefined
? {}
: { colors: [...normalized.colors] }),
...(normalized.regions === undefined
? {}
: { regions: [...normalized.regions] }),
...(normalized.rules === undefined ? {} : { rules: [...normalized.rules] }),
...(normalized.globalRules === undefined
? {}
: { globalRules: [...normalized.globalRules] }),
};
}
+740
View File
@@ -0,0 +1,740 @@
import {
compressToBase64,
decompressFromBase64,
decompressFromEncodedURIComponent,
} from "lz-string";
import {
MAX_BOARD_SIZE,
MAX_DOCUMENT_BYTES,
MIN_BOARD_SIZE,
SudokuFormatError,
} from "./document";
import {
SUDOKU_DOCUMENT_SCHEMA,
SUDOKU_DOCUMENT_VERSION,
type PortableConstraint,
type SudokuDocument,
} from "./types";
export const MAX_FPUZZLES_PAYLOAD_LENGTH = 262_144;
const SUPPORTED_ROOT_FIELDS = new Set([
"size",
"grid",
"title",
"author",
"ruleset",
"solution",
"diagonal+",
"diagonal-",
"antiknight",
"antiking",
"antikingsmove",
"nonconsecutive",
"killercage",
"thermometer",
"arrow",
"difference",
"ratio",
"xv",
"inequality",
"renban",
"palindrome",
"disabledlogic",
"truecandidatesoptions",
"successMessage",
"successmessage",
]);
const UNSUPPORTED_RULE_FIELDS: Readonly<Record<string, string>> = {
disjointgroups: "disjoint groups",
littlekillersum: "little killer sums",
sandwichsum: "sandwich sums",
even: "even cells",
odd: "odd cells",
extraregion: "extra regions",
clone: "clone regions",
quadruple: "quadruples",
betweenline: "between lines",
minimum: "minimum cells",
maximum: "maximum cells",
whispers: "whisper lines",
regionsumline: "region-sum lines",
entropicline: "entropic lines",
modularline: "modular lines",
zipperline: "zipper lines",
nabner: "Nabner lines",
doublearrow: "double arrows",
lockout: "lockout lines",
rowindexer: "row indexers",
columnindexer: "column indexers",
boxindexer: "box indexers",
xsum: "X-sums",
skyscraper: "skyscrapers",
fogofwar: "fog of war",
foglight: "fog lights",
cage: "generic cages",
negative: "negative constraints",
};
const DECORATION_FIELDS: Readonly<Record<string, string>> = {
line: "decorative lines",
rectangle: "rectangles",
circle: "circles",
text: "text decorations",
};
export class NetworkPuzzleIdError extends SudokuFormatError {
readonly puzzleId: string;
constructor(puzzleId: string) {
super(
"NETWORK_PUZZLE_ID",
`${puzzleId}” is a server-hosted SudokuPad puzzle ID. This local-only app cannot fetch short puzzle IDs; paste an inline fpuzzles URL or raw fpuzzles JSON instead.`,
);
this.name = "NetworkPuzzleIdError";
this.puzzleId = puzzleId;
}
}
type JsonRecord = Record<string, unknown>;
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function fail(code: string, message: string): never {
throw new SudokuFormatError(code, message);
}
function present(value: unknown): boolean {
if (value === undefined || value === null || value === false) return false;
if (Array.isArray(value)) return value.length > 0;
if (typeof value === "string") return value.length > 0;
return true;
}
function assertSupportedRootFields(value: JsonRecord): void {
for (const [field, raw] of Object.entries(value)) {
const unsupported = UNSUPPORTED_RULE_FIELDS[field];
if (unsupported !== undefined && present(raw)) {
return fail(
"UNSUPPORTED_FPUZZLES",
`This puzzle uses ${unsupported}, which Sudoku Tools cannot enforce yet. Import stopped rather than silently weakening the puzzle.`,
);
}
const decoration = DECORATION_FIELDS[field];
if (decoration !== undefined && present(raw)) {
return fail(
"UNSUPPORTED_FPUZZLES",
`This puzzle contains ${decoration}, which cannot be preserved yet. Import stopped rather than discarding them.`,
);
}
if (
!SUPPORTED_ROOT_FIELDS.has(field) &&
unsupported === undefined &&
decoration === undefined
) {
return fail(
"UNSUPPORTED_FPUZZLES",
`Unknown fpuzzles field “${field}”. Import stopped so a rule or visual cannot be silently lost.`,
);
}
}
}
function boundedJson(input: string): unknown {
if (new TextEncoder().encode(input).byteLength > MAX_DOCUMENT_BYTES) {
return fail(
"LIMIT_EXCEEDED",
"The fpuzzles JSON is too large to open safely.",
);
}
try {
return JSON.parse(input) as unknown;
} catch (error) {
throw new SudokuFormatError(
"INVALID_FPUZZLES",
"The fpuzzles data is not valid JSON.",
{
cause: error,
},
);
}
}
export function cellIndexFromAddress(value: unknown, size: number): number {
if (typeof value !== "string")
return fail("INVALID_CELL", "An fpuzzles cell must be RnCn text.");
const match = /^R(\d+)C(\d+)$/iu.exec(value.trim());
if (match === null)
return fail("INVALID_CELL", `Invalid fpuzzles cell address: ${value}.`);
const row = Number(match[1]);
const column = Number(match[2]);
if (row < 1 || row > size || column < 1 || column > size) {
return fail(
"INVALID_CELL",
`Cell ${value} is outside the ${size}×${size} grid.`,
);
}
return (row - 1) * size + column - 1;
}
export function addressFromCellIndex(index: number, size: number): string {
if (!Number.isInteger(index) || index < 0 || index >= size * size) {
return fail(
"INVALID_CELL",
`Cell index ${index} is outside the ${size}×${size} grid.`,
);
}
return `R${Math.floor(index / size) + 1}C${(index % size) + 1}`;
}
function fpCells(value: unknown, size: number, minimum = 1): number[] {
if (
!Array.isArray(value) ||
value.length < minimum ||
value.length > size * size
) {
return fail(
"INVALID_CELLS",
"An fpuzzles constraint contains an invalid cell list.",
);
}
const result = value.map((entry) => cellIndexFromAddress(entry, size));
if (new Set(result).size !== result.length) {
return fail(
"DUPLICATE_CELL",
"An fpuzzles constraint contains a duplicate cell.",
);
}
return result;
}
function objects(value: unknown, maximum = 5_000): JsonRecord[] {
if (value === undefined) return [];
if (
!Array.isArray(value) ||
value.length > maximum ||
!value.every(isRecord)
) {
return fail(
"INVALID_FPUZZLES",
"An fpuzzles constraint collection is invalid or too large.",
);
}
return value;
}
function lines(value: unknown, size: number): number[][] {
if (
!Array.isArray(value) ||
value.length === 0 ||
value.length > size * size
) {
return fail("INVALID_CELLS", "An fpuzzles line collection is invalid.");
}
// Some producers use `lines: [[...]]`; tolerate a direct cell list as well.
if (value.every((entry) => typeof entry === "string"))
return [fpCells(value, size, 2)];
return value.map((line) => fpCells(line, size, 2));
}
function numeric(
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_FPUZZLES", `${label} is outside the supported range.`);
}
return parsed as number;
}
function readGrid(value: unknown, size: number): JsonRecord[] {
if (!Array.isArray(value))
return fail("INVALID_FPUZZLES", "fpuzzles.grid must be an array.");
const flattened =
value.length === size && value.every(Array.isArray) ? value.flat() : value;
if (flattened.length !== size * size || !flattened.every(isRecord)) {
return fail(
"INVALID_FPUZZLES",
`fpuzzles.grid must contain ${size * size} cells.`,
);
}
return flattened;
}
function readRegions(
grid: readonly JsonRecord[],
size: number,
): number[] | undefined {
const hasAny = grid.some((entry) => entry.region !== undefined);
if (!hasAny) return undefined;
return grid.map((entry, index) =>
entry.region === undefined
? fail(
"INVALID_FPUZZLES",
"A custom region map must assign every cell to a region.",
)
: numeric(entry.region, `grid[${index}].region`, 0, size - 1),
);
}
function addLineConstraints(
output: PortableConstraint[],
source: unknown,
size: number,
type: "thermo" | "renban" | "palindrome",
): void {
for (const item of objects(source)) {
for (const line of lines(item.lines ?? item.cells, size))
output.push({ type, cells: line });
}
}
function parseRules(value: unknown): string[] | undefined {
if (value === undefined || value === "") return undefined;
if (typeof value === "string") return [value];
if (
Array.isArray(value) &&
value.every((entry) => typeof entry === "string")
) {
return value.slice(0, 1_000);
}
return fail(
"INVALID_FPUZZLES",
"fpuzzles.ruleset must be text or a list of text rules.",
);
}
/** Convert already-decoded fpuzzles JSON into the bounded Sudoku Tools model. */
export function parseFpuzzles(value: unknown): SudokuDocument {
if (!isRecord(value))
return fail("INVALID_FPUZZLES", "The fpuzzles puzzle must be an object.");
assertSupportedRootFields(value);
const size = numeric(
value.size ?? 9,
"fpuzzles.size",
MIN_BOARD_SIZE,
MAX_BOARD_SIZE,
);
const grid = readGrid(value.grid, size);
const givens = grid.map((entry, index) => {
if (entry.given !== true) return 0;
return numeric(entry.value, `grid[${index}].value`, 1, size);
});
const values = grid.map((entry, index) =>
entry.value === undefined || entry.value === null || entry.value === ""
? 0
: numeric(entry.value, `grid[${index}].value`, 1, size),
);
const readMarks = (field: "centerPencilMarks" | "cornerPencilMarks") => {
const parsed = grid.map((entry, cellIndex) => {
const raw = entry[field];
if (raw === undefined || raw === null) return [];
if (!Array.isArray(raw) || raw.length > size) {
return fail(
"INVALID_FPUZZLES",
`grid[${cellIndex}].${field} must be a bounded digit array.`,
);
}
return [
...new Set(
raw.map((mark, markIndex) =>
numeric(mark, `grid[${cellIndex}].${field}[${markIndex}]`, 1, size),
),
),
].sort((a, b) => a - b);
});
return parsed.some((entry) => entry.length > 0) ? parsed : undefined;
};
const centerMarks = readMarks("centerPencilMarks");
const cornerMarks = readMarks("cornerPencilMarks");
const solution =
value.solution === undefined
? undefined
: Array.isArray(value.solution) && value.solution.length === size * size
? value.solution.map((entry, index) =>
numeric(entry, `solution[${index}]`, 1, size),
)
: fail(
"INVALID_FPUZZLES",
`solution must contain exactly ${size * size} digits.`,
);
const constraints: PortableConstraint[] = [];
if (value["diagonal+"] === true)
constraints.push({ type: "diagonal", direction: "main" });
if (value["diagonal-"] === true)
constraints.push({ type: "diagonal", direction: "anti" });
if (value.antiknight === true) constraints.push({ type: "anti-knight" });
if (value.antiking === true || value.antikingsmove === true)
constraints.push({ type: "anti-king" });
if (value.nonconsecutive === true)
constraints.push({ type: "non-consecutive" });
for (const cage of objects(value.killercage)) {
if (cage.value === undefined || cage.value === "") {
return fail(
"UNSUPPORTED_FPUZZLES",
"A killer cage without a sum cannot be imported.",
);
}
const sum = numeric(cage.value, "killercage.value", 1, size * size * size);
constraints.push({
type: "killer-cage",
cells: fpCells(cage.cells, size),
sum,
noRepeat: cage.unique !== false,
});
}
addLineConstraints(constraints, value.thermometer, size, "thermo");
addLineConstraints(constraints, value.renban, size, "renban");
addLineConstraints(constraints, value.palindrome, size, "palindrome");
for (const arrow of objects(value.arrow)) {
const bulb = fpCells(arrow.cells, size);
for (const line of lines(arrow.lines, size)) {
const path = line.filter((cell) => !bulb.includes(cell));
if (path.length === 0) {
return fail(
"INVALID_FPUZZLES",
"An arrow line must extend beyond its bulb.",
);
}
constraints.push({ type: "arrow", bulb, line: path });
}
}
for (const dot of objects(value.difference)) {
const dotCells = fpCells(dot.cells, size, 2);
if (dotCells.length !== 2)
return fail("INVALID_CELLS", "A difference dot needs two cells.");
const difference = numeric(dot.value ?? 1, "difference.value", 1, size - 1);
const [a, b] = dotCells as [number, number];
if (difference !== 1) {
return fail(
"UNSUPPORTED_FPUZZLES",
`Difference-${difference} dots are not supported; only standard white Kropki dots are available.`,
);
}
constraints.push({ type: "kropki", a, b, kind: "white" });
}
for (const dot of objects(value.ratio)) {
const dotCells = fpCells(dot.cells, size, 2);
if (dotCells.length !== 2)
return fail("INVALID_CELLS", "A ratio dot needs two cells.");
const ratio = numeric(dot.value ?? 2, "ratio.value", 2, size);
const [a, b] = dotCells as [number, number];
if (ratio !== 2) {
return fail(
"UNSUPPORTED_FPUZZLES",
`Ratio-${ratio} dots are not supported; only standard black Kropki dots are available.`,
);
}
constraints.push({ type: "kropki", a, b, kind: "black" });
}
for (const xv of objects(value.xv)) {
const xvCells = fpCells(xv.cells, size, 2);
if (xvCells.length !== 2)
return fail("INVALID_CELLS", "An XV clue needs two cells.");
const total =
typeof xv.value === "string" && xv.value.toUpperCase() === "V"
? 5
: typeof xv.value === "string" && xv.value.toUpperCase() === "X"
? 10
: numeric(xv.value, "xv.value", 1, size * 2);
if (total !== 5 && total !== 10) {
return fail(
"UNSUPPORTED_FPUZZLES",
`XV total ${total} is not supported; expected 5 or 10.`,
);
}
const [a, b] = xvCells as [number, number];
constraints.push({ type: "xv", a, b, total });
}
for (const inequality of objects(value.inequality)) {
const inequalityCells = fpCells(inequality.cells, size, 2);
if (inequalityCells.length !== 2) {
return fail("INVALID_CELLS", "An inequality needs two cells.");
}
const [a, b] = inequalityCells as [number, number];
if (inequality.value === ">")
constraints.push({ type: "inequality", lesser: b, greater: a });
else constraints.push({ type: "inequality", lesser: a, greater: b });
}
const regions = readRegions(grid, size);
const rules = parseRules(value.ruleset);
const title =
typeof value.title === "string" ? value.title.slice(0, 20_000) : undefined;
const author =
typeof value.author === "string"
? value.author.slice(0, 20_000)
: undefined;
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: SUDOKU_DOCUMENT_VERSION,
size,
givens,
values,
constraints,
...(solution === undefined ? {} : { solution }),
...(cornerMarks === undefined ? {} : { cornerMarks }),
...(centerMarks === undefined ? {} : { centerMarks }),
...(regions === undefined ? {} : { regions }),
...(rules === undefined ? {} : { rules }),
...(title === undefined ? {} : { title }),
...(author === undefined ? {} : { author }),
};
}
function constraintCells(cells: readonly number[], size: number): string[] {
return cells.map((cell) => addressFromCellIndex(cell, size));
}
export function exportFpuzzles(document: SudokuDocument): JsonRecord {
const { size } = document;
const output: JsonRecord = {
size,
grid: Array.from({ length: size }, (_, row) =>
Array.from({ length: size }, (_unused, column) => {
const index = row * size + column;
const given = document.givens[index] ?? 0;
const value = given || document.values?.[index] || 0;
return {
...(value === 0 ? {} : { value }),
...(given === 0 ? {} : { given: true }),
...((document.centerMarks ?? document.candidates)?.[index]?.length
? {
centerPencilMarks: [
...(document.centerMarks ?? document.candidates)![index]!,
],
}
: {}),
...(document.cornerMarks?.[index]?.length
? { cornerPencilMarks: [...document.cornerMarks[index]!] }
: {}),
...(document.regions === undefined || document.regions[index] === -1
? {}
: { region: document.regions[index] }),
};
}),
),
...(document.title === undefined ? {} : { title: document.title }),
...(document.author === undefined ? {} : { author: document.author }),
...(document.solution === undefined
? {}
: { solution: [...document.solution] }),
...(document.rules === undefined
? {}
: { ruleset: document.rules.join("\n") }),
};
const append = (field: string, item: JsonRecord): void => {
const collection = output[field];
if (collection === undefined) output[field] = [item];
else (collection as JsonRecord[]).push(item);
};
for (const constraint of document.constraints) {
switch (constraint.type) {
case "diagonal":
output[constraint.direction === "main" ? "diagonal+" : "diagonal-"] =
true;
break;
case "anti-knight":
output.antiknight = true;
break;
case "anti-king":
output.antikingsmove = true;
break;
case "non-consecutive":
output.nonconsecutive = true;
break;
case "killer-cage":
append("killercage", {
cells: constraintCells(constraint.cells, size),
...(constraint.sum === undefined
? {}
: { value: String(constraint.sum) }),
...(constraint.noRepeat === false ? { unique: false } : {}),
});
break;
case "thermo":
append("thermometer", {
lines: [constraintCells(constraint.cells, size)],
});
break;
case "renban":
case "palindrome":
append(constraint.type, {
lines: [constraintCells(constraint.cells, size)],
});
break;
case "arrow":
append("arrow", {
cells: constraintCells(constraint.bulb, size),
lines: [
constraintCells(
[
constraint.bulb.at(-1)!,
...constraint.line.filter(
(cell) => !constraint.bulb.includes(cell),
),
],
size,
),
],
});
break;
case "kropki":
append(constraint.kind === "white" ? "difference" : "ratio", {
cells: constraintCells([constraint.a, constraint.b], size),
value: constraint.kind === "white" ? "1" : "2",
});
break;
case "xv":
append("xv", {
cells: constraintCells([constraint.a, constraint.b], size),
value:
constraint.total === 5
? "V"
: constraint.total === 10
? "X"
: String(constraint.total),
});
break;
case "inequality":
append("inequality", {
cells: constraintCells([constraint.lesser, constraint.greater], size),
value: "<",
});
break;
}
}
if ((document.globalRules?.length ?? 0) > 0) {
return fail(
"UNSUPPORTED_FPUZZLES",
"This project contains global rules that fpuzzles export cannot preserve safely.",
);
}
return output;
}
function extractPayload(input: string): string | undefined {
const trimmed = input.trim();
if (trimmed.startsWith("{")) return undefined;
let candidate = trimmed;
if (/^https?:\/\//iu.test(trimmed)) {
let url: URL;
try {
url = new URL(trimmed);
} catch (error) {
throw new SudokuFormatError(
"INVALID_FPUZZLES_URL",
"The fpuzzles URL is invalid.",
{
cause: error,
},
);
}
const queryId =
url.searchParams.get("puzzleid") ?? url.searchParams.get("load");
candidate = queryId ?? decodeURIComponent(url.pathname.replace(/^\//u, ""));
}
if (candidate.startsWith("fpuzzles"))
return candidate.slice("fpuzzles".length);
if (/^[\w-]{1,128}$/u.test(candidate))
throw new NetworkPuzzleIdError(candidate);
return candidate;
}
function decodePayload(payload: string): string {
if (payload.length === 0 || payload.length > MAX_FPUZZLES_PAYLOAD_LENGTH) {
return fail(
"LIMIT_EXCEEDED",
"The compressed fpuzzles payload is empty or too large.",
);
}
let decodedPayload = payload;
try {
decodedPayload = decodeURIComponent(payload);
} catch {
// URLSearchParams already decodes input. Keep the original if a literal % is malformed.
}
const base64 = decompressFromBase64(decodedPayload.replaceAll(" ", "+"));
const uriEncoded =
base64 || decompressFromEncodedURIComponent(decodedPayload);
if (uriEncoded === null || uriEncoded === "") {
return fail(
"INVALID_FPUZZLES",
"The inline fpuzzles payload could not be decompressed.",
);
}
if (new TextEncoder().encode(uriEncoded).byteLength > MAX_DOCUMENT_BYTES) {
return fail(
"LIMIT_EXCEEDED",
"The decompressed fpuzzles puzzle is too large to open safely.",
);
}
return uriEncoded;
}
/** Import raw JSON, f-puzzles load URLs, or inline SudokuPad fpuzzles URLs. */
export function importFpuzzles(input: string): SudokuDocument {
const payload = extractPayload(input);
return parseFpuzzles(
boundedJson(payload === undefined ? input : decodePayload(payload)),
);
}
export function exportFpuzzlesJson(
document: SudokuDocument,
pretty = false,
): string {
const json = JSON.stringify(
exportFpuzzles(document),
null,
pretty ? 2 : undefined,
);
if (new TextEncoder().encode(json).byteLength > MAX_DOCUMENT_BYTES) {
return fail("LIMIT_EXCEEDED", "The exported fpuzzles JSON is too large.");
}
return json;
}
export function exportFpuzzlesPayload(document: SudokuDocument): string {
const payload = compressToBase64(exportFpuzzlesJson(document));
if (payload.length > MAX_FPUZZLES_PAYLOAD_LENGTH) {
return fail(
"LIMIT_EXCEEDED",
"The compressed fpuzzles payload is too large.",
);
}
return encodeURIComponent(payload);
}
export function exportFpuzzlesUrl(
document: SudokuDocument,
baseUrl = "https://sudokupad.app/",
): string {
const url = new URL(baseUrl);
url.searchParams.set(
"puzzleid",
`fpuzzles${decodeURIComponent(exportFpuzzlesPayload(document))}`,
);
return url.toString();
}
+105
View File
@@ -0,0 +1,105 @@
import { MAX_BOARD_SIZE, MIN_BOARD_SIZE, SudokuFormatError } from "./document";
import {
SUDOKU_DOCUMENT_SCHEMA,
SUDOKU_DOCUMENT_VERSION,
type SudokuDocument,
} from "./types";
const SEPARATORS = /[\s|+-]/gu;
function decodeSymbol(symbol: string): number {
if (symbol === "." || symbol === "0") return 0;
if (symbol >= "1" && symbol <= "9") return Number(symbol);
const code = symbol.toUpperCase().charCodeAt(0);
if (code >= 65 && code <= 80) return code - 55;
throw new SudokuFormatError(
"INVALID_GRID_SYMBOL",
`Unsupported grid symbol ${JSON.stringify(symbol)}. Use 0 or . for an empty cell.`,
);
}
function encodeSymbol(value: number): string {
if (value === 0) return ".";
if (value <= 9) return String(value);
return String.fromCharCode(value + 55);
}
export interface PlainGridOptions {
readonly size?: number;
readonly title?: string;
readonly author?: string;
}
export function parsePlainGrid(
input: string,
options: PlainGridOptions = {},
): SudokuDocument {
const compact = input.replace(/^\uFEFF/u, "").replace(SEPARATORS, "");
const inferred = Math.sqrt(compact.length);
const size = options.size ?? inferred;
if (
!Number.isInteger(size) ||
size < MIN_BOARD_SIZE ||
size > MAX_BOARD_SIZE
) {
throw new SudokuFormatError(
"INVALID_GRID_SIZE",
`The grid must have a square number of cells and a side length from ${MIN_BOARD_SIZE} to ${MAX_BOARD_SIZE}.`,
);
}
if (compact.length !== size * size) {
throw new SudokuFormatError(
"INVALID_GRID_LENGTH",
`Expected ${size * size} cell symbols for a ${size}×${size} grid, but found ${compact.length}.`,
);
}
const givens = [...compact].map((symbol, index) => {
const value = decodeSymbol(symbol);
if (value > size) {
throw new SudokuFormatError(
"INVALID_GRID_SYMBOL",
`Cell ${index + 1} contains ${symbol}, which is outside 1${size}.`,
);
}
return value;
});
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: SUDOKU_DOCUMENT_VERSION,
size,
givens,
constraints: [],
...(options.title === undefined ? {} : { title: options.title }),
...(options.author === undefined ? {} : { author: options.author }),
};
}
export function serializePlainGrid(
document: Pick<SudokuDocument, "size" | "givens" | "values">,
source: "givens" | "values" = "givens",
): string {
const values = source === "values" ? document.values : document.givens;
if (values === undefined) {
throw new SudokuFormatError(
"MISSING_GRID",
"This puzzle has no current values to export.",
);
}
if (values.length !== document.size * document.size) {
throw new SudokuFormatError(
"INVALID_GRID_LENGTH",
"The grid does not match the puzzle size.",
);
}
return values
.map((value, index) => {
if (!Number.isInteger(value) || value < 0 || value > document.size) {
throw new SudokuFormatError(
"INVALID_GRID_SYMBOL",
`Cell ${index + 1} is out of range.`,
);
}
return encodeSymbol(value);
})
.join("");
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./document";
export * from "./fpuzzles";
export * from "./grid";
export * from "./share";
export * from "./types";
+66
View File
@@ -0,0 +1,66 @@
import {
compressToEncodedURIComponent,
decompressFromEncodedURIComponent,
} from "lz-string";
import {
MAX_DOCUMENT_BYTES,
SudokuFormatError,
parseSudokuDocument,
serializeSudokuDocument,
} from "./document";
import type { SudokuDocument } from "./types";
export const PUZZLE_HASH_PREFIX = "#sudoku=v1.";
export const MAX_SHARE_HASH_LENGTH = 131_072;
export function encodePuzzleHash(document: SudokuDocument): string {
const payload = compressToEncodedURIComponent(
serializeSudokuDocument(document),
);
const hash = `${PUZZLE_HASH_PREFIX}${payload}`;
if (hash.length > MAX_SHARE_HASH_LENGTH) {
throw new SudokuFormatError(
"LIMIT_EXCEEDED",
`The compressed puzzle exceeds the ${MAX_SHARE_HASH_LENGTH.toLocaleString()} character share limit.`,
);
}
return hash;
}
export function decodePuzzleHash(hashOrUrl: string): SudokuDocument {
let hash = hashOrUrl.trim();
try {
if (/^[a-z][a-z\d+.-]*:\/\//iu.test(hash)) hash = new URL(hash).hash;
} catch (error) {
throw new SudokuFormatError("INVALID_SHARE", "The share URL is invalid.", {
cause: error,
});
}
if (!hash.startsWith(PUZZLE_HASH_PREFIX)) {
throw new SudokuFormatError(
"INVALID_SHARE",
`Expected a hash beginning with ${PUZZLE_HASH_PREFIX}.`,
);
}
if (hash.length > MAX_SHARE_HASH_LENGTH) {
throw new SudokuFormatError(
"LIMIT_EXCEEDED",
"The share hash is too large to open safely.",
);
}
const compressed = hash.slice(PUZZLE_HASH_PREFIX.length);
const json = decompressFromEncodedURIComponent(compressed);
if (json === null || json === "") {
throw new SudokuFormatError(
"INVALID_SHARE",
"The share payload could not be decompressed.",
);
}
if (new TextEncoder().encode(json).byteLength > MAX_DOCUMENT_BYTES) {
throw new SudokuFormatError(
"LIMIT_EXCEEDED",
"The decompressed puzzle is too large to open safely.",
);
}
return parseSudokuDocument(json);
}
+148
View File
@@ -0,0 +1,148 @@
export const SUDOKU_DOCUMENT_SCHEMA =
"de.add-ideas.sudoku-tools.puzzle" as const;
export const SUDOKU_DOCUMENT_VERSION = 1 as const;
export type CellIndex = number;
export type PortableConstraint =
| { readonly type: "diagonal"; readonly direction: "main" | "anti" }
| { readonly type: "anti-knight" }
| { readonly type: "anti-king" }
| { readonly type: "non-consecutive" }
| {
readonly type: "killer-cage";
readonly cells: readonly CellIndex[];
readonly sum: number;
readonly noRepeat?: boolean;
}
| { readonly type: "thermo"; readonly cells: readonly CellIndex[] }
| {
readonly type: "arrow";
readonly bulb: readonly CellIndex[];
readonly line: readonly CellIndex[];
}
| {
readonly type: "kropki";
readonly a: CellIndex;
readonly b: CellIndex;
readonly kind: "white" | "black";
}
| {
readonly type: "xv";
readonly a: CellIndex;
readonly b: CellIndex;
readonly total: 5 | 10;
}
| {
readonly type: "inequality";
readonly lesser: CellIndex;
readonly greater: CellIndex;
}
| { readonly type: "renban"; readonly cells: readonly CellIndex[] }
| { readonly type: "palindrome"; readonly cells: readonly CellIndex[] };
/**
* Stable, versioned interchange format owned by Sudoku Tools. Cell indices are
* row-major and zero based. Zero denotes an empty grid value.
*/
export interface SudokuDocument {
readonly schema: typeof SUDOKU_DOCUMENT_SCHEMA;
readonly version: typeof SUDOKU_DOCUMENT_VERSION;
readonly size: number;
readonly givens: readonly number[];
readonly values?: readonly number[];
readonly cornerMarks?: readonly (readonly number[])[];
readonly centerMarks?: readonly (readonly number[])[];
/** Legacy v1 alias for centre marks. */
readonly candidates?: readonly (readonly number[])[];
readonly colors?: readonly number[];
readonly elapsedMs?: number;
readonly solution?: readonly number[];
readonly regions?: readonly number[];
readonly constraints: readonly PortableConstraint[];
readonly title?: string;
readonly author?: string;
readonly rules?: readonly string[];
/** Named boolean/global rules which cannot be represented by a local shape. */
readonly globalRules?: readonly string[];
readonly id?: string;
}
/** Minimal structural type accepted by the domain adapter. */
export interface DomainPuzzleShape {
readonly version: 1;
readonly size: number;
readonly givens: readonly number[];
readonly regions?: readonly number[];
readonly constraints?: readonly PortableConstraint[];
readonly title?: string;
readonly author?: string;
readonly id?: string;
readonly rules?: string;
readonly solution?: readonly number[];
}
export function toDomainPuzzle(document: SudokuDocument): DomainPuzzleShape {
if ((document.globalRules?.length ?? 0) > 0) {
throw new Error(
"This document contains global rules that the current puzzle engine cannot enforce.",
);
}
return {
version: 1,
size: document.size,
givens: [...document.givens],
...(document.regions === undefined
? {}
: { regions: [...document.regions] }),
constraints: document.constraints.map(cloneConstraint),
...(document.title === undefined ? {} : { title: document.title }),
...(document.author === undefined ? {} : { author: document.author }),
...(document.id === undefined ? {} : { id: document.id }),
...(document.rules === undefined
? {}
: { rules: document.rules.join("\n") }),
...(document.solution === undefined
? {}
: { solution: [...document.solution] }),
};
}
export function fromDomainPuzzle(puzzle: DomainPuzzleShape): SudokuDocument {
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: SUDOKU_DOCUMENT_VERSION,
size: puzzle.size,
givens: [...puzzle.givens],
constraints: (puzzle.constraints ?? []).map(cloneConstraint),
...(puzzle.regions === undefined ? {} : { regions: [...puzzle.regions] }),
...(puzzle.title === undefined ? {} : { title: puzzle.title }),
...(puzzle.author === undefined ? {} : { author: puzzle.author }),
...(puzzle.id === undefined ? {} : { id: puzzle.id }),
...(puzzle.rules === undefined ? {} : { rules: [puzzle.rules] }),
...(puzzle.solution === undefined
? {}
: { solution: [...puzzle.solution] }),
};
}
export function cloneConstraint(
constraint: PortableConstraint,
): PortableConstraint {
switch (constraint.type) {
case "killer-cage":
return { ...constraint, cells: [...constraint.cells] };
case "thermo":
case "renban":
case "palindrome":
return { ...constraint, cells: [...constraint.cells] };
case "arrow":
return {
...constraint,
bulb: [...constraint.bulb],
line: [...constraint.line],
};
default:
return { ...constraint };
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./killer";
export * from "./relations";
export * from "./residual";
+252
View File
@@ -0,0 +1,252 @@
export const MAX_KILLER_COMBINATIONS = 20_000;
export const MAX_KILLER_ASSIGNMENTS = 50_000;
export interface KillerCombinationOptions {
readonly cellCount: number;
readonly sum: number;
readonly size?: number;
readonly allowedDigits?: readonly number[];
readonly requiredDigits?: readonly number[];
readonly excludedDigits?: readonly number[];
readonly allowRepeats?: boolean;
/** Candidate digits for each cage cell, in cell order. */
readonly candidates?: readonly (readonly number[])[];
readonly maxCombinations?: number;
readonly maxAssignments?: number;
}
export interface KillerAnalysis {
/** Sorted multisets. Each logical combination occurs at most once. */
readonly combinations: readonly (readonly number[])[];
/** Candidate-compatible, cell-ordered assignments (bounded). */
readonly assignments: readonly (readonly number[])[];
readonly possibleDigits: readonly number[];
/** Digits present in every feasible combination. */
readonly necessaryDigits: readonly number[];
readonly possibleByCell: readonly (readonly number[])[];
readonly truncated: boolean;
}
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 uniqueDigits(
values: readonly number[],
size: number,
label: string,
): number[] {
const output = [...new Set(values)];
for (const value of output) boundedInteger(value, label, 1, size);
return output.sort((a, b) => a - b);
}
function intersection(
left: ReadonlySet<number>,
right: ReadonlySet<number>,
): Set<number> {
return new Set([...left].filter((value) => right.has(value)));
}
/**
* Enumerate killer sums without permutations, optionally filter them through
* per-cell candidates, and derive digits that are possible/necessary.
*/
export function analyzeKillerCage(
options: KillerCombinationOptions,
): KillerAnalysis {
const size = boundedInteger(options.size ?? 9, "size", 1, 25);
const cellCount = boundedInteger(options.cellCount, "cellCount", 1, 25);
const sum = boundedInteger(options.sum, "sum", 1, size * cellCount);
const allowRepeats = options.allowRepeats ?? false;
if (!allowRepeats && cellCount > size) {
return {
combinations: [],
assignments: [],
possibleDigits: [],
necessaryDigits: [],
possibleByCell: Array.from({ length: cellCount }, () => []),
truncated: false,
};
}
const excluded = new Set(
uniqueDigits(options.excludedDigits ?? [], size, "excluded digit"),
);
const allowed = uniqueDigits(
options.allowedDigits ??
Array.from({ length: size }, (_, index) => index + 1),
size,
"allowed digit",
).filter((digit) => !excluded.has(digit));
const required = uniqueDigits(
options.requiredDigits ?? [],
size,
"required digit",
);
if (required.some((digit) => !allowed.includes(digit))) {
return {
combinations: [],
assignments: [],
possibleDigits: [],
necessaryDigits: [],
possibleByCell: Array.from({ length: cellCount }, () => []),
truncated: false,
};
}
if (!allowRepeats && required.length > cellCount) {
return {
combinations: [],
assignments: [],
possibleDigits: [],
necessaryDigits: [],
possibleByCell: Array.from({ length: cellCount }, () => []),
truncated: false,
};
}
let candidateSets: Set<number>[] | undefined;
if (options.candidates !== undefined) {
if (options.candidates.length !== cellCount) {
throw new RangeError(
`candidates must contain exactly ${cellCount} cell entries.`,
);
}
candidateSets = options.candidates.map(
(entry) =>
new Set(
uniqueDigits(entry, size, "candidate").filter((digit) =>
allowed.includes(digit),
),
),
);
}
const maxCombinations = boundedInteger(
options.maxCombinations ?? 5_000,
"maxCombinations",
1,
MAX_KILLER_COMBINATIONS,
);
const maxAssignments = boundedInteger(
options.maxAssignments ?? 10_000,
"maxAssignments",
1,
MAX_KILLER_ASSIGNMENTS,
);
const combinations: number[][] = [];
const assignments: number[][] = [];
const possibleByCell = Array.from(
{ length: cellCount },
() => new Set<number>(),
);
let necessary: Set<number> | undefined;
let truncated = false;
const compatibleAssignments = (
combination: readonly number[],
): number[][] => {
if (candidateSets === undefined) return [[...combination]];
const counts = new Map<number, number>();
for (const digit of combination)
counts.set(digit, (counts.get(digit) ?? 0) + 1);
const result: number[][] = [];
const current = Array<number>(cellCount).fill(0);
const visit = (cellPosition: number): void => {
if (assignments.length + result.length >= maxAssignments) {
truncated = true;
return;
}
if (cellPosition === cellCount) {
result.push([...current]);
return;
}
const candidates = candidateSets[cellPosition];
if (candidates === undefined) return;
for (const digit of [...counts.keys()].sort((a, b) => a - b)) {
const remaining = counts.get(digit) ?? 0;
if (remaining === 0 || !candidates.has(digit)) continue;
counts.set(digit, remaining - 1);
current[cellPosition] = digit;
visit(cellPosition + 1);
counts.set(digit, remaining);
if (truncated) return;
}
};
visit(0);
return result;
};
const accept = (combination: readonly number[]): void => {
if (!required.every((digit) => combination.includes(digit))) return;
const feasible = compatibleAssignments(combination);
if (feasible.length === 0) return;
combinations.push([...combination]);
const digitSet = new Set(combination);
necessary =
necessary === undefined ? digitSet : intersection(necessary, digitSet);
if (candidateSets === undefined) {
for (const possible of possibleByCell)
for (const digit of digitSet) possible.add(digit);
} else {
for (const assignment of feasible) {
for (const [position, digit] of assignment.entries())
possibleByCell[position]?.add(digit);
}
}
assignments.push(...feasible);
};
const current: number[] = [];
const search = (start: number, currentSum: number): void => {
if (truncated) return;
const remaining = cellCount - current.length;
if (remaining === 0) {
if (currentSum === sum) accept(current);
if (combinations.length >= maxCombinations) truncated = true;
return;
}
if (currentSum >= sum || allowed.length === 0) return;
for (let index = start; index < allowed.length; index += 1) {
const digit = allowed[index];
if (digit === undefined) continue;
const nextSum = currentSum + digit;
if (nextSum > sum) break;
current.push(digit);
search(allowRepeats ? index : index + 1, nextSum);
current.pop();
if (truncated) return;
}
};
search(0, 0);
const possibleDigits = [...new Set(combinations.flat())].sort(
(a, b) => a - b,
);
return {
combinations,
assignments,
possibleDigits,
necessaryDigits: [...(necessary ?? [])].sort((a, b) => a - b),
possibleByCell: possibleByCell.map((entry) =>
[...entry].sort((a, b) => a - b),
),
truncated,
};
}
export function calculateKillerCombinations(
options: Omit<KillerCombinationOptions, "candidates">,
): readonly (readonly number[])[] {
return analyzeKillerCage(options).combinations;
}
+100
View File
@@ -0,0 +1,100 @@
export type DigitPair = readonly [number, number];
export type RelationSpec =
| { readonly type: "kropki"; readonly kind: "white" | "black" }
| { readonly type: "difference"; readonly difference: number }
| { readonly type: "ratio"; readonly ratio: number }
| { readonly type: "xv"; readonly total: number }
| { readonly type: "inequality"; readonly relation: "<" | ">" };
function digits(size: number, candidates?: readonly number[]): number[] {
if (!Number.isInteger(size) || size < 1 || size > 25) {
throw new RangeError("size must be an integer from 1 to 25.");
}
const result = [
...new Set(
candidates ?? Array.from({ length: size }, (_, index) => index + 1),
),
];
if (
result.some(
(digit) => !Number.isInteger(digit) || digit < 1 || digit > size,
)
) {
throw new RangeError(`Candidates must be digits from 1 to ${size}.`);
}
return result.sort((a, b) => a - b);
}
/** Ordered pairs: the first tuple member belongs to the first selected cell. */
export function relationPairs(
specification: RelationSpec,
size = 9,
firstCandidates?: readonly number[],
secondCandidates?: readonly number[],
): readonly DigitPair[] {
const first = digits(size, firstCandidates);
const second = digits(size, secondCandidates);
const accepts = (a: number, b: number): boolean => {
switch (specification.type) {
case "kropki":
return specification.kind === "white"
? Math.abs(a - b) === 1
: a === b * 2 || b === a * 2;
case "difference":
return Math.abs(a - b) === specification.difference;
case "ratio":
return a === b * specification.ratio || b === a * specification.ratio;
case "xv":
return a + b === specification.total;
case "inequality":
return specification.relation === "<" ? a < b : a > b;
}
};
const output: DigitPair[] = [];
for (const a of first)
for (const b of second) if (accepts(a, b)) output.push([a, b]);
return output;
}
export function kropkiPairs(
kind: "white" | "black",
size = 9,
firstCandidates?: readonly number[],
secondCandidates?: readonly number[],
): readonly DigitPair[] {
return relationPairs(
{ type: "kropki", kind },
size,
firstCandidates,
secondCandidates,
);
}
export function xvPairs(
total: number,
size = 9,
firstCandidates?: readonly number[],
secondCandidates?: readonly number[],
): readonly DigitPair[] {
return relationPairs(
{ type: "xv", total },
size,
firstCandidates,
secondCandidates,
);
}
export function inequalityPairs(
relation: "<" | ">",
size = 9,
firstCandidates?: readonly number[],
secondCandidates?: readonly number[],
): readonly DigitPair[] {
return relationPairs(
{ type: "inequality", relation },
size,
firstCandidates,
secondCandidates,
);
}
+158
View File
@@ -0,0 +1,158 @@
import { analyzeKillerCage, type KillerAnalysis } from "./killer";
export interface ResidualOptions {
readonly total?: number;
readonly knownValues?: readonly number[];
readonly knownSums?: readonly number[];
readonly unknownCount?: number;
readonly size?: number;
readonly allowedDigits?: readonly number[];
readonly allowRepeats?: boolean;
}
export interface ResidualResult {
readonly total: number;
readonly accounted: number;
readonly residual: number;
readonly unknownCount?: number;
readonly analysis?: KillerAnalysis;
}
/** Generic arithmetic behind the 45 rule (also useful for non-9×9 boards). */
export function calculateResidual(options: ResidualOptions): ResidualResult {
const size = options.size ?? 9;
if (!Number.isInteger(size) || size < 1 || size > 25)
throw new RangeError("Invalid size.");
const total = options.total ?? (size * (size + 1)) / 2;
const parts = [...(options.knownValues ?? []), ...(options.knownSums ?? [])];
if (!Number.isFinite(total) || parts.some((part) => !Number.isFinite(part))) {
throw new RangeError("Totals and accounted parts must be finite numbers.");
}
const accounted = parts.reduce((sum, part) => sum + part, 0);
const residual = total - accounted;
if (options.unknownCount === undefined) return { total, accounted, residual };
if (
!Number.isInteger(options.unknownCount) ||
options.unknownCount < 0 ||
options.unknownCount > 25
) {
throw new RangeError("unknownCount must be an integer from 0 to 25.");
}
if (options.unknownCount === 0) {
return { total, accounted, residual, unknownCount: 0 };
}
return {
total,
accounted,
residual,
unknownCount: options.unknownCount,
analysis:
residual >= options.unknownCount &&
residual <= size * options.unknownCount
? analyzeKillerCage({
cellCount: options.unknownCount,
sum: residual,
size,
...(options.allowedDigits === undefined
? {}
: { allowedDigits: options.allowedDigits }),
...(options.allowRepeats === undefined
? {}
: { allowRepeats: options.allowRepeats }),
})
: {
combinations: [],
assignments: [],
possibleDigits: [],
necessaryDigits: [],
possibleByCell: Array.from(
{ length: options.unknownCount },
() => [],
),
truncated: false,
},
};
}
export interface FortyFiveCage {
readonly cells: readonly number[];
readonly sum: number;
}
export interface FortyFiveRuleOptions {
readonly unitCells: readonly number[];
readonly cages?: readonly FortyFiveCage[];
readonly knownValues?: Readonly<Record<number, number>>;
readonly size?: number;
}
export interface FortyFiveRuleResult extends ResidualResult {
readonly accountedCells: readonly number[];
readonly residualCells: readonly number[];
readonly crossingCages: readonly FortyFiveCage[];
}
/**
* Subtract complete cages and uncovered solved cells from a Sudoku house. Cages
* crossing the boundary are reported but deliberately not subtracted.
*/
export function fortyFiveRuleResidual(
options: FortyFiveRuleOptions,
): FortyFiveRuleResult {
const size = options.size ?? 9;
const unit = [...new Set(options.unitCells)];
if (unit.length !== options.unitCells.length)
throw new RangeError("unitCells contains duplicates.");
if (
unit.some(
(cell) => !Number.isInteger(cell) || cell < 0 || cell >= size * size,
)
) {
throw new RangeError("unitCells contains a cell outside the board.");
}
const unitSet = new Set(unit);
const accountedCells = new Set<number>();
const cageSums: number[] = [];
const crossingCages: FortyFiveCage[] = [];
for (const cage of options.cages ?? []) {
const contained = cage.cells.every((cell) => unitSet.has(cell));
const touches = cage.cells.some((cell) => unitSet.has(cell));
if (touches && !contained) {
crossingCages.push({ cells: [...cage.cells], sum: cage.sum });
continue;
}
if (!contained) continue;
if (!Number.isFinite(cage.sum))
throw new RangeError("A cage sum is not finite.");
for (const cell of cage.cells) {
if (accountedCells.has(cell))
throw new RangeError("Complete cages overlap inside the unit.");
accountedCells.add(cell);
}
cageSums.push(cage.sum);
}
const knownValues: number[] = [];
for (const cell of unit) {
if (accountedCells.has(cell)) continue;
const value = options.knownValues?.[cell];
if (value === undefined || value === 0) continue;
if (!Number.isInteger(value) || value < 1 || value > size) {
throw new RangeError(`Known value for cell ${cell} is out of range.`);
}
accountedCells.add(cell);
knownValues.push(value);
}
const residualCells = unit.filter((cell) => !accountedCells.has(cell));
const residual = calculateResidual({
size,
knownSums: cageSums,
knownValues,
unknownCount: residualCells.length,
});
return {
...residual,
accountedCells: [...accountedCells].sort((a, b) => a - b),
residualCells,
crossingCages,
};
}
+9
View File
@@ -0,0 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+187
View File
@@ -0,0 +1,187 @@
import {
PuzzleValidationError,
candidatesForCell,
compilePuzzle,
findConflicts,
normalizePuzzle,
type NormalizedPuzzle,
type PuzzleDefinition,
type ValidationIssue,
} from "../domain";
import { seededRandom, shuffled, type RandomSource } from "./random";
export interface ExactSolveOptions {
readonly values?: readonly number[];
/** Stop after this many solutions. Defaults to 2 so uniqueness can be tested. */
readonly maxSolutions?: number;
readonly maxNodes?: number;
readonly timeoutMs?: number;
/** Randomizes equal choices deterministically when supplied. */
readonly seed?: string | number;
}
export type ExactLimitReason = "solution-cap" | "node-cap" | "timeout";
export interface ExactSolveResult {
readonly solutions: readonly (readonly number[])[];
readonly count: number;
readonly truncated: boolean;
readonly limitReason?: ExactLimitReason;
readonly nodes: number;
readonly elapsedMs: number;
}
function boundedInteger(
value: number | undefined,
fallback: number,
minimum: number,
maximum: number,
name: string,
): number {
const result = value ?? fallback;
if (!Number.isInteger(result) || result < minimum || result > maximum) {
throw new RangeError(
`${name} must be an integer from ${minimum} to ${maximum}.`,
);
}
return result;
}
function validateStart(
normalized: NormalizedPuzzle,
values: readonly number[],
): void {
const issues: ValidationIssue[] = [];
if (values.length !== normalized.size * normalized.size) {
issues.push({
path: "values",
message: `must contain exactly ${normalized.size ** 2} values`,
});
} else {
values.forEach((value, cell) => {
if (!Number.isInteger(value) || value < 0 || value > normalized.size) {
issues.push({
path: `values[${cell}]`,
message: `must be an integer from 0 to ${normalized.size}`,
});
}
const given = normalized.givens[cell] ?? 0;
if (given !== 0 && value !== given) {
issues.push({
path: `values[${cell}]`,
message: "must preserve the given value",
});
}
});
}
if (issues.length === 0) {
for (const conflict of findConflicts(compilePuzzle(normalized), values)) {
issues.push({ path: "values", message: conflict.message });
}
}
if (issues.length > 0) throw new PuzzleValidationError(issues);
}
export function solveExact(
puzzle: PuzzleDefinition | NormalizedPuzzle,
options: ExactSolveOptions = {},
): ExactSolveResult {
const normalized = normalizePuzzle(puzzle);
const board = [...(options.values ?? normalized.givens)];
validateStart(normalized, board);
const maxSolutions = boundedInteger(
options.maxSolutions,
2,
1,
100,
"maxSolutions",
);
const maxNodes = boundedInteger(
options.maxNodes,
2_000_000,
1,
100_000_000,
"maxNodes",
);
const timeoutMs = boundedInteger(
options.timeoutMs,
10_000,
1,
120_000,
"timeoutMs",
);
const compiled = compilePuzzle(normalized);
const random: RandomSource | undefined =
options.seed === undefined ? undefined : seededRandom(options.seed);
const solutions: number[][] = [];
const started = Date.now();
let nodes = 0;
let limitReason: ExactLimitReason | undefined;
const search = (): void => {
if (solutions.length >= maxSolutions) {
limitReason = "solution-cap";
return;
}
if (nodes >= maxNodes) {
limitReason = "node-cap";
return;
}
if (Date.now() - started >= timeoutMs) {
limitReason = "timeout";
return;
}
nodes += 1;
const tied: Array<readonly [number, number[]]> = [];
let minimum = normalized.size + 1;
for (let cell = 0; cell < board.length; cell += 1) {
if (board[cell] !== 0) continue;
const candidates = candidatesForCell(compiled, board, cell);
if (candidates.length === 0) return;
if (candidates.length < minimum) {
minimum = candidates.length;
tied.length = 0;
tied.push([cell, candidates]);
} else if (candidates.length === minimum) {
tied.push([cell, candidates]);
}
if (minimum === 1 && random === undefined) break;
}
if (tied.length === 0) {
solutions.push([...board]);
return;
}
const selection =
random === undefined ? tied[0] : tied[Math.floor(random() * tied.length)];
if (selection === undefined) return;
const chosen = selection[0];
let choices = selection[1];
if (random !== undefined) choices = shuffled(choices, random);
for (const value of choices) {
board[chosen] = value;
search();
board[chosen] = 0;
if (limitReason !== undefined) return;
}
};
search();
const result: ExactSolveResult = {
solutions,
count: solutions.length,
truncated: limitReason !== undefined,
nodes,
elapsedMs: Date.now() - started,
...(limitReason === undefined ? {} : { limitReason }),
};
return result;
}
export function countSolutions(
puzzle: PuzzleDefinition | NormalizedPuzzle,
options: Omit<ExactSolveOptions, "maxSolutions"> & {
readonly maxSolutions?: number;
} = {},
): number {
return solveExact(puzzle, options).count;
}
+207
View File
@@ -0,0 +1,207 @@
import {
classicRegions,
normalizePuzzle,
type NormalizedPuzzle,
type PuzzleDefinition,
} from "../domain";
import { solveExact } from "./exact";
import { seededRandom, shuffled } from "./random";
export type ClueSymmetry = "none" | "rotational";
export interface MinimizeOptions {
readonly seed?: string | number;
readonly targetClues?: number;
readonly symmetry?: ClueSymmetry;
readonly maxChecks?: number;
readonly solveMaxNodes?: number;
readonly solveTimeoutMs?: number;
}
export interface GenerateClassicOptions extends MinimizeOptions {
readonly size?: number;
readonly boxRows?: number;
readonly boxColumns?: number;
}
function factors(
size: number,
requestedRows?: number,
requestedColumns?: number,
): readonly [number, number] {
let rows = requestedRows;
let columns = requestedColumns;
if (rows === undefined && columns === undefined) {
rows = Math.floor(Math.sqrt(size));
while (rows > 1 && size % rows !== 0) rows -= 1;
columns = size / rows;
} else if (
rows === undefined &&
columns !== undefined &&
size % columns === 0
) {
rows = size / columns;
} else if (columns === undefined && rows !== undefined && size % rows === 0) {
columns = size / rows;
}
if (
rows === undefined ||
columns === undefined ||
!Number.isInteger(rows) ||
!Number.isInteger(columns) ||
rows * columns !== size
) {
throw new RangeError(
"boxRows and boxColumns must be factors whose product is size",
);
}
return [rows, columns];
}
function fullClassicGrid(
size: number,
boxRows: number,
boxColumns: number,
seed: string | number,
): number[] {
const random = seededRandom(seed);
const digits = shuffled(
Array.from({ length: size }, (_, index) => index + 1),
random,
);
const bandOrder = shuffled(
Array.from({ length: size / boxRows }, (_, index) => index),
random,
);
const rowOrder = bandOrder.flatMap((band) =>
shuffled(
Array.from({ length: boxRows }, (_, offset) => band * boxRows + offset),
random,
),
);
const stackOrder = shuffled(
Array.from({ length: size / boxColumns }, (_, index) => index),
random,
);
const columnOrder = stackOrder.flatMap((stack) =>
shuffled(
Array.from(
{ length: boxColumns },
(_, offset) => stack * boxColumns + offset,
),
random,
),
);
return rowOrder.flatMap((row) =>
columnOrder.map((column) => {
const pattern =
(row * boxColumns + Math.floor(row / boxRows) + column) % size;
return digits[pattern] as number;
}),
);
}
function boundedOption(
value: number | undefined,
fallback: number,
minimum: number,
maximum: number,
name: string,
): number {
const result = value ?? fallback;
if (!Number.isInteger(result) || result < minimum || result > maximum) {
throw new RangeError(
`${name} must be an integer from ${minimum} to ${maximum}`,
);
}
return result;
}
export function minimizePuzzle(
puzzle: PuzzleDefinition | NormalizedPuzzle,
options: MinimizeOptions = {},
): NormalizedPuzzle {
const normalized = normalizePuzzle(puzzle);
const targetClues = boundedOption(
options.targetClues,
Math.max(
normalized.size,
Math.round(normalized.size * normalized.size * 0.38),
),
0,
normalized.size * normalized.size,
"targetClues",
);
const maxChecks = boundedOption(
options.maxChecks,
normalized.size * normalized.size * 2,
1,
normalized.size * normalized.size * 10,
"maxChecks",
);
const symmetry = options.symmetry ?? "rotational";
if (symmetry !== "none" && symmetry !== "rotational") {
throw new RangeError('symmetry must be "none" or "rotational"');
}
const random = seededRandom(options.seed ?? "sudoku-tools");
const givens = [...normalized.givens];
const countClues = (): number =>
givens.reduce((count, value) => count + (value === 0 ? 0 : 1), 0);
const order = shuffled(
Array.from({ length: givens.length }, (_, cell) => cell),
random,
);
let checks = 0;
for (const cell of order) {
if (checks >= maxChecks || countClues() <= targetClues) break;
if (givens[cell] === 0) continue;
const mirror = givens.length - cell - 1;
const group =
symmetry === "rotational" && mirror !== cell ? [cell, mirror] : [cell];
if (group.some((entry) => givens[entry] === 0)) continue;
if (countClues() - group.length < targetClues) continue;
const saved = group.map((entry) => givens[entry] ?? 0);
group.forEach((entry) => {
givens[entry] = 0;
});
checks += 1;
const candidate: PuzzleDefinition = {
...normalized,
givens,
solution: normalized.solution,
};
const result = solveExact(candidate, {
maxSolutions: 2,
maxNodes: options.solveMaxNodes ?? 2_000_000,
timeoutMs: options.solveTimeoutMs ?? 10_000,
});
if (result.count !== 1 || result.truncated) {
group.forEach((entry, index) => {
givens[entry] = saved[index] ?? 0;
});
}
}
return normalizePuzzle({ ...normalized, givens });
}
export function generateClassic(
options: GenerateClassicOptions = {},
): NormalizedPuzzle {
const size = boundedOption(options.size, 9, 4, 16, "size");
const [boxRows, boxColumns] = factors(
size,
options.boxRows,
options.boxColumns,
);
const seed = options.seed ?? "sudoku-tools";
const solution = fullClassicGrid(size, boxRows, boxColumns, seed);
const full = normalizePuzzle({
version: 1,
size,
givens: solution,
solution,
regions: classicRegions(size, boxRows, boxColumns),
constraints: [],
});
return minimizePuzzle(full, options);
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./exact";
export * from "./generator";
export * from "./killer";
export * from "./logical";
+79
View File
@@ -0,0 +1,79 @@
export interface KillerCombinationOptions {
readonly size: number;
readonly count: number;
readonly sum: number;
readonly noRepeat?: boolean;
readonly usedDigits?: readonly number[];
readonly maxResults?: number;
}
export interface KillerCombinationResult {
readonly combinations: readonly (readonly number[])[];
readonly truncated: boolean;
}
export function killerDigitCombinations(
options: KillerCombinationOptions,
): KillerCombinationResult {
const { size, count, sum } = options;
if (!Number.isInteger(size) || size < 4 || size > 16) {
throw new RangeError("size must be an integer from 4 to 16");
}
if (!Number.isInteger(count) || count < 1 || count > size) {
throw new RangeError("count must be an integer from 1 to size");
}
if (!Number.isInteger(sum) || sum < count || sum > count * size) {
throw new RangeError("sum is outside the possible range");
}
const maxResults = options.maxResults ?? 10_000;
if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > 100_000) {
throw new RangeError("maxResults must be an integer from 1 to 100000");
}
const noRepeat = options.noRepeat !== false;
const usedDigits = new Set(options.usedDigits ?? []);
for (const digit of usedDigits) {
if (!Number.isInteger(digit) || digit < 1 || digit > size) {
throw new RangeError("usedDigits contains an out-of-range digit");
}
}
const combinations: number[][] = [];
let truncated = false;
const current: number[] = [];
const visit = (
remainingCount: number,
remainingSum: number,
minimum: number,
): void => {
if (combinations.length >= maxResults) {
truncated = true;
return;
}
if (remainingCount === 0) {
if (remainingSum === 0) combinations.push([...current]);
return;
}
const lowestPossible = noRepeat
? (remainingCount * (2 * minimum + remainingCount - 1)) / 2
: remainingCount * minimum;
const highestPossible = noRepeat
? (remainingCount * (2 * size - remainingCount + 1)) / 2
: remainingCount * size;
if (remainingSum < lowestPossible || remainingSum > highestPossible) return;
for (let digit = minimum; digit <= size; digit += 1) {
if (usedDigits.has(digit)) continue;
if (digit > remainingSum) break;
current.push(digit);
visit(
remainingCount - 1,
remainingSum - digit,
noRepeat ? digit + 1 : digit,
);
current.pop();
if (truncated) return;
}
};
visit(count, sum, 1);
return { combinations, truncated };
}
+728
View File
@@ -0,0 +1,728 @@
import {
PuzzleValidationError,
candidatesForCell,
compilePuzzle,
findConflicts,
isSolved,
normalizePuzzle,
type CellId,
type CompiledPuzzle,
type NormalizedPuzzle,
type PuzzleDefinition,
type SudokuUnit,
type ValidationIssue,
} from "../domain";
export type LogicalTechnique =
| "naked-single"
| "hidden-single"
| "naked-pair"
| "naked-triple"
| "naked-quad"
| "hidden-pair"
| "hidden-triple"
| "hidden-quad"
| "pointing"
| "claiming"
| "x-wing"
| "swordfish"
| "xy-wing"
| "xyz-wing"
| "killer-cage";
export interface LogicalPlacement {
readonly cell: CellId;
readonly value: number;
}
export interface LogicalElimination {
readonly cell: CellId;
readonly values: readonly number[];
}
export interface LogicalStep {
readonly technique: LogicalTechnique;
readonly placements: readonly LogicalPlacement[];
readonly eliminations: readonly LogicalElimination[];
readonly focusCells: readonly CellId[];
readonly explanation: string;
}
export type LogicalSolveStatus = "solved" | "stuck" | "invalid" | "step-limit";
export interface LogicalSolveOptions {
readonly values?: readonly number[];
readonly maxSteps?: number;
}
export interface LogicalSolveResult {
readonly status: LogicalSolveStatus;
readonly values: readonly number[];
readonly candidates: readonly (readonly number[])[];
readonly steps: readonly LogicalStep[];
}
interface LogicalState {
readonly compiled: CompiledPuzzle;
readonly values: number[];
readonly masks: number[];
}
function digitBit(value: number): number {
return 1 << value;
}
function popcount(mask: number): number {
let value = mask >>> 0;
let count = 0;
while (value !== 0) {
value &= value - 1;
count += 1;
}
return count;
}
function digits(mask: number, size: number): number[] {
const result: number[] = [];
for (let value = 1; value <= size; value += 1) {
if ((mask & digitBit(value)) !== 0) result.push(value);
}
return result;
}
function onlyDigit(mask: number, size: number): number {
return digits(mask, size)[0] ?? 0;
}
function combinations<T>(values: readonly T[], count: number): T[][] {
const result: T[][] = [];
const current: T[] = [];
const visit = (start: number): void => {
if (current.length === count) {
result.push([...current]);
return;
}
for (
let index = start;
index <= values.length - (count - current.length);
index += 1
) {
const value = values[index];
if (value === undefined) continue;
current.push(value);
visit(index + 1);
current.pop();
}
};
visit(0);
return result;
}
function validateStart(
normalized: NormalizedPuzzle,
values: readonly number[],
): void {
const issues: ValidationIssue[] = [];
if (values.length !== normalized.size * normalized.size) {
issues.push({
path: "values",
message: `must contain exactly ${normalized.size ** 2} values`,
});
} else {
values.forEach((value, cell) => {
if (!Number.isInteger(value) || value < 0 || value > normalized.size) {
issues.push({
path: `values[${cell}]`,
message: "contains an out-of-range value",
});
}
const given = normalized.givens[cell] ?? 0;
if (given !== 0 && value !== given) {
issues.push({
path: `values[${cell}]`,
message: "must preserve the given value",
});
}
});
}
if (issues.length > 0) throw new PuzzleValidationError(issues);
}
function initializeState(
normalized: NormalizedPuzzle,
values: readonly number[],
): LogicalState {
const compiled = compilePuzzle(normalized);
return {
compiled,
values: [...values],
masks: values.map((value, cell) => {
if (value !== 0) return 0;
return candidatesForCell(compiled, values, cell).reduce(
(mask, candidate) => mask | digitBit(candidate),
0,
);
}),
};
}
function eliminationStep(
technique: LogicalTechnique,
eliminations: readonly LogicalElimination[],
focusCells: readonly CellId[],
explanation: string,
): LogicalStep | undefined {
return eliminations.length === 0
? undefined
: { technique, placements: [], eliminations, focusCells, explanation };
}
function findNakedSingle(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
for (let cell = 0; cell < state.values.length; cell += 1) {
const mask = state.masks[cell] ?? 0;
if (state.values[cell] === 0 && popcount(mask) === 1) {
const value = onlyDigit(mask, size);
return {
technique: "naked-single",
placements: [{ cell, value }],
eliminations: [],
focusCells: [cell],
explanation: `Cell ${cell + 1} has only one candidate: ${value}.`,
};
}
}
return undefined;
}
function findHiddenSingle(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
for (const unit of state.compiled.units) {
for (let value = 1; value <= size; value += 1) {
if (unit.cells.some((cell) => state.values[cell] === value)) continue;
const cells = unit.cells.filter(
(cell) =>
state.values[cell] === 0 &&
((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
);
if (cells.length === 1) {
const cell = cells[0] as number;
return {
technique: "hidden-single",
placements: [{ cell, value }],
eliminations: [],
focusCells: unit.cells,
explanation: `${value} has only one possible cell in this ${unit.kind}.`,
};
}
}
}
return undefined;
}
function subsetName(hidden: boolean, count: number): LogicalTechnique {
const suffix = count === 2 ? "pair" : count === 3 ? "triple" : "quad";
return `${hidden ? "hidden" : "naked"}-${suffix}` as LogicalTechnique;
}
function findNakedSubset(state: LogicalState): LogicalStep | undefined {
for (const unit of state.compiled.units) {
const empty = unit.cells.filter((cell) => state.values[cell] === 0);
for (let count = 2; count <= 4; count += 1) {
const eligible = empty.filter((cell) => {
const total = popcount(state.masks[cell] ?? 0);
return total >= 2 && total <= count;
});
for (const cells of combinations(eligible, count)) {
const union = cells.reduce(
(mask, cell) => mask | (state.masks[cell] ?? 0),
0,
);
if (popcount(union) !== count) continue;
const selected = new Set(cells);
const eliminations = empty
.filter(
(cell) =>
!selected.has(cell) && ((state.masks[cell] ?? 0) & union) !== 0,
)
.map((cell) => ({
cell,
values: digits(
(state.masks[cell] ?? 0) & union,
state.compiled.puzzle.size,
),
}));
const step = eliminationStep(
subsetName(false, count),
eliminations,
cells,
`${count} cells contain only the same ${count} candidates in this ${unit.kind}.`,
);
if (step !== undefined) return step;
}
}
}
return undefined;
}
function findHiddenSubset(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
const values = Array.from({ length: size }, (_, index) => index + 1);
for (const unit of state.compiled.units) {
const empty = unit.cells.filter((cell) => state.values[cell] === 0);
for (let count = 2; count <= 4; count += 1) {
for (const selectedValues of combinations(values, count)) {
const subsetMask = selectedValues.reduce(
(mask, value) => mask | digitBit(value),
0,
);
if (
selectedValues.some(
(value) =>
!empty.some(
(cell) => ((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
),
)
) {
continue;
}
const cells = empty.filter(
(cell) => ((state.masks[cell] ?? 0) & subsetMask) !== 0,
);
if (cells.length !== count) continue;
const eliminations = cells
.filter((cell) => ((state.masks[cell] ?? 0) & ~subsetMask) !== 0)
.map((cell) => ({
cell,
values: digits((state.masks[cell] ?? 0) & ~subsetMask, size),
}));
const step = eliminationStep(
subsetName(true, count),
eliminations,
cells,
`${selectedValues.join(", ")} can occur only in ${count} cells of this ${unit.kind}.`,
);
if (step !== undefined) return step;
}
}
}
return undefined;
}
function findPointingOrClaiming(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
const regions = state.compiled.units.filter((unit) => unit.kind === "region");
for (const region of regions) {
const regionSet = new Set(region.cells);
for (let value = 1; value <= size; value += 1) {
const cells = region.cells.filter(
(cell) => ((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
);
if (cells.length < 2) continue;
const rows = new Set(cells.map((cell) => Math.floor(cell / size)));
const columns = new Set(cells.map((cell) => cell % size));
const aligned =
rows.size === 1
? (["row", [...rows][0]] as const)
: columns.size === 1
? (["column", [...columns][0]] as const)
: undefined;
if (aligned === undefined || aligned[1] === undefined) continue;
const unit = state.compiled.units.find(
(candidate) =>
candidate.kind === aligned[0] && candidate.index === aligned[1],
);
if (unit === undefined) continue;
const eliminations = unit.cells
.filter(
(cell) =>
!regionSet.has(cell) &&
((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
)
.map((cell) => ({ cell, values: [value] }));
const step = eliminationStep(
"pointing",
eliminations,
cells,
`${value} is confined to one ${aligned[0]} inside a region.`,
);
if (step !== undefined) return step;
}
}
const lines = state.compiled.units.filter(
(unit) => unit.kind === "row" || unit.kind === "column",
);
for (const line of lines) {
for (let value = 1; value <= size; value += 1) {
const cells = line.cells.filter(
(cell) => ((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
);
if (cells.length < 2) continue;
const regionIds = new Set(
cells.map((cell) => state.compiled.puzzle.regions[cell]),
);
if (regionIds.size !== 1) continue;
const regionId = [...regionIds][0];
const region = regions.find((unit) => unit.index === regionId);
if (region === undefined) continue;
const lineSet = new Set(line.cells);
const eliminations = region.cells
.filter(
(cell) =>
!lineSet.has(cell) &&
((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
)
.map((cell) => ({ cell, values: [value] }));
const step = eliminationStep(
"claiming",
eliminations,
cells,
`${value} in this ${line.kind} is confined to a single region.`,
);
if (step !== undefined) return step;
}
}
return undefined;
}
function lineUnit(
compiled: CompiledPuzzle,
kind: "row" | "column",
index: number,
): SudokuUnit | undefined {
return compiled.units.find(
(unit) => unit.kind === kind && unit.index === index,
);
}
function findFish(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
for (const count of [2, 3]) {
for (const [baseKind, coverKind] of [
["row", "column"],
["column", "row"],
] as const) {
for (let value = 1; value <= size; value += 1) {
const eligible = Array.from(
{ length: size },
(_, index) => index,
).filter((index) => {
const unit = lineUnit(state.compiled, baseKind, index);
const total =
unit?.cells.filter(
(cell) => ((state.masks[cell] ?? 0) & digitBit(value)) !== 0,
).length ?? 0;
return total >= 2 && total <= count;
});
for (const baseIndices of combinations(eligible, count)) {
const coverIndices = new Set<number>();
const focus: number[] = [];
for (const baseIndex of baseIndices) {
const unit = lineUnit(state.compiled, baseKind, baseIndex);
for (const cell of unit?.cells ?? []) {
if (((state.masks[cell] ?? 0) & digitBit(value)) === 0) continue;
focus.push(cell);
coverIndices.add(
coverKind === "column" ? cell % size : Math.floor(cell / size),
);
}
}
if (coverIndices.size !== count) continue;
const baseSet = new Set(baseIndices);
const eliminations: LogicalElimination[] = [];
for (const coverIndex of coverIndices) {
const unit = lineUnit(state.compiled, coverKind, coverIndex);
for (const cell of unit?.cells ?? []) {
const baseIndex =
baseKind === "row" ? Math.floor(cell / size) : cell % size;
if (
!baseSet.has(baseIndex) &&
((state.masks[cell] ?? 0) & digitBit(value)) !== 0
) {
eliminations.push({ cell, values: [value] });
}
}
}
const technique: LogicalTechnique =
count === 2 ? "x-wing" : "swordfish";
const step = eliminationStep(
technique,
eliminations,
focus,
`${value} forms a ${technique} across ${count} ${baseKind}s.`,
);
if (step !== undefined) return step;
}
}
}
}
return undefined;
}
function commonPeers(
compiled: CompiledPuzzle,
cells: readonly CellId[],
): Set<CellId> {
const first = cells[0];
if (first === undefined) return new Set();
const result = new Set(compiled.peers[first]);
for (const cell of cells.slice(1)) {
for (const candidate of result) {
if (!compiled.peers[cell]?.has(candidate)) result.delete(candidate);
}
}
for (const cell of cells) result.delete(cell);
return result;
}
function findXyWing(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
for (let pivot = 0; pivot < state.values.length; pivot += 1) {
const pivotMask = state.masks[pivot] ?? 0;
if (popcount(pivotMask) !== 2) continue;
const wings = [...(state.compiled.peers[pivot] ?? [])].filter(
(cell) => popcount(state.masks[cell] ?? 0) === 2,
);
for (const pair of combinations(wings, 2)) {
const a = pair[0];
const b = pair[1];
if (a === undefined || b === undefined) continue;
const aMask = state.masks[a] ?? 0;
const bMask = state.masks[b] ?? 0;
const sharedA = aMask & pivotMask;
const sharedB = bMask & pivotMask;
if (
popcount(sharedA) !== 1 ||
popcount(sharedB) !== 1 ||
sharedA === sharedB
)
continue;
const zMask = aMask & bMask & ~pivotMask;
if (popcount(zMask) !== 1) continue;
const value = onlyDigit(zMask, size);
const eliminations = [...commonPeers(state.compiled, [a, b])]
.filter((cell) => ((state.masks[cell] ?? 0) & zMask) !== 0)
.map((cell) => ({ cell, values: [value] }));
const step = eliminationStep(
"xy-wing",
eliminations,
[pivot, a, b],
`Cells ${pivot + 1}, ${a + 1}, and ${b + 1} form an XY-Wing eliminating ${value}.`,
);
if (step !== undefined) return step;
}
}
return undefined;
}
function findXyzWing(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
for (let pivot = 0; pivot < state.values.length; pivot += 1) {
const pivotMask = state.masks[pivot] ?? 0;
if (popcount(pivotMask) !== 3) continue;
const wings = [...(state.compiled.peers[pivot] ?? [])].filter((cell) => {
const mask = state.masks[cell] ?? 0;
return popcount(mask) === 2 && (mask & ~pivotMask) === 0;
});
for (const pair of combinations(wings, 2)) {
const a = pair[0];
const b = pair[1];
if (a === undefined || b === undefined) continue;
const aMask = state.masks[a] ?? 0;
const bMask = state.masks[b] ?? 0;
if ((aMask | bMask) !== pivotMask) continue;
const shared = aMask & bMask;
if (popcount(shared) !== 1) continue;
const value = onlyDigit(shared, size);
const eliminations = [...commonPeers(state.compiled, [pivot, a, b])]
.filter((cell) => ((state.masks[cell] ?? 0) & shared) !== 0)
.map((cell) => ({ cell, values: [value] }));
const step = eliminationStep(
"xyz-wing",
eliminations,
[pivot, a, b],
`Cells ${pivot + 1}, ${a + 1}, and ${b + 1} form an XYZ-Wing eliminating ${value}.`,
);
if (step !== undefined) return step;
}
}
return undefined;
}
function findKillerReduction(state: LogicalState): LogicalStep | undefined {
const size = state.compiled.puzzle.size;
for (const constraint of state.compiled.puzzle.constraints) {
if (constraint.type !== "killer-cage") continue;
const empty = constraint.cells.filter((cell) => state.values[cell] === 0);
if (empty.length === 0) continue;
const assigned = constraint.cells
.map((cell) => state.values[cell] ?? 0)
.filter((value) => value !== 0);
const target =
constraint.sum - assigned.reduce((sum, value) => sum + value, 0);
const used = new Set(assigned);
const allowed = new Map<CellId, number>();
empty.forEach((cell) => allowed.set(cell, 0));
let visits = 0;
let truncated = false;
const chosen = new Set<number>(used);
const visit = (index: number, remaining: number): void => {
if (visits >= 100_000) {
truncated = true;
return;
}
visits += 1;
if (index === empty.length) {
if (remaining === 0) {
empty.forEach((cell) => {
const value = state.values[cell] ?? 0;
// Temporary chosen values are stored just beyond the live board.
const selected = assignment[indexByCell.get(cell) ?? -1] ?? value;
allowed.set(cell, (allowed.get(cell) ?? 0) | digitBit(selected));
});
}
return;
}
const cell = empty[index];
if (cell === undefined) return;
const remainingCells = empty.length - index - 1;
for (const value of digits(state.masks[cell] ?? 0, size)) {
if (constraint.noRepeat !== false && chosen.has(value)) continue;
const next = remaining - value;
if (next < remainingCells || next > remainingCells * size) continue;
assignment[index] = value;
chosen.add(value);
visit(index + 1, next);
chosen.delete(value);
}
};
const assignment = new Array<number>(empty.length).fill(0);
const indexByCell = new Map(empty.map((cell, index) => [cell, index]));
visit(0, target);
if (truncated) continue;
const eliminations = empty
.filter(
(cell) => ((state.masks[cell] ?? 0) & ~(allowed.get(cell) ?? 0)) !== 0,
)
.map((cell) => ({
cell,
values: digits(
(state.masks[cell] ?? 0) & ~(allowed.get(cell) ?? 0),
size,
),
}));
const step = eliminationStep(
"killer-cage",
eliminations,
constraint.cells,
`Only sum-compatible assignments remain in the ${constraint.sum} cage.`,
);
if (step !== undefined) return step;
}
return undefined;
}
function findStep(state: LogicalState): LogicalStep | undefined {
return (
findNakedSingle(state) ??
findHiddenSingle(state) ??
findNakedSubset(state) ??
findHiddenSubset(state) ??
findPointingOrClaiming(state) ??
findFish(state) ??
findXyWing(state) ??
findXyzWing(state) ??
findKillerReduction(state)
);
}
function applyStep(state: LogicalState, step: LogicalStep): void {
for (const elimination of step.eliminations) {
let mask = state.masks[elimination.cell] ?? 0;
for (const value of elimination.values) mask &= ~digitBit(value);
state.masks[elimination.cell] = mask;
}
for (const placement of step.placements) {
state.values[placement.cell] = placement.value;
state.masks[placement.cell] = 0;
}
if (step.placements.length > 0) {
for (let cell = 0; cell < state.values.length; cell += 1) {
if (state.values[cell] !== 0) continue;
const raw = candidatesForCell(state.compiled, state.values, cell).reduce(
(mask, value) => mask | digitBit(value),
0,
);
state.masks[cell] = (state.masks[cell] ?? 0) & raw;
}
}
}
function exposedCandidates(
state: LogicalState,
): readonly (readonly number[])[] {
return state.masks.map((mask) => digits(mask, state.compiled.puzzle.size));
}
export function solveLogically(
puzzle: PuzzleDefinition | NormalizedPuzzle,
options: LogicalSolveOptions = {},
): LogicalSolveResult {
const normalized = normalizePuzzle(puzzle);
const values = options.values ?? normalized.givens;
validateStart(normalized, values);
const maxSteps = options.maxSteps ?? 1_000;
if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > 10_000) {
throw new RangeError("maxSteps must be an integer from 1 to 10000");
}
const state = initializeState(normalized, values);
const steps: LogicalStep[] = [];
if (findConflicts(state.compiled, state.values).length > 0) {
return {
status: "invalid",
values: state.values,
candidates: exposedCandidates(state),
steps,
};
}
while (steps.length < maxSteps) {
if (isSolved(state.compiled, state.values)) {
return {
status: "solved",
values: state.values,
candidates: exposedCandidates(state),
steps,
};
}
if (
state.values.some(
(value, cell) => value === 0 && (state.masks[cell] ?? 0) === 0,
)
) {
return {
status: "invalid",
values: state.values,
candidates: exposedCandidates(state),
steps,
};
}
const step = findStep(state);
if (step === undefined) {
return {
status: "stuck",
values: state.values,
candidates: exposedCandidates(state),
steps,
};
}
applyStep(state, step);
steps.push(step);
}
return {
status: isSolved(state.compiled, state.values) ? "solved" : "step-limit",
values: state.values,
candidates: exposedCandidates(state),
steps,
};
}
+33
View File
@@ -0,0 +1,33 @@
export type RandomSource = () => number;
function hashSeed(seed: string | number): number {
const text = String(seed);
let hash = 2_166_136_261;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 16_777_619);
}
return hash >>> 0;
}
export function seededRandom(seed: string | number): RandomSource {
let state = hashSeed(seed);
return () => {
state += 0x6d2b79f5;
let value = state;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
};
}
export function shuffled<T>(values: readonly T[], random: RandomSource): T[] {
const result = [...values];
for (let index = result.length - 1; index > 0; index -= 1) {
const other = Math.floor(random() * (index + 1));
const value = result[index];
result[index] = result[other] as T;
result[other] = value as T;
}
return result;
}
+122
View File
@@ -0,0 +1,122 @@
export type EntryMode = "value" | "corner" | "center" | "color";
export interface PlaySnapshot {
values: number[];
cornerMarks: number[];
centerMarks: number[];
colors: number[];
}
export interface PlaySession extends PlaySnapshot {
elapsedSeconds: number;
paused: boolean;
}
export function createSession(givens: readonly number[]): PlaySession {
return {
values: [...givens],
cornerMarks: givens.map(() => 0),
centerMarks: givens.map(() => 0),
colors: givens.map(() => 0),
elapsedSeconds: 0,
paused: false,
};
}
export function snapshotSession(session: PlaySession): PlaySnapshot {
return {
values: [...session.values],
cornerMarks: [...session.cornerMarks],
centerMarks: [...session.centerMarks],
colors: [...session.colors],
};
}
export function restoreSnapshot(
session: PlaySession,
snapshot: PlaySnapshot,
): PlaySession {
return {
...session,
values: [...snapshot.values],
cornerMarks: [...snapshot.cornerMarks],
centerMarks: [...snapshot.centerMarks],
colors: [...snapshot.colors],
};
}
function toggleMask(mask: number, value: number) {
const bit = 1 << (value - 1);
return mask & bit ? mask & ~bit : mask | bit;
}
export function enterSelection(
session: PlaySession,
selection: ReadonlySet<number>,
mode: EntryMode,
value: number,
givens: readonly number[],
): PlaySession {
const next = restoreSnapshot(session, session);
for (const cell of selection) {
if (cell < 0 || cell >= next.values.length) continue;
if (mode === "value") {
if (givens[cell]) continue;
next.values[cell] = next.values[cell] === value ? 0 : value;
next.cornerMarks[cell] = 0;
next.centerMarks[cell] = 0;
} else if (mode === "corner") {
if (next.values[cell]) continue;
next.cornerMarks[cell] = toggleMask(next.cornerMarks[cell] ?? 0, value);
} else if (mode === "center") {
if (next.values[cell]) continue;
next.centerMarks[cell] = toggleMask(next.centerMarks[cell] ?? 0, value);
} else {
next.colors[cell] = next.colors[cell] === value ? 0 : value;
}
}
return next;
}
export function eraseSelection(
session: PlaySession,
selection: ReadonlySet<number>,
mode: EntryMode,
givens: readonly number[],
): PlaySession {
const next = restoreSnapshot(session, session);
for (const cell of selection) {
if (cell < 0 || cell >= next.values.length) continue;
if (mode === "value" && !givens[cell]) next.values[cell] = 0;
if (mode === "corner") next.cornerMarks[cell] = 0;
if (mode === "center") next.centerMarks[cell] = 0;
if (mode === "color") next.colors[cell] = 0;
}
return next;
}
export function maskValues(mask: number, size: number): number[] {
const values: number[] = [];
for (let value = 1; value <= size; value += 1)
if (mask & (1 << (value - 1))) values.push(value);
return values;
}
export function symbolFor(value: number, size: number) {
if (value <= 0) return "";
if (value <= 9) return String(value);
if (size <= 16) return String.fromCharCode(55 + value);
return String(value);
}
export function valueForKey(key: string, size: number) {
if (/^[1-9]$/u.test(key)) {
const value = Number(key);
return value <= size ? value : null;
}
if (/^[a-z]$/iu.test(key)) {
const value = key.toUpperCase().charCodeAt(0) - 55;
return value >= 10 && value <= size ? value : null;
}
return null;
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./library";
export * from "./record";
export * from "./types";
+314
View File
@@ -0,0 +1,314 @@
import { SudokuFormatError } from "../formats";
import {
cloneProjectRecord,
MAX_PROJECT_BYTES,
normalizeProjectRecord,
} from "./record";
import type {
ProjectLibraryExport,
SudokuProjectRecord,
SudokuProjectSummary,
} from "./types";
export const MAX_LIBRARY_PROJECTS = 256;
export const MAX_MEMORY_LIBRARY_BYTES = 32 * 1_048_576;
const DATABASE_VERSION = 1;
const STORE_NAME = "projects";
export type ProjectLibraryMode = "indexeddb" | "memory";
export interface ProjectLibraryOptions {
readonly indexedDB?: IDBFactory | null;
readonly databaseName?: string;
}
function summary(record: SudokuProjectRecord): SudokuProjectSummary {
return {
id: record.id,
title: record.title,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
size: record.puzzle.size,
completed: record.progress?.completed ?? false,
};
}
function bytes(record: SudokuProjectRecord): number {
return new TextEncoder().encode(JSON.stringify(record)).byteLength;
}
function requestResult<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () =>
reject(request.error ?? new Error("IndexedDB request failed."));
});
}
function transactionDone(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () =>
reject(transaction.error ?? new Error("IndexedDB transaction failed."));
transaction.onabort = () =>
reject(transaction.error ?? new Error("IndexedDB transaction aborted."));
});
}
async function openDatabase(
factory: IDBFactory,
name: string,
): Promise<IDBDatabase> {
return await new Promise((resolve, reject) => {
const request = factory.open(name, DATABASE_VERSION);
request.onupgradeneeded = () => {
const database = request.result;
if (!database.objectStoreNames.contains(STORE_NAME)) {
const store = database.createObjectStore(STORE_NAME, { keyPath: "id" });
store.createIndex("updatedAt", "updatedAt");
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () =>
reject(request.error ?? new Error("Could not open IndexedDB."));
request.onblocked = () =>
reject(new Error("The project library database upgrade is blocked."));
});
}
export class ProjectLibrary {
readonly #factory: IDBFactory | null;
readonly #databaseName: string;
readonly #memory = new Map<string, SudokuProjectRecord>();
#database: Promise<IDBDatabase> | undefined;
#mode: ProjectLibraryMode;
constructor(options: ProjectLibraryOptions = {}) {
this.#factory =
options.indexedDB === undefined
? typeof indexedDB === "undefined"
? null
: indexedDB
: options.indexedDB;
this.#databaseName = options.databaseName ?? "sudoku-tools";
this.#mode = this.#factory === null ? "memory" : "indexeddb";
}
get mode(): ProjectLibraryMode {
return this.#mode;
}
async ready(): Promise<ProjectLibraryMode> {
await this.#db();
return this.#mode;
}
async #db(): Promise<IDBDatabase | undefined> {
if (this.#mode === "memory" || this.#factory === null) return undefined;
this.#database ??= openDatabase(this.#factory, this.#databaseName);
try {
return await this.#database;
} catch {
this.#mode = "memory";
this.#database = undefined;
return undefined;
}
}
#memoryTotal(replacement?: SudokuProjectRecord): number {
let total = 0;
for (const record of this.#memory.values()) {
if (replacement !== undefined && record.id === replacement.id) continue;
total += bytes(record);
}
return total + (replacement === undefined ? 0 : bytes(replacement));
}
#memoryPut(record: SudokuProjectRecord): void {
if (
!this.#memory.has(record.id) &&
this.#memory.size >= MAX_LIBRARY_PROJECTS
) {
throw new SudokuFormatError(
"STORAGE_LIMIT",
`The fallback library is limited to ${MAX_LIBRARY_PROJECTS} projects.`,
);
}
if (this.#memoryTotal(record) > MAX_MEMORY_LIBRARY_BYTES) {
throw new SudokuFormatError(
"STORAGE_LIMIT",
"The in-memory project library is full.",
);
}
this.#memory.set(record.id, cloneProjectRecord(record));
}
async list(): Promise<readonly SudokuProjectSummary[]> {
const database = await this.#db();
if (database === undefined) {
return [...this.#memory.values()]
.map(summary)
.sort(
(a, b) => b.updatedAt - a.updatedAt || a.title.localeCompare(b.title),
);
}
try {
const transaction = database.transaction(STORE_NAME, "readonly");
const records = await requestResult(
transaction.objectStore(STORE_NAME).getAll(),
);
await transactionDone(transaction);
if (records.length > MAX_LIBRARY_PROJECTS) {
throw new SudokuFormatError(
"STORAGE_LIMIT",
"The project library contains too many records.",
);
}
return records
.map((record) => summary(normalizeProjectRecord(record)))
.sort(
(a, b) => b.updatedAt - a.updatedAt || a.title.localeCompare(b.title),
);
} catch (error) {
if (error instanceof SudokuFormatError) throw error;
this.#mode = "memory";
return this.list();
}
}
async get(id: string): Promise<SudokuProjectRecord | undefined> {
const database = await this.#db();
if (database === undefined) {
const record = this.#memory.get(id);
return record === undefined ? undefined : cloneProjectRecord(record);
}
try {
const transaction = database.transaction(STORE_NAME, "readonly");
const value = await requestResult(
transaction.objectStore(STORE_NAME).get(id),
);
await transactionDone(transaction);
return value === undefined ? undefined : normalizeProjectRecord(value);
} catch (error) {
if (error instanceof SudokuFormatError) throw error;
this.#mode = "memory";
return this.get(id);
}
}
async put(value: SudokuProjectRecord): Promise<SudokuProjectRecord> {
const record = normalizeProjectRecord(value);
if (bytes(record) > MAX_PROJECT_BYTES) {
throw new SudokuFormatError(
"STORAGE_LIMIT",
"The project is too large to save.",
);
}
const database = await this.#db();
if (database === undefined) {
this.#memoryPut(record);
return cloneProjectRecord(record);
}
try {
const transaction = database.transaction(STORE_NAME, "readwrite");
const store = transaction.objectStore(STORE_NAME);
const existing = await requestResult(store.getKey(record.id));
const count = await requestResult(store.count());
if (existing === undefined && count >= MAX_LIBRARY_PROJECTS) {
transaction.abort();
throw new SudokuFormatError(
"STORAGE_LIMIT",
`The project library is limited to ${MAX_LIBRARY_PROJECTS} projects.`,
);
}
store.put(record);
await transactionDone(transaction);
return cloneProjectRecord(record);
} catch (error) {
if (error instanceof SudokuFormatError) throw error;
this.#mode = "memory";
this.#memoryPut(record);
return cloneProjectRecord(record);
}
}
async delete(id: string): Promise<boolean> {
const database = await this.#db();
if (database === undefined) return this.#memory.delete(id);
try {
const transaction = database.transaction(STORE_NAME, "readwrite");
const store = transaction.objectStore(STORE_NAME);
const exists = (await requestResult(store.getKey(id))) !== undefined;
if (exists) store.delete(id);
await transactionDone(transaction);
return exists;
} catch {
this.#mode = "memory";
return this.#memory.delete(id);
}
}
async clear(): Promise<void> {
const database = await this.#db();
if (database === undefined) {
this.#memory.clear();
return;
}
try {
const transaction = database.transaction(STORE_NAME, "readwrite");
transaction.objectStore(STORE_NAME).clear();
await transactionDone(transaction);
} catch {
this.#mode = "memory";
this.#memory.clear();
}
}
async exportAll(): Promise<ProjectLibraryExport> {
const summaries = await this.list();
const projects: SudokuProjectRecord[] = [];
for (const item of summaries) {
const record = await this.get(item.id);
if (record !== undefined) projects.push(record);
}
return {
schema: "de.add-ideas.sudoku-tools.library",
version: 1,
exportedAt: Date.now(),
projects,
};
}
async importAll(value: unknown, replace = false): Promise<number> {
if (
typeof value !== "object" ||
value === null ||
(value as { schema?: unknown }).schema !==
"de.add-ideas.sudoku-tools.library" ||
(value as { version?: unknown }).version !== 1 ||
!Array.isArray((value as { projects?: unknown }).projects)
) {
throw new SudokuFormatError(
"INVALID_LIBRARY",
"Unsupported project library export.",
);
}
const raw = (value as { projects: unknown[] }).projects;
if (raw.length > MAX_LIBRARY_PROJECTS) {
throw new SudokuFormatError(
"STORAGE_LIMIT",
"The import contains too many projects.",
);
}
const records = raw.map(normalizeProjectRecord);
if (replace) await this.clear();
for (const record of records) await this.put(record);
return records.length;
}
}
export function createProjectLibrary(
options?: ProjectLibraryOptions,
): ProjectLibrary {
return new ProjectLibrary(options);
}
+280
View File
@@ -0,0 +1,280 @@
import {
MAX_DOCUMENT_BYTES,
SudokuFormatError,
cloneSudokuDocument,
normalizeSudokuDocument,
} from "../formats";
import {
PROJECT_RECORD_SCHEMA,
PROJECT_RECORD_VERSION,
type SudokuProgress,
type SudokuProjectRecord,
} from "./types";
export const MAX_PROJECT_BYTES = MAX_DOCUMENT_BYTES * 2;
export const MAX_PROJECT_ID_LENGTH = 128;
export const MAX_PROJECT_TITLE_LENGTH = 500;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function storageError(code: string, message: string): never {
throw new SudokuFormatError(code, message);
}
function timestamp(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
return storageError(
"INVALID_PROJECT",
`${label} must be a non-negative timestamp.`,
);
}
return value;
}
function progress(value: unknown, size: number): SudokuProgress | undefined {
if (value === undefined) return undefined;
if (!isRecord(value) || value.version !== 1) {
return storageError("INVALID_PROJECT", "Unsupported progress record.");
}
const cellCount = size * size;
if (!Array.isArray(value.values) || value.values.length !== cellCount) {
return storageError(
"INVALID_PROJECT",
`Progress values must contain ${cellCount} entries.`,
);
}
const values = value.values.map((entry, index) => {
if (
!Number.isInteger(entry) ||
(entry as number) < 0 ||
(entry as number) > size
) {
return storageError(
"INVALID_PROJECT",
`Progress value ${index + 1} is out of range.`,
);
}
return entry as number;
});
const marks = (input: unknown, label: string): number[][] | undefined => {
if (input === undefined) return undefined;
if (!Array.isArray(input) || input.length !== cellCount) {
return storageError(
"INVALID_PROJECT",
`Progress ${label} must contain ${cellCount} entries.`,
);
}
return input.map((entry, index) => {
if (!Array.isArray(entry) || entry.length > size) {
return storageError(
"INVALID_PROJECT",
`Progress ${label} for cell ${index + 1} are invalid.`,
);
}
const result = entry.map((digit) => {
if (
!Number.isInteger(digit) ||
(digit as number) < 1 ||
(digit as number) > size
) {
return storageError(
"INVALID_PROJECT",
`A ${label} value for cell ${index + 1} is out of range.`,
);
}
return digit as number;
});
return [...new Set(result)].sort((a, b) => a - b);
});
};
const candidates = marks(value.candidates, "candidates");
const cornerMarks = marks(value.cornerMarks, "corner marks");
const centerMarks = marks(value.centerMarks, "center marks") ?? candidates;
let colors: number[] | undefined;
if (value.colors !== undefined) {
if (!Array.isArray(value.colors) || value.colors.length !== cellCount) {
return storageError(
"INVALID_PROJECT",
`Progress colors must contain ${cellCount} entries.`,
);
}
colors = value.colors.map((color, index) => {
if (
!Number.isInteger(color) ||
(color as number) < 0 ||
(color as number) > 8
) {
return storageError(
"INVALID_PROJECT",
`Progress color for cell ${index + 1} is out of range.`,
);
}
return color as number;
});
}
if (
value.elapsedMs !== undefined &&
(!Number.isFinite(value.elapsedMs) || (value.elapsedMs as number) < 0)
) {
return storageError(
"INVALID_PROJECT",
"elapsedMs must be a non-negative number.",
);
}
if (value.completed !== undefined && typeof value.completed !== "boolean") {
return storageError("INVALID_PROJECT", "completed must be true or false.");
}
return {
version: 1,
values,
...(cornerMarks === undefined ? {} : { cornerMarks }),
...(centerMarks === undefined ? {} : { centerMarks }),
...(colors === undefined ? {} : { colors }),
...(candidates === undefined ? {} : { candidates }),
...(value.elapsedMs === undefined
? {}
: { elapsedMs: value.elapsedMs as number }),
...(value.completed === undefined ? {} : { completed: value.completed }),
};
}
export function normalizeProjectRecord(value: unknown): SudokuProjectRecord {
if (!isRecord(value))
return storageError("INVALID_PROJECT", "A project must be an object.");
if (
value.schema !== PROJECT_RECORD_SCHEMA ||
value.version !== PROJECT_RECORD_VERSION
) {
return storageError(
"UNSUPPORTED_VERSION",
"Unsupported project record schema or version.",
);
}
if (
typeof value.id !== "string" ||
value.id.length === 0 ||
value.id.length > MAX_PROJECT_ID_LENGTH ||
[...value.id].some((character) => (character.codePointAt(0) ?? 0) <= 31)
) {
return storageError(
"INVALID_PROJECT",
"The project ID is empty, too long, or unsafe.",
);
}
if (
typeof value.title !== "string" ||
value.title.length > MAX_PROJECT_TITLE_LENGTH
) {
return storageError(
"INVALID_PROJECT",
"The project title is invalid or too long.",
);
}
const createdAt = timestamp(value.createdAt, "createdAt");
const updatedAt = timestamp(value.updatedAt, "updatedAt");
if (updatedAt < createdAt) {
return storageError(
"INVALID_PROJECT",
"updatedAt cannot precede createdAt.",
);
}
const puzzle = normalizeSudokuDocument(value.puzzle);
const normalized: SudokuProjectRecord = {
schema: PROJECT_RECORD_SCHEMA,
version: PROJECT_RECORD_VERSION,
id: value.id,
title: value.title,
createdAt,
updatedAt,
puzzle,
...(value.progress === undefined
? {}
: { progress: progress(value.progress, puzzle.size) }),
};
const bytes = new TextEncoder().encode(JSON.stringify(normalized)).byteLength;
if (bytes > MAX_PROJECT_BYTES) {
return storageError(
"LIMIT_EXCEEDED",
`The project exceeds ${MAX_PROJECT_BYTES.toLocaleString()} bytes.`,
);
}
return normalized;
}
export interface NewProjectOptions {
readonly id?: string;
readonly title?: string;
readonly now?: number;
readonly progress?: SudokuProgress;
}
function randomId(): string {
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
return crypto.randomUUID();
}
return `project-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`;
}
export function createProjectRecord(
puzzle: SudokuProjectRecord["puzzle"],
options: NewProjectOptions = {},
): SudokuProjectRecord {
const now = options.now ?? Date.now();
return normalizeProjectRecord({
schema: PROJECT_RECORD_SCHEMA,
version: PROJECT_RECORD_VERSION,
id: options.id ?? randomId(),
title: options.title ?? puzzle.title ?? "Untitled puzzle",
createdAt: now,
updatedAt: now,
puzzle,
...(options.progress === undefined ? {} : { progress: options.progress }),
});
}
export function cloneProjectRecord(
record: SudokuProjectRecord,
): SudokuProjectRecord {
const normalized = normalizeProjectRecord(record);
return {
...normalized,
puzzle: cloneSudokuDocument(normalized.puzzle),
...(normalized.progress === undefined
? {}
: {
progress: {
...normalized.progress,
values: [...normalized.progress.values],
...(normalized.progress.cornerMarks === undefined
? {}
: {
cornerMarks: normalized.progress.cornerMarks.map((entry) => [
...entry,
]),
}),
...(normalized.progress.centerMarks === undefined
? {}
: {
centerMarks: normalized.progress.centerMarks.map((entry) => [
...entry,
]),
}),
...(normalized.progress.colors === undefined
? {}
: { colors: [...normalized.progress.colors] }),
...(normalized.progress.candidates === undefined
? {}
: {
candidates: normalized.progress.candidates.map((entry) => [
...entry,
]),
}),
},
}),
};
}
+44
View File
@@ -0,0 +1,44 @@
import type { SudokuDocument } from "../formats";
export const PROJECT_RECORD_SCHEMA =
"de.add-ideas.sudoku-tools.project" as const;
export const PROJECT_RECORD_VERSION = 1 as const;
export interface SudokuProgress {
readonly version: 1;
readonly values: readonly number[];
readonly cornerMarks?: readonly (readonly number[])[];
readonly centerMarks?: readonly (readonly number[])[];
readonly colors?: readonly number[];
/** Legacy alias for centerMarks in early v1 records. */
readonly candidates?: readonly (readonly number[])[];
readonly elapsedMs?: number;
readonly completed?: boolean;
}
export interface SudokuProjectRecord {
readonly schema: typeof PROJECT_RECORD_SCHEMA;
readonly version: typeof PROJECT_RECORD_VERSION;
readonly id: string;
readonly title: string;
readonly createdAt: number;
readonly updatedAt: number;
readonly puzzle: SudokuDocument;
readonly progress?: SudokuProgress;
}
export interface SudokuProjectSummary {
readonly id: string;
readonly title: string;
readonly createdAt: number;
readonly updatedAt: number;
readonly size: number;
readonly completed: boolean;
}
export interface ProjectLibraryExport {
readonly schema: "de.add-ideas.sudoku-tools.library";
readonly version: 1;
readonly exportedAt: number;
readonly projects: readonly SudokuProjectRecord[];
}
+2176
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
if (
!(
HTMLDialogElement.prototype as HTMLDialogElement & {
showModal?: () => void;
}
).showModal
) {
HTMLDialogElement.prototype.showModal = function showModal() {
this.setAttribute("open", "");
};
}
const nativeDialogClose = HTMLDialogElement.prototype.close;
HTMLDialogElement.prototype.close = function close(returnValue?: string) {
if (nativeDialogClose) {
try {
nativeDialogClose.call(this, returnValue);
return;
} catch {
// jsdom fallback.
}
}
this.removeAttribute("open");
};
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: vi.fn(() => "blob:sudoku-tools-test"),
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: vi.fn(),
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
+41
View File
@@ -0,0 +1,41 @@
{
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.sudoku-tools",
"name": "Sudoku Tools",
"version": "0.1.0",
"description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["games", "puzzles", "logic"],
"tags": ["sudoku", "killer", "solver", "setter", "generator", "candidates"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"workers": true,
"indexedDb": true,
"crossOriginIsolated": false,
"topLevelContext": false
},
"privacy": {
"processing": "local",
"fileUploads": false,
"telemetry": false,
"label": "Puzzles and progress stay in this browser; nothing is uploaded."
},
"source": {
"repository": "https://git.add-ideas.de/lotobo/sudoku-tools",
"license": "GPL-3.0-or-later"
},
"actions": [
{
"id": "source",
"label": "Source",
"url": "https://git.add-ideas.de/lotobo/sudoku-tools"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
import source from "./manifest.source.json";
export const manifest = defineToolboxApp(parseToolboxApp(source));
+1
View File
@@ -0,0 +1 @@
export const APPLICATION_VERSION = "0.1.0";
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+127
View File
@@ -0,0 +1,127 @@
import type {
SolverWorkerOperation,
SolverWorkerRequest,
SolverWorkerResponse,
SolverWorkerValue,
} from "./protocol";
export interface SolverWorkerClient {
readonly request: (
operation: SolverWorkerOperation,
options?: {
readonly timeoutMs?: number;
readonly signal?: AbortSignal;
/** Restart the worker and reject older work. Defaults to true. */
readonly supersede?: boolean;
},
) => Promise<SolverWorkerValue>;
readonly terminate: () => void;
}
interface PendingRequest {
readonly resolve: (value: SolverWorkerValue) => void;
readonly reject: (reason: unknown) => void;
readonly timer: ReturnType<typeof setTimeout>;
readonly removeAbort: () => void;
}
export function createSolverWorkerClient(): SolverWorkerClient {
const pending = new Map<string, PendingRequest>();
let sequence = 0;
let terminated = false;
const rejectPending = (reason: Error): void => {
for (const entry of pending.values()) {
clearTimeout(entry.timer);
entry.removeAbort();
entry.reject(reason);
}
pending.clear();
};
const spawnWorker = (): Worker => {
const next = new Worker(new URL("./solver.worker.ts", import.meta.url), {
type: "module",
});
next.addEventListener(
"message",
(event: MessageEvent<SolverWorkerResponse>) => {
const response = event.data;
const entry = pending.get(response.id);
if (entry === undefined) return;
pending.delete(response.id);
clearTimeout(entry.timer);
entry.removeAbort();
if (response.ok) entry.resolve(response.value);
else {
const error = new Error(response.error.message);
error.name = response.error.name;
entry.reject(error);
}
},
);
next.addEventListener("error", () => {
rejectPending(new Error("Solver worker failed."));
next.terminate();
if (!terminated) worker = spawnWorker();
});
return next;
};
let worker = spawnWorker();
const restart = (reason: Error): void => {
worker.terminate();
rejectPending(reason);
if (!terminated) worker = spawnWorker();
};
const request: SolverWorkerClient["request"] = (operation, options = {}) => {
if (terminated)
return Promise.reject(new Error("Solver worker was terminated."));
const timeoutMs = options.timeoutMs ?? 125_000;
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 300_000) {
return Promise.reject(
new RangeError("Worker timeout must be from 1 to 300000 milliseconds."),
);
}
if (options.signal?.aborted === true) {
return Promise.reject(
new DOMException("The operation was aborted.", "AbortError"),
);
}
if (options.supersede !== false && pending.size > 0) {
restart(new DOMException("The operation was superseded.", "AbortError"));
}
sequence += 1;
const id = `${Date.now().toString(36)}-${sequence.toString(36)}`;
const message: SolverWorkerRequest = { id, operation };
return new Promise<SolverWorkerValue>((resolve, reject) => {
const abort = (): void => {
if (!pending.has(id)) return;
restart(new DOMException("The operation was aborted.", "AbortError"));
};
const timer = setTimeout(() => {
if (!pending.has(id)) return;
restart(new Error("Solver worker response timed out."));
}, timeoutMs);
options.signal?.addEventListener("abort", abort, { once: true });
pending.set(id, {
resolve,
reject,
timer,
removeAbort: () => options.signal?.removeEventListener("abort", abort),
});
worker.postMessage(message);
});
};
return {
request,
terminate: () => {
terminated = true;
worker.terminate();
rejectPending(new Error("Solver worker was terminated."));
},
};
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./client";
export * from "./protocol";
+63
View File
@@ -0,0 +1,63 @@
import type { PuzzleDefinition } from "../domain";
import type {
ExactSolveOptions,
ExactSolveResult,
GenerateClassicOptions,
KillerCombinationOptions,
KillerCombinationResult,
LogicalSolveOptions,
LogicalSolveResult,
MinimizeOptions,
} from "../solver";
import type { NormalizedPuzzle, ValidationIssue } from "../domain";
export type SolverWorkerOperation =
| {
readonly kind: "solve";
readonly puzzle: PuzzleDefinition;
readonly options?: ExactSolveOptions;
}
| {
readonly kind: "logical";
readonly puzzle: PuzzleDefinition;
readonly options?: LogicalSolveOptions;
}
| { readonly kind: "generate"; readonly options?: GenerateClassicOptions }
| {
readonly kind: "minimize";
readonly puzzle: PuzzleDefinition;
readonly options?: MinimizeOptions;
}
| {
readonly kind: "killer-combinations";
readonly options: KillerCombinationOptions;
};
export interface SolverWorkerRequest {
readonly id: string;
readonly operation: SolverWorkerOperation;
}
export type SolverWorkerValue =
| ExactSolveResult
| LogicalSolveResult
| NormalizedPuzzle
| KillerCombinationResult;
export interface SolverWorkerError {
readonly name: string;
readonly message: string;
readonly issues?: readonly ValidationIssue[];
}
export type SolverWorkerResponse =
| {
readonly id: string;
readonly ok: true;
readonly value: SolverWorkerValue;
}
| {
readonly id: string;
readonly ok: false;
readonly error: SolverWorkerError;
};
+66
View File
@@ -0,0 +1,66 @@
/// <reference lib="webworker" />
import { PuzzleValidationError } from "../domain";
import {
generateClassic,
killerDigitCombinations,
minimizePuzzle,
solveExact,
solveLogically,
} from "../solver";
import type {
SolverWorkerRequest,
SolverWorkerResponse,
SolverWorkerValue,
} from "./protocol";
const scope = self as unknown as DedicatedWorkerGlobalScope;
function run(request: SolverWorkerRequest): SolverWorkerValue {
const operation = request.operation;
switch (operation.kind) {
case "solve":
return solveExact(operation.puzzle, operation.options);
case "logical":
return solveLogically(operation.puzzle, operation.options);
case "generate":
return generateClassic(operation.options);
case "minimize":
return minimizePuzzle(operation.puzzle, operation.options);
case "killer-combinations":
return killerDigitCombinations(operation.options);
}
}
scope.addEventListener("message", (event: MessageEvent<unknown>) => {
const input = event.data;
if (
typeof input !== "object" ||
input === null ||
typeof (input as { id?: unknown }).id !== "string" ||
typeof (input as { operation?: unknown }).operation !== "object"
) {
return;
}
const request = input as SolverWorkerRequest;
let response: SolverWorkerResponse;
try {
response = { id: request.id, ok: true, value: run(request) };
} catch (error) {
response = {
id: request.id,
ok: false,
error: {
name: error instanceof Error ? error.name : "Error",
message:
error instanceof Error ? error.message : "Unknown solver error",
...(error instanceof PuzzleValidationError
? { issues: error.issues }
: {}),
},
};
}
scope.postMessage(response);
});
export {};