feat: launch local-first Sudoku workbench
This commit is contained in:
+41
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 < 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 & uniqueness
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onGenerate}
|
||||
disabled={busy || puzzle.size > 9}
|
||||
>
|
||||
Generate classic
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>1–9 / A–G</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>
|
||||
);
|
||||
}
|
||||
@@ -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 < second</option>
|
||||
<option value="greater">First > 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 1–9 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 1–4 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;
|
||||
@@ -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),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./compile";
|
||||
export * from "./geometry";
|
||||
export * from "./rules";
|
||||
export * from "./types";
|
||||
export * from "./validation";
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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] }),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./document";
|
||||
export * from "./fpuzzles";
|
||||
export * from "./grid";
|
||||
export * from "./share";
|
||||
export * from "./types";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./killer";
|
||||
export * from "./relations";
|
||||
export * from "./residual";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./exact";
|
||||
export * from "./generator";
|
||||
export * from "./killer";
|
||||
export * from "./logical";
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./library";
|
||||
export * from "./record";
|
||||
export * from "./types";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
]),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
|
||||
import source from "./manifest.source.json";
|
||||
|
||||
export const manifest = defineToolboxApp(parseToolboxApp(source));
|
||||
@@ -0,0 +1 @@
|
||||
export const APPLICATION_VERSION = "0.1.0";
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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."));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./client";
|
||||
export * from "./protocol";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 {};
|
||||
Reference in New Issue
Block a user