From 4a9869baa03b281964ccfc4a43d954a4f4c8c729 Mon Sep 17 00:00:00 2001
From: Albrecht Degering
Date: Sun, 30 Aug 2026 17:47:10 +0200
Subject: [PATCH] feat: add gameplay assists and variant generator
---
CHANGELOG.md | 13 +
README.md | 26 +-
public/CHANGELOG.md | 13 +
public/README.md | 26 +-
src/components/ConstraintEditor.tsx | 79 ++-
src/components/DigitCompletionBar.tsx | 57 ++
src/components/GeneratorWorkspace.tsx | 243 +++++++
src/components/HelpDialog.tsx | 21 +-
src/components/HelpersWorkspace.tsx | 16 +-
src/components/SudokuBoard.tsx | 41 +-
src/components/Workbench.tsx | 215 ++++--
src/components/constraintEditing.ts | 52 ++
src/data/samples.ts | 257 ++++++-
src/solver/difficulty.ts | 254 +++++++
src/solver/index.ts | 2 +
src/solver/variantGenerator.ts | 680 +++++++++++++++++++
src/state/gameplayHelpers.ts | 121 ++++
src/styles.css | 163 ++++-
src/workers/protocol.ts | 15 +
src/workers/solver.worker.ts | 6 +
tests/browser/workbench.spec.ts | 83 +++
tests/components/constraintEditing.test.ts | 37 +
tests/components/digitCompletionBar.test.tsx | 32 +
tests/components/generatorWorkspace.test.tsx | 62 ++
tests/components/sudokuBoard.test.tsx | 44 ++
tests/components/workbench.test.tsx | 104 +++
tests/data/samples.test.ts | 47 +-
tests/solver/variantGenerator.test.ts | 203 ++++++
tests/state/gameplayHelpers.test.ts | 97 +++
29 files changed, 2899 insertions(+), 110 deletions(-)
create mode 100644 src/components/DigitCompletionBar.tsx
create mode 100644 src/components/GeneratorWorkspace.tsx
create mode 100644 src/components/constraintEditing.ts
create mode 100644 src/solver/difficulty.ts
create mode 100644 src/solver/variantGenerator.ts
create mode 100644 src/state/gameplayHelpers.ts
create mode 100644 tests/components/constraintEditing.test.ts
create mode 100644 tests/components/digitCompletionBar.test.tsx
create mode 100644 tests/components/generatorWorkspace.test.tsx
create mode 100644 tests/components/sudokuBoard.test.tsx
create mode 100644 tests/solver/variantGenerator.test.ts
create mode 100644 tests/state/gameplayHelpers.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index df37137..efc7a58 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,19 @@ All notable changes are documented here.
## Unreleased
+- Added optional digit-completion counts with muted complete digits and red
+ over-completion warnings.
+- Added toggleable Ctrl/Command-click matching-digit highlights without turning
+ the highlights into an editable multi-selection.
+- Fixed cage replacement and removal in the setter so overlapping cages cannot
+ remain accidentally active.
+- Replaced ambiguous V/X pair labels on the board with numbered 5/10 badges and
+ gave inequalities a directional chevron with a marked lesser-value tip.
+- Added thirteen uniquely checked built-in examples covering every supported
+ constraint family.
+- Added bounded, seedable generation for classic and twelve variant families,
+ plus independent uniqueness and evidence-based difficulty assessment.
+
## 0.1.0 - 2026-08-30
- Initial local-first Sudoku setting, playing, solving and analysis workbench.
diff --git a/README.md b/README.md
index f36b5a1..941c390 100644
--- a/README.md
+++ b/README.md
@@ -9,20 +9,32 @@ release also runs independently from any static HTTPS host or local preview.
- **Play** — keyboard, mouse and touch entry; multi-cell selection; values,
corner/centre notes and colours; undo/redo; conflict highlighting; timer and
- local progress.
+ local progress; optional digit-completion counts and matching-digit
+ highlights.
- **Set** — givens, metadata, regions and typed constraints; uniqueness checks
- and seeded classic puzzle generation.
+ with overlap-safe cage replacement and selection-based cage removal.
+- **Generate** — seedable, bounded construction for classic and 12 variant
+ families; independent uniqueness verification and evidence-based difficulty
+ assessment.
- **Solve** — exact solution counting plus an original human-style engine whose
steps include structured evidence, placements and eliminations.
- **Helpers** — Killer combinations and candidate-aware assignments, 45-rule
residuals, and Kropki, XV or inequality relation pairs.
Classic grids and common variants share one bounded puzzle model: irregular
-regions, diagonals, Killer cages, thermometers, arrows, Kropki and XV clues,
-inequalities, renban lines, palindromes, anti-knight, anti-king and
-non-consecutive rules. Import accepts compact grid strings, this project's JSON
-format, share fragments, and the common f-puzzles fields supported by the
-model. Unknown constraints are reported rather than silently discarded.
+regions, diagonals, Killer cages, thermometers, arrows, Kropki and numbered
+5/10 sum-pair clues, inequalities, renban lines, palindromes, anti-knight,
+anti-king and non-consecutive rules. Thirteen bundled, uniquely checked examples
+demonstrate every supported constraint. Import accepts compact grid strings,
+this project's JSON format, share fragments, and the common f-puzzles fields
+supported by the model. Unknown constraints are reported rather than silently
+discarded.
+
+Generation supports only size/variant combinations which pass the bounded
+construction checks. The requested difficulty controls clue-removal targets;
+the displayed 0–100 result is calculated afterwards from logical techniques,
+clue load and reproducible exact-search evidence. A timeout or node limit is
+reported as unknown and never promoted to a uniqueness or difficulty claim.
## Privacy and storage
diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md
index df37137..efc7a58 100644
--- a/public/CHANGELOG.md
+++ b/public/CHANGELOG.md
@@ -4,6 +4,19 @@ All notable changes are documented here.
## Unreleased
+- Added optional digit-completion counts with muted complete digits and red
+ over-completion warnings.
+- Added toggleable Ctrl/Command-click matching-digit highlights without turning
+ the highlights into an editable multi-selection.
+- Fixed cage replacement and removal in the setter so overlapping cages cannot
+ remain accidentally active.
+- Replaced ambiguous V/X pair labels on the board with numbered 5/10 badges and
+ gave inequalities a directional chevron with a marked lesser-value tip.
+- Added thirteen uniquely checked built-in examples covering every supported
+ constraint family.
+- Added bounded, seedable generation for classic and twelve variant families,
+ plus independent uniqueness and evidence-based difficulty assessment.
+
## 0.1.0 - 2026-08-30
- Initial local-first Sudoku setting, playing, solving and analysis workbench.
diff --git a/public/README.md b/public/README.md
index f36b5a1..941c390 100644
--- a/public/README.md
+++ b/public/README.md
@@ -9,20 +9,32 @@ release also runs independently from any static HTTPS host or local preview.
- **Play** — keyboard, mouse and touch entry; multi-cell selection; values,
corner/centre notes and colours; undo/redo; conflict highlighting; timer and
- local progress.
+ local progress; optional digit-completion counts and matching-digit
+ highlights.
- **Set** — givens, metadata, regions and typed constraints; uniqueness checks
- and seeded classic puzzle generation.
+ with overlap-safe cage replacement and selection-based cage removal.
+- **Generate** — seedable, bounded construction for classic and 12 variant
+ families; independent uniqueness verification and evidence-based difficulty
+ assessment.
- **Solve** — exact solution counting plus an original human-style engine whose
steps include structured evidence, placements and eliminations.
- **Helpers** — Killer combinations and candidate-aware assignments, 45-rule
residuals, and Kropki, XV or inequality relation pairs.
Classic grids and common variants share one bounded puzzle model: irregular
-regions, diagonals, Killer cages, thermometers, arrows, Kropki and XV clues,
-inequalities, renban lines, palindromes, anti-knight, anti-king and
-non-consecutive rules. Import accepts compact grid strings, this project's JSON
-format, share fragments, and the common f-puzzles fields supported by the
-model. Unknown constraints are reported rather than silently discarded.
+regions, diagonals, Killer cages, thermometers, arrows, Kropki and numbered
+5/10 sum-pair clues, inequalities, renban lines, palindromes, anti-knight,
+anti-king and non-consecutive rules. Thirteen bundled, uniquely checked examples
+demonstrate every supported constraint. Import accepts compact grid strings,
+this project's JSON format, share fragments, and the common f-puzzles fields
+supported by the model. Unknown constraints are reported rather than silently
+discarded.
+
+Generation supports only size/variant combinations which pass the bounded
+construction checks. The requested difficulty controls clue-removal targets;
+the displayed 0–100 result is calculated afterwards from logical techniques,
+clue load and reproducible exact-search evidence. A timeout or node limit is
+reported as unknown and never promoted to a uniqueness or difficulty claim.
## Privacy and storage
diff --git a/src/components/ConstraintEditor.tsx b/src/components/ConstraintEditor.tsx
index 168a17c..0ba5e27 100644
--- a/src/components/ConstraintEditor.tsx
+++ b/src/components/ConstraintEditor.tsx
@@ -1,5 +1,10 @@
import { useState } from "react";
import type { PuzzleDefinition, VariantConstraint } from "../domain/types";
+import {
+ removeKillerCagesAtCells,
+ replaceOverlappingKillerCages,
+ selectionTouchesKillerCage,
+} from "./constraintEditing";
interface ConstraintEditorProps {
puzzle: PuzzleDefinition;
@@ -34,7 +39,7 @@ function describeConstraint(constraint: VariantConstraint, size: number) {
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)}`;
+ return `sum ${String(constraint.total)} pair · ${cell(constraint.a)}–${cell(constraint.b)}`;
case "inequality":
return `${cell(constraint.lesser)} < ${cell(constraint.greater)}`;
}
@@ -57,6 +62,17 @@ export function ConstraintEditor({
onChange({ ...puzzle, constraints: [...constraints, constraint] });
const need = (count: number) => selection.length === count;
const atLeast = (count: number) => selection.length >= count;
+ const cageCellCount = Math.max(1, selection.length);
+ const minimumCageSum = (cageCellCount * (cageCellCount + 1)) / 2;
+ const maximumCageSum =
+ (cageCellCount * (2 * puzzle.size - cageCellCount + 1)) / 2;
+ const validCage =
+ selection.length >= 1 &&
+ selection.length <= puzzle.size &&
+ Number.isInteger(cageSum) &&
+ cageSum >= minimumCageSum &&
+ cageSum <= maximumCageSum;
+ const selectedCageExists = selectionTouchesKillerCage(constraints, selection);
const toggleGlobal = (
type: "anti-knight" | "anti-king" | "non-consecutive",
@@ -143,22 +159,46 @@ export function ConstraintEditor({
Cage sum
setCageSum(Number(event.target.value))}
/>
- append({ type: "killer-cage", cells: selection, sum: cageSum })
+ onChange({
+ ...puzzle,
+ constraints: replaceOverlappingKillerCages(constraints, {
+ type: "killer-cage",
+ cells: selection,
+ sum: cageSum,
+ }),
+ })
}
>
- Add cage
+ Add / replace cage
+
+
+ onChange({
+ ...puzzle,
+ constraints: removeKillerCagesAtCells(constraints, selection),
+ })
+ }
+ >
+ Remove selected cage
+
+ Adding replaces any cage touching the selection. Removing clears every
+ cage touching a selected cell.
+
- V pair
+ Sum 5 (XV)
- X pair
+ Sum 10 (XV)
- First < second
+ 1st < 2nd (inequality)
+
+
+ append({
+ type: "inequality",
+ lesser: selection[1]!,
+ greater: selection[0]!,
+ })
+ }
+ >
+ 1st > 2nd (inequality)
@@ -374,12 +427,8 @@ export function ConstraintEditor({
Check definition & uniqueness
- 9}
- >
- Generate classic
+
+ Open generator
diff --git a/src/components/DigitCompletionBar.tsx b/src/components/DigitCompletionBar.tsx
new file mode 100644
index 0000000..ab17488
--- /dev/null
+++ b/src/components/DigitCompletionBar.tsx
@@ -0,0 +1,57 @@
+import type { DigitCompletion } from "../state/gameplayHelpers";
+import { symbolFor } from "../state/session";
+
+function completionLabel(item: DigitCompletion, size: number): string {
+ const symbol = symbolFor(item.digit, size);
+ if (item.status === "done")
+ return `Digit ${symbol}: complete, ${String(item.placed)} of ${String(item.target)} placed`;
+ if (item.status === "overdone")
+ return `Digit ${symbol}: overdone by ${String(item.excess)}, ${String(item.placed)} of ${String(item.target)} placed`;
+ return `Digit ${symbol}: ${String(item.remaining)} remaining, ${String(item.placed)} of ${String(item.target)} placed`;
+}
+
+export function DigitCompletionBar({
+ size,
+ completions,
+ highlightedDigit,
+ highlightingEnabled,
+ onHighlight,
+}: {
+ size: number;
+ completions: readonly DigitCompletion[];
+ highlightedDigit: number | null;
+ highlightingEnabled: boolean;
+ onHighlight: (digit: number) => void;
+}) {
+ return (
+
+
+ Digit progress
+ muted = complete · red = too many
+
+ 9 ? " digit-completion__bar--wide" : ""}`}
+ role="group"
+ aria-label="Highlight matching digits"
+ >
+ {completions.map((item) => (
+ onHighlight(item.digit)}
+ >
+ {symbolFor(item.digit, size)}
+
+ {item.placed}/{item.target}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/GeneratorWorkspace.tsx b/src/components/GeneratorWorkspace.tsx
new file mode 100644
index 0000000..71275ee
--- /dev/null
+++ b/src/components/GeneratorWorkspace.tsx
@@ -0,0 +1,243 @@
+import { useMemo, useState, type FormEvent } from "react";
+import {
+ GENERATOR_VARIANTS,
+ type DifficultyAssessment,
+ type GeneratedVariantPuzzle,
+ type GenerationDifficultyTarget,
+ type GeneratorVariant,
+ type GenerateVariantOptions,
+} from "../solver";
+
+const difficultyTargets: readonly GenerationDifficultyTarget[] = [
+ "beginner",
+ "easy",
+ "medium",
+ "hard",
+ "expert",
+];
+
+function techniqueLabel(value: string | undefined): string {
+ return value === undefined
+ ? "Direct placements"
+ : value
+ .split("-")
+ .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
+ .join(" ");
+}
+
+export function GeneratorWorkspace({
+ busy,
+ assessment,
+ generation,
+ onGenerate,
+ onRate,
+}: {
+ busy: boolean;
+ assessment?: DifficultyAssessment;
+ generation?: GeneratedVariantPuzzle;
+ onGenerate: (options: GenerateVariantOptions) => void;
+ onRate: () => void;
+}) {
+ const [variant, setVariant] = useState("classic");
+ const definition = useMemo(
+ () => GENERATOR_VARIANTS.find((item) => item.id === variant)!,
+ [variant],
+ );
+ const [size, setSize] = useState(9);
+ const [targetDifficulty, setTargetDifficulty] =
+ useState("medium");
+ const [symmetry, setSymmetry] = useState<"none" | "rotational">("rotational");
+ const [constraintCount, setConstraintCount] = useState(8);
+ const [seed, setSeed] = useState("");
+ const usesMarkingCount = ![
+ "classic",
+ "diagonal",
+ "killer",
+ "anti-knight",
+ "anti-king",
+ "non-consecutive",
+ ].includes(variant);
+
+ const submit = (event: FormEvent) => {
+ event.preventDefault();
+ onGenerate({
+ variant,
+ size,
+ targetDifficulty,
+ symmetry,
+ ...(usesMarkingCount ? { constraintCount } : {}),
+ seed: seed.trim() || `local-${Date.now().toString(36)}`,
+ });
+ };
+
+ return (
+
+
+
Bounded local construction
+
Sudoku generator
+
+ Build a seedable, uniquely checked puzzle in the worker. The requested
+ level guides clue removal; the finished puzzle receives an independent
+ evidence-based rating.
+
+
+
+
+
+ {assessment && (
+
+
+
+
Difficulty assessment
+
{assessment.label}
+
+
+ {assessment.uniqueness}
+
+
+
+
+ Score {assessment.score ?? "—"}/100
+
+
+ Givens {assessment.clueCount}
+
+
+ Logical steps {assessment.logicalSteps}
+
+
+ Hardest{" "}
+ {techniqueLabel(assessment.hardestTechnique)}
+
+
+ Search nodes {assessment.exactNodes}
+
+ {generation && (
+
+ Markings {generation.generatedConstraintCount}
+
+ )}
+
+ {assessment.summary}
+ {generation && (
+
+ Seed: {String(generation.seed)}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx
index 90ddb90..ecb63a3 100644
--- a/src/components/HelpDialog.tsx
+++ b/src/components/HelpDialog.tsx
@@ -11,12 +11,13 @@ export function HelpDialog({
- Four complementary workspaces
+ Five complementary workspaces
Play keeps values, two kinds of notes, colours,
history and elapsed time. Set edits clues and
- constraints. Solve explains logical steps and can
- verify uniqueness. Helpers answers focused
+ constraints. Generate constructs and rates bounded,
+ seedable variants. Solve explains logical steps and
+ can verify uniqueness. Helpers answers focused
questions without changing the board.
@@ -47,6 +48,10 @@ export function HelpDialog({
Ctrl/⌘ + Z/Y
Undo or redo
+
+
Ctrl/⌘ + click
+ Highlight every placed copy of that digit
+
@@ -59,6 +64,16 @@ export function HelpDialog({
explanation.
+
Import and privacy
diff --git a/src/components/HelpersWorkspace.tsx b/src/components/HelpersWorkspace.tsx
index e018701..9ffcc71 100644
--- a/src/components/HelpersWorkspace.tsx
+++ b/src/components/HelpersWorkspace.tsx
@@ -362,12 +362,16 @@ export function HelpersWorkspace({
value={relation}
onChange={(event) => setRelation(event.target.value)}
>
- White Kropki · difference 1
- Black Kropki · ratio 1:2
- V · sum 5
- X · sum 10
- First < second
- First > second
+ White dot · difference 1
+ Black dot · ratio 1:2
+ Sum = 5 · XV rule
+ Sum = 10 · XV rule
+
+ First cell < second cell · inequality
+
+
+ First cell > second cell · inequality
+
diff --git a/src/components/SudokuBoard.tsx b/src/components/SudokuBoard.tsx
index 7aa35c3..d75bdfa 100644
--- a/src/components/SudokuBoard.tsx
+++ b/src/components/SudokuBoard.tsx
@@ -10,6 +10,7 @@ interface SudokuBoardProps {
colors?: readonly number[];
candidates?: readonly number[];
selected: ReadonlySet;
+ highlighted?: ReadonlySet;
conflicts?: ReadonlySet;
activeCell: number;
showCandidates?: boolean;
@@ -150,25 +151,33 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
r="0.115"
/>
);
+ if (constraint.type === "xv")
+ return (
+
+
+
+ {constraint.total}
+
+
+ );
const rotation = Math.atan2(b.y - a.y, b.x - a.x) * (180 / Math.PI);
return (
-
- {constraint.type === "xv"
- ? constraint.total === 5
- ? "V"
- : "X"
- : "<"}
-
+
+
+
);
}
return null;
@@ -198,6 +207,7 @@ export function SudokuBoard({
colors = [],
candidates = [],
selected,
+ highlighted = new Set(),
conflicts = new Set(),
activeCell,
showCandidates,
@@ -237,6 +247,7 @@ export function SudokuBoard({
className={[
"sudoku-cell",
isGiven ? "is-given" : "",
+ highlighted.has(cell) ? "is-digit-highlighted" : "",
selected.has(cell) ? "is-selected" : "",
conflicts.has(cell) ? "has-conflict" : "",
colors[cell] ? `has-color-${String(colors[cell])}` : "",
diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx
index d136ba7..3137920 100644
--- a/src/components/Workbench.tsx
+++ b/src/components/Workbench.tsx
@@ -24,8 +24,14 @@ import {
toDomainPuzzle,
type SudokuDocument,
} from "../formats";
-import { CLASSIC_SAMPLE, KILLER_SAMPLE } from "../data/samples";
-import type { ExactSolveResult, LogicalSolveResult } from "../solver";
+import { CLASSIC_SAMPLE, SAMPLE_CATALOG } from "../data/samples";
+import type {
+ DifficultyAssessment,
+ ExactSolveResult,
+ GeneratedVariantPuzzle,
+ GenerateVariantOptions,
+ LogicalSolveResult,
+} from "../solver";
import {
createProjectLibrary,
createProjectRecord,
@@ -46,8 +52,15 @@ import {
type PlaySession,
type PlaySnapshot,
} from "../state/session";
+import {
+ digitCompletions,
+ matchingDigitCells,
+ toggledDigitHighlight,
+} from "../state/gameplayHelpers";
import { createSolverWorkerClient, type SolverWorkerClient } from "../workers";
import { ConstraintEditor } from "./ConstraintEditor";
+import { DigitCompletionBar } from "./DigitCompletionBar";
+import { GeneratorWorkspace } from "./GeneratorWorkspace";
import { HelpersWorkspace } from "./HelpersWorkspace";
import { ImportExportDialog } from "./ImportExportDialog";
import { LibraryDialog } from "./LibraryDialog";
@@ -55,7 +68,7 @@ import { NumberPad } from "./NumberPad";
import { SolveWorkspace } from "./SolveWorkspace";
import { SudokuBoard } from "./SudokuBoard";
-type Workspace = "play" | "set" | "solve" | "helpers";
+type Workspace = "play" | "set" | "generate" | "solve" | "helpers";
type FeedbackKind = "info" | "success" | "error";
interface Feedback {
@@ -231,6 +244,7 @@ function errorMessage(error: unknown): string {
}
function constraintLabel(type: string): string {
+ if (type === "xv") return "Sum pairs (5/10)";
return type
.split("-")
.map((part) => part[0]?.toUpperCase() + part.slice(1))
@@ -247,12 +261,17 @@ export function Workbench() {
const [entryMode, setEntryMode] = useState("value");
const [showCandidates, setShowCandidates] = useState(false);
const [showConflicts, setShowConflicts] = useState(true);
+ const [showDigitCompletion, setShowDigitCompletion] = useState(true);
+ const [enableDigitHighlight, setEnableDigitHighlight] = useState(true);
+ const [highlightedDigit, setHighlightedDigit] = useState(null);
const [past, setPast] = useState([]);
const [future, setFuture] = useState([]);
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState();
const [logical, setLogical] = useState();
const [exact, setExact] = useState();
+ const [difficulty, setDifficulty] = useState();
+ const [generation, setGeneration] = useState();
const [solveError, setSolveError] = useState();
const [importOpen, setImportOpen] = useState(false);
const [libraryOpen, setLibraryOpen] = useState(false);
@@ -270,6 +289,19 @@ export function Workbench() {
const normalized = useMemo(() => safeNormalize(puzzle), [puzzle]);
const selectedSet = useMemo(() => new Set(selection), [selection]);
+ const completionData = useMemo(
+ () => digitCompletions(puzzle.size, session.values),
+ [puzzle.size, session.values],
+ );
+ const highlightedCells = useMemo(
+ () =>
+ highlightedDigit === null
+ ? new Set()
+ : new Set(
+ matchingDigitCells(puzzle.size, session.values, highlightedDigit),
+ ),
+ [highlightedDigit, puzzle.size, session.values],
+ );
const candidateMasks = useMemo(() => {
if (normalized.error) return session.values.map(() => 0);
try {
@@ -307,6 +339,8 @@ export function Workbench() {
const clearAnalysis = useCallback(() => {
setLogical(undefined);
setExact(undefined);
+ setDifficulty(undefined);
+ setGeneration(undefined);
setSolveError(undefined);
}, []);
@@ -346,6 +380,7 @@ export function Workbench() {
setSession(sessionFromDocument(valid, progress));
setSelection([0]);
setActiveCell(0);
+ setHighlightedDigit(null);
setPast([]);
setFuture([]);
setCurrentProjectId(projectId);
@@ -362,6 +397,7 @@ export function Workbench() {
setSession(sessionFromProgress(valid, record.progress));
setSelection([0]);
setActiveCell(0);
+ setHighlightedDigit(null);
setPast([]);
setFuture([]);
setCurrentProjectId(record.id);
@@ -454,11 +490,33 @@ export function Workbench() {
if (session.paused) return;
event.preventDefault();
event.currentTarget.focus();
+ const command = event.ctrlKey || event.metaKey;
+ if (
+ workspace === "play" &&
+ enableDigitHighlight &&
+ command &&
+ session.values[cell]
+ ) {
+ draggingRef.current = false;
+ changeSelection(cell, false, false);
+ setHighlightedDigit((current) =>
+ toggledDigitHighlight(puzzle.size, session.values, cell, current),
+ );
+ return;
+ }
+ if (!command) setHighlightedDigit(null);
draggingRef.current = true;
- const additive = event.shiftKey || event.ctrlKey || event.metaKey;
- changeSelection(cell, additive, event.ctrlKey || event.metaKey);
+ const additive = event.shiftKey || command;
+ changeSelection(cell, additive, command);
},
- [changeSelection, session.paused],
+ [
+ changeSelection,
+ enableDigitHighlight,
+ puzzle.size,
+ session.paused,
+ session.values,
+ workspace,
+ ],
);
const handlePointerEnter = useCallback(
@@ -750,47 +808,65 @@ export function Workbench() {
}
}, [puzzle]);
- const generatePuzzle = useCallback(async () => {
+ const generateConfigured = useCallback(
+ async (options: GenerateVariantOptions) => {
+ if (workerRef.current === null) return;
+ setBusy(true);
+ setFeedback({
+ kind: "info",
+ message:
+ "Generating and rating a uniquely checked puzzle locally. The current board stays available until it is ready…",
+ });
+ try {
+ const generated = (await workerRef.current.request(
+ { kind: "generate-variant", options },
+ { timeoutMs: 240_000 },
+ )) as GeneratedVariantPuzzle;
+ loadPuzzle(generated.puzzle);
+ setGeneration(generated);
+ setDifficulty(generated.difficulty);
+ setWorkspace("generate");
+ setFeedback({
+ kind: "success",
+ message: `Generated a unique ${generated.variant} puzzle. Rated ${generated.difficulty.label}${generated.difficulty.score === null ? "" : ` (${String(generated.difficulty.score)}/100)`}.`,
+ });
+ } catch (error) {
+ setFeedback({ kind: "error", message: errorMessage(error) });
+ } finally {
+ setBusy(false);
+ }
+ },
+ [loadPuzzle],
+ );
+
+ const rateCurrentPuzzle = useCallback(async () => {
if (workerRef.current === null) return;
+ if (normalized.error) {
+ setFeedback({ kind: "error", message: normalized.error });
+ return;
+ }
setBusy(true);
setFeedback({
kind: "info",
- message: "Generating a unique puzzle locally…",
+ message: "Checking uniqueness and rating the current puzzle locally…",
});
try {
- const generated = (await workerRef.current.request(
- {
- kind: "generate",
- options: {
- size: puzzle.size,
- targetClues: Math.max(
- puzzle.size,
- Math.round(puzzle.size * puzzle.size * 0.42),
- ),
- symmetry: "rotational",
- seed: `${Date.now().toString(36)}-${Math.random().toString(36)}`,
- },
- },
- { timeoutMs: 180_000 },
- )) as NormalizedPuzzle;
- loadPuzzle({
- ...generated,
- title: "Generated classic",
- author: "Sudoku Tools",
- rules: `Place 1–${String(generated.size)} exactly once in every row, column and outlined region.`,
- });
- setWorkspace("play");
+ const assessment = (await workerRef.current.request(
+ { kind: "difficulty", puzzle: normalized.puzzle },
+ { timeoutMs: 60_000 },
+ )) as DifficultyAssessment;
+ setGeneration(undefined);
+ setDifficulty(assessment);
setFeedback({
- kind: "success",
- message:
- "A unique rotationally symmetric puzzle was generated locally.",
+ kind: assessment.uniqueness === "unique" ? "success" : "info",
+ message: assessment.summary,
});
} catch (error) {
setFeedback({ kind: "error", message: errorMessage(error) });
} finally {
setBusy(false);
}
- }, [loadPuzzle, puzzle.size]);
+ }, [normalized]);
const checkBoard = useCallback(() => {
if (normalized.error) {
@@ -990,20 +1066,24 @@ export function Workbench() {
{
- const sample =
- event.target.value === "killer"
- ? KILLER_SAMPLE
- : CLASSIC_SAMPLE;
- loadPuzzle(cloneSample(sample));
- setWorkspace("play");
+ const sample = SAMPLE_CATALOG.find(
+ ({ id }) => id === event.target.value,
+ );
+ if (sample !== undefined) {
+ loadPuzzle(cloneSample(sample.puzzle));
+ setWorkspace("play");
+ }
event.target.value = "";
}}
>
Built-in examples…
- Classic 9 × 9
- Pocket Killer 4 × 4
+ {SAMPLE_CATALOG.map((sample) => (
+
+ {sample.label}
+
+ ))}
setImportOpen(true)}>
@@ -1029,6 +1109,7 @@ export function Workbench() {
[
["play", "Play"],
["set", "Set"],
+ ["generate", "Generate"],
["solve", "Solve"],
["helpers", "Helpers"],
] as const
@@ -1110,6 +1191,7 @@ export function Workbench() {
colors={session.colors}
candidates={candidateMasks}
selected={selectedSet}
+ highlighted={highlightedCells}
conflicts={conflictCells}
activeCell={activeCell}
showCandidates={showCandidates}
@@ -1130,6 +1212,19 @@ export function Workbench() {
)}
+ {workspace === "play" && showDigitCompletion && (
+
+ setHighlightedDigit((current) =>
+ current === digit ? null : digit,
+ )
+ }
+ />
+ )}
Z/X/C/V change mode · arrows move · Shift extends · Ctrl/⌘ Z
- undoes.
+ undoes · Ctrl/⌘-click a placed digit highlights its matches.
)}
@@ -1253,11 +1370,21 @@ export function Workbench() {
)
}
onCheck={() => void checkDefinition()}
- onGenerate={() => void generatePuzzle()}
+ onGenerate={() => setWorkspace("generate")}
/>
)}
+ {workspace === "generate" && (
+ void generateConfigured(options)}
+ onRate={() => void rateCurrentPuzzle()}
+ />
+ )}
+
{workspace === "solve" && (
,
+): boolean {
+ return constraint.cells.some((cell) => cells.has(cell));
+}
+
+/**
+ * Setter cages form a partition: drawing a cage replaces every existing cage
+ * that touches its cells instead of leaving hidden, overlapping sum rules.
+ */
+export function replaceOverlappingKillerCages(
+ constraints: readonly VariantConstraint[],
+ cage: KillerCageConstraint,
+): VariantConstraint[] {
+ const cells = new Set(cage.cells);
+ return [
+ ...constraints.filter(
+ (constraint) =>
+ constraint.type !== "killer-cage" || !touchesAnyCell(constraint, cells),
+ ),
+ { ...cage, cells: [...cage.cells] },
+ ];
+}
+
+/** Remove every cage touching at least one selected cell. */
+export function removeKillerCagesAtCells(
+ constraints: readonly VariantConstraint[],
+ cells: readonly number[],
+): VariantConstraint[] {
+ if (cells.length === 0) return [...constraints];
+ const selected = new Set(cells);
+ return constraints.filter(
+ (constraint) =>
+ constraint.type !== "killer-cage" ||
+ !touchesAnyCell(constraint, selected),
+ );
+}
+
+export function selectionTouchesKillerCage(
+ constraints: readonly VariantConstraint[],
+ cells: readonly number[],
+): boolean {
+ if (cells.length === 0) return false;
+ const selected = new Set(cells);
+ return constraints.some(
+ (constraint) =>
+ constraint.type === "killer-cage" && touchesAnyCell(constraint, selected),
+ );
+}
diff --git a/src/data/samples.ts b/src/data/samples.ts
index 54e10a8..d3cb437 100644
--- a/src/data/samples.ts
+++ b/src/data/samples.ts
@@ -1,12 +1,23 @@
import type { PuzzleDefinition } from "../domain/types";
-const classicGrid =
- "460000001000300476009060050804000607000854000102000805030040900928001000500000013";
+function grid(...rows: readonly string[]): number[] {
+ return [...rows.join("")].map(Number);
+}
export const CLASSIC_SAMPLE: PuzzleDefinition = {
version: 1,
size: 9,
- givens: [...classicGrid].map(Number),
+ givens: grid(
+ "460000001",
+ "000300476",
+ "009060050",
+ "804000607",
+ "000854000",
+ "102000805",
+ "030040900",
+ "928001000",
+ "500000013",
+ ),
title: "A first classic",
author: "Sudoku Tools",
rules: "Place 1–9 exactly once in every row, column and outlined 3×3 region.",
@@ -32,4 +43,242 @@ export const KILLER_SAMPLE: PuzzleDefinition = {
],
};
-export const SAMPLE_PUZZLES = [CLASSIC_SAMPLE, KILLER_SAMPLE] as const;
+export const DIAGONAL_SAMPLE: PuzzleDefinition = {
+ version: 1,
+ size: 9,
+ givens: grid(
+ "080000037",
+ "013090020",
+ "207305860",
+ "408003016",
+ "700601008",
+ "130900702",
+ "021509604",
+ "060030950",
+ "570000080",
+ ),
+ constraints: [
+ { type: "diagonal", direction: "main" },
+ { type: "diagonal", direction: "anti" },
+ ],
+ title: "Crossing paths",
+ author: "Sudoku Tools",
+ rules:
+ "Place 1–9 once in every row, column, region and on both marked long diagonals.",
+ solution: grid(
+ "985426137",
+ "613897425",
+ "247315869",
+ "498273516",
+ "752641398",
+ "136958742",
+ "321589674",
+ "864732951",
+ "579164283",
+ ),
+};
+
+export const THERMO_SAMPLE: PuzzleDefinition = {
+ version: 1,
+ size: 6,
+ givens: grid("310240", "245300", "100003", "400002", "004621", "021034"),
+ constraints: [
+ { type: "thermo", cells: [3, 9, 15, 16] },
+ { type: "thermo", cells: [6, 7, 13] },
+ { type: "thermo", cells: [12, 13, 19] },
+ { type: "thermo", cells: [29, 23, 22] },
+ ],
+ title: "Warm fronts",
+ author: "Sudoku Tools",
+ rules:
+ "Place 1–6 once in every row, column and region. Digits increase strictly from each thermometer bulb to its tip.",
+ solution: grid("316245", "245316", "152463", "463152", "534621", "621534"),
+};
+
+export const ARROW_SAMPLE: PuzzleDefinition = {
+ version: 1,
+ size: 6,
+ givens: grid("201045", "450103", "500200", "002001", "603012", "120304"),
+ constraints: [
+ { type: "arrow", bulb: [19], line: [14, 20] },
+ { type: "arrow", bulb: [7], line: [6, 13] },
+ { type: "arrow", bulb: [24], line: [25, 20] },
+ { type: "arrow", bulb: [6], line: [13, 18] },
+ ],
+ title: "Follow the sum",
+ author: "Sudoku Tools",
+ rules:
+ "Place 1–6 once in every row, column and region. Digits on each arrow line sum to the digit in its bulb.",
+ solution: grid("231645", "456123", "514236", "362451", "643512", "125364"),
+};
+
+export const KROPKI_SAMPLE: PuzzleDefinition = {
+ version: 1,
+ size: 6,
+ givens: grid("250001", "134500", "360400", "002013", "005236", "600054"),
+ constraints: [
+ { type: "kropki", a: 14, b: 20, kind: "black" },
+ { type: "kropki", a: 27, b: 28, kind: "white" },
+ { type: "kropki", a: 5, b: 11, kind: "white" },
+ { type: "kropki", a: 0, b: 6, kind: "black" },
+ ],
+ title: "Black and white",
+ author: "Sudoku Tools",
+ rules:
+ "Place 1–6 once in every row, column and region. White dots mark consecutive digits and black dots mark a 1:2 ratio. Unmarked pairs have no extra rule.",
+ solution: grid("256341", "134562", "361425", "542613", "415236", "623154"),
+};
+
+export const XV_SAMPLE: PuzzleDefinition = {
+ version: 1,
+ size: 6,
+ givens: grid("153000", "420000", "201645", "564302", "000026", "000453"),
+ constraints: [
+ { type: "xv", a: 4, b: 5, total: 10 },
+ { type: "xv", a: 14, b: 20, total: 5 },
+ { type: "xv", a: 19, b: 25, total: 10 },
+ { type: "xv", a: 12, b: 13, total: 5 },
+ ],
+ title: "Five or ten",
+ author: "Sudoku Tools",
+ rules:
+ "Place 1–6 once in every row, column and region. Numbered pair badges show a required sum of 5 or 10. Unmarked pairs have no extra rule.",
+ solution: grid("153264", "426531", "231645", "564312", "345126", "612453"),
+};
+
+export const INEQUALITY_SAMPLE: PuzzleDefinition = {
+ version: 1,
+ size: 6,
+ givens: grid("152300", "340105", "520000", "000032", "605043", "004651"),
+ constraints: [
+ { type: "inequality", lesser: 9, greater: 15 },
+ { type: "inequality", lesser: 5, greater: 4 },
+ { type: "inequality", lesser: 18, greater: 12 },
+ { type: "inequality", lesser: 9, greater: 3 },
+ { type: "inequality", lesser: 23, greater: 29 },
+ { type: "inequality", lesser: 13, greater: 14 },
+ { type: "inequality", lesser: 29, greater: 28 },
+ { type: "inequality", lesser: 22, greater: 28 },
+ ],
+ title: "Lesser and greater",
+ author: "Sudoku Tools",
+ rules:
+ "Place 1–6 once in every row, column and region. Each inequality opens toward the greater digit.",
+ solution: grid("152364", "346125", "523416", "461532", "615243", "234651"),
+};
+
+export const RENBAN_SAMPLE: PuzzleDefinition = {
+ version: 1,
+ size: 6,
+ givens: grid("104230", "002060", "050312", "213050", "040500", "025103"),
+ constraints: [
+ { type: "renban", cells: [9, 8, 7, 13] },
+ { type: "renban", cells: [20, 21, 27, 28, 34] },
+ { type: "renban", cells: [9, 15, 16, 17] },
+ { type: "renban", cells: [34, 35, 29, 28, 22] },
+ ],
+ title: "Consecutive company",
+ author: "Sudoku Tools",
+ rules:
+ "Place 1–6 once in every row, column and region. Each renban line contains a distinct consecutive set in any order.",
+ solution: grid("164235", "532461", "456312", "213654", "341526", "625143"),
+};
+
+export const PALINDROME_SAMPLE: PuzzleDefinition = {
+ version: 1,
+ size: 6,
+ givens: grid("062501", "540060", "005023", "420600", "050034", "104250"),
+ constraints: [
+ { type: "palindrome", cells: [4, 18] },
+ { type: "palindrome", cells: [10, 26] },
+ { type: "palindrome", cells: [27, 30] },
+ { type: "palindrome", cells: [24, 33] },
+ ],
+ title: "Mirror lines",
+ author: "Sudoku Tools",
+ rules:
+ "Place 1–6 once in every row, column and region. Digits on every palindrome line read the same from either end.",
+ solution: grid("362541", "541362", "615423", "423615", "256134", "134256"),
+};
+
+export const ANTI_KNIGHT_SAMPLE: PuzzleDefinition = {
+ version: 1,
+ size: 4,
+ givens: grid("0124", "4003", "2001", "1340"),
+ constraints: [{ type: "anti-knight" }],
+ title: "No knight's move",
+ author: "Sudoku Tools",
+ rules:
+ "Place 1–4 once in every row, column and region. Equal digits may not be a chess knight's move apart.",
+ solution: grid("3124", "4213", "2431", "1342"),
+};
+
+export const ANTI_KING_SAMPLE: PuzzleDefinition = {
+ version: 1,
+ size: 6,
+ givens: grid("010034", "604051", "526000", "000562", "360405", "450020"),
+ constraints: [{ type: "anti-king" }],
+ title: "Kings keep apart",
+ author: "Sudoku Tools",
+ rules:
+ "Place 1–6 once in every row, column and region. Equal digits may not touch diagonally, like kings in chess.",
+ solution: grid("215634", "634251", "526143", "143562", "362415", "451326"),
+};
+
+export const NON_CONSECUTIVE_SAMPLE: PuzzleDefinition = {
+ version: 1,
+ size: 6,
+ givens: grid("010264", "602030", "060053", "530020", "050602", "426010"),
+ constraints: [{ type: "non-consecutive" }],
+ title: "Mind the gap",
+ author: "Sudoku Tools",
+ rules:
+ "Place 1–6 once in every row, column and region. Orthogonally adjacent digits may not be consecutive.",
+ solution: grid("315264", "642531", "264153", "531426", "153642", "426315"),
+};
+
+export interface SamplePuzzleEntry {
+ readonly id: string;
+ readonly label: string;
+ readonly puzzle: PuzzleDefinition;
+}
+
+export const SAMPLE_CATALOG: readonly SamplePuzzleEntry[] = [
+ { id: "classic", label: "Classic 9 × 9", puzzle: CLASSIC_SAMPLE },
+ { id: "killer", label: "Killer 4 × 4", puzzle: KILLER_SAMPLE },
+ { id: "diagonal", label: "Diagonal 9 × 9", puzzle: DIAGONAL_SAMPLE },
+ { id: "thermo", label: "Thermo 6 × 6", puzzle: THERMO_SAMPLE },
+ { id: "arrow", label: "Arrow 6 × 6", puzzle: ARROW_SAMPLE },
+ { id: "kropki", label: "Kropki 6 × 6", puzzle: KROPKI_SAMPLE },
+ { id: "xv", label: "Sum pairs 6 × 6", puzzle: XV_SAMPLE },
+ {
+ id: "inequality",
+ label: "Inequality 6 × 6",
+ puzzle: INEQUALITY_SAMPLE,
+ },
+ { id: "renban", label: "Renban 6 × 6", puzzle: RENBAN_SAMPLE },
+ {
+ id: "palindrome",
+ label: "Palindrome 6 × 6",
+ puzzle: PALINDROME_SAMPLE,
+ },
+ {
+ id: "anti-knight",
+ label: "Anti-knight 4 × 4",
+ puzzle: ANTI_KNIGHT_SAMPLE,
+ },
+ {
+ id: "anti-king",
+ label: "Anti-king 6 × 6",
+ puzzle: ANTI_KING_SAMPLE,
+ },
+ {
+ id: "non-consecutive",
+ label: "Non-consecutive 6 × 6",
+ puzzle: NON_CONSECUTIVE_SAMPLE,
+ },
+];
+
+export const SAMPLE_PUZZLES: readonly PuzzleDefinition[] = SAMPLE_CATALOG.map(
+ ({ puzzle }) => puzzle,
+);
diff --git a/src/solver/difficulty.ts b/src/solver/difficulty.ts
new file mode 100644
index 0000000..0741568
--- /dev/null
+++ b/src/solver/difficulty.ts
@@ -0,0 +1,254 @@
+import {
+ normalizePuzzle,
+ type NormalizedPuzzle,
+ type PuzzleDefinition,
+} from "../domain";
+import { solveExact, type ExactLimitReason } from "./exact";
+import {
+ solveLogically,
+ type LogicalSolveStatus,
+ type LogicalTechnique,
+} from "./logical";
+
+export type DifficultyLevel =
+ "beginner" | "easy" | "medium" | "hard" | "expert" | "extreme" | "unrated";
+
+export type UniquenessStatus = "unique" | "multiple" | "none" | "unknown";
+
+export interface DifficultyOptions {
+ /** Maximum logical steps considered by the human-style solver. */
+ readonly logicalMaxSteps?: number;
+ /** Maximum exact-search nodes used to verify uniqueness. */
+ readonly exactMaxNodes?: number;
+ /** Wall-clock safety limit for uniqueness verification. */
+ readonly exactTimeoutMs?: number;
+}
+
+export interface DifficultyAssessment {
+ /** A stable 0–100 estimate, or null when the puzzle cannot be rated safely. */
+ readonly score: number | null;
+ readonly level: DifficultyLevel;
+ readonly label: string;
+ readonly uniqueness: UniquenessStatus;
+ readonly clueCount: number;
+ readonly emptyCount: number;
+ readonly logicalStatus: LogicalSolveStatus;
+ readonly logicalSteps: number;
+ readonly hardestTechnique?: LogicalTechnique;
+ readonly techniqueCounts: Readonly>>;
+ readonly exactNodes: number;
+ readonly exactTruncated: boolean;
+ readonly exactLimitReason?: ExactLimitReason;
+ readonly summary: string;
+}
+
+const TECHNIQUE_WEIGHT: Readonly> = {
+ "naked-single": 3,
+ "hidden-single": 8,
+ "killer-cage": 16,
+ "naked-pair": 18,
+ pointing: 23,
+ claiming: 25,
+ "hidden-pair": 27,
+ "naked-triple": 31,
+ "hidden-triple": 36,
+ "naked-quad": 40,
+ "hidden-quad": 44,
+ "x-wing": 50,
+ "xy-wing": 57,
+ "xyz-wing": 61,
+ swordfish: 66,
+};
+
+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 levelForScore(score: number): Exclude {
+ if (score <= 20) return "beginner";
+ if (score <= 36) return "easy";
+ if (score <= 54) return "medium";
+ if (score <= 72) return "hard";
+ if (score <= 88) return "expert";
+ return "extreme";
+}
+
+function titleCase(value: string): string {
+ return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
+}
+
+function unrated(
+ normalized: NormalizedPuzzle,
+ uniqueness: Exclude,
+ logicalStatus: LogicalSolveStatus,
+ logicalSteps: number,
+ exact: {
+ readonly nodes: number;
+ readonly truncated: boolean;
+ readonly limitReason?: ExactLimitReason;
+ },
+ summary: string,
+): DifficultyAssessment {
+ const clueCount = normalized.givens.filter((value) => value !== 0).length;
+ return {
+ score: null,
+ level: "unrated",
+ label: "Unrated",
+ uniqueness,
+ clueCount,
+ emptyCount: normalized.givens.length - clueCount,
+ logicalStatus,
+ logicalSteps,
+ techniqueCounts: {},
+ exactNodes: exact.nodes,
+ exactTruncated: exact.truncated,
+ ...(exact.limitReason === undefined
+ ? {}
+ : { exactLimitReason: exact.limitReason }),
+ summary,
+ };
+}
+
+/**
+ * Rates a puzzle using only deterministic solver evidence. Uniqueness is never
+ * claimed when the exact search hits a node or time limit.
+ */
+export function evaluateDifficulty(
+ puzzle: PuzzleDefinition | NormalizedPuzzle,
+ options: DifficultyOptions = {},
+): DifficultyAssessment {
+ const normalized = normalizePuzzle(puzzle);
+ const logicalMaxSteps = boundedInteger(
+ options.logicalMaxSteps,
+ 2_000,
+ 1,
+ 10_000,
+ "logicalMaxSteps",
+ );
+ const exactMaxNodes = boundedInteger(
+ options.exactMaxNodes,
+ 2_000_000,
+ 1,
+ 100_000_000,
+ "exactMaxNodes",
+ );
+ const exactTimeoutMs = boundedInteger(
+ options.exactTimeoutMs,
+ 10_000,
+ 1,
+ 120_000,
+ "exactTimeoutMs",
+ );
+ const logical = solveLogically(normalized, { maxSteps: logicalMaxSteps });
+ const exact = solveExact(normalized, {
+ maxSolutions: 2,
+ maxNodes: exactMaxNodes,
+ timeoutMs: exactTimeoutMs,
+ });
+
+ if (exact.count === 0 && !exact.truncated) {
+ return unrated(
+ normalized,
+ "none",
+ logical.status,
+ logical.steps.length,
+ exact,
+ "The puzzle has no solution, so it cannot be rated.",
+ );
+ }
+ if (exact.count >= 2) {
+ return unrated(
+ normalized,
+ "multiple",
+ logical.status,
+ logical.steps.length,
+ exact,
+ "The puzzle has multiple solutions, so a difficulty rating would be misleading.",
+ );
+ }
+ if (exact.truncated) {
+ return unrated(
+ normalized,
+ "unknown",
+ logical.status,
+ logical.steps.length,
+ exact,
+ "The bounded uniqueness check did not finish; uniqueness and difficulty remain unknown.",
+ );
+ }
+
+ const techniqueCounts: Partial> = {};
+ let hardestTechnique: LogicalTechnique | undefined;
+ let hardestWeight = 0;
+ for (const step of logical.steps) {
+ techniqueCounts[step.technique] =
+ (techniqueCounts[step.technique] ?? 0) + 1;
+ const weight = TECHNIQUE_WEIGHT[step.technique];
+ if (weight >= hardestWeight) {
+ hardestWeight = weight;
+ hardestTechnique = step.technique;
+ }
+ }
+
+ const clueCount = normalized.givens.filter((value) => value !== 0).length;
+ const emptyCount = normalized.givens.length - clueCount;
+ const emptyRatio = emptyCount / normalized.givens.length;
+ let score: number;
+ if (logical.status === "solved") {
+ const stepLoad = Math.min(
+ 13,
+ Math.round(
+ Math.log2(logical.steps.length + 1) * 2 +
+ logical.steps.length / normalized.size,
+ ),
+ );
+ score = Math.round(3 + emptyRatio * 14 + hardestWeight * 0.78 + stepLoad);
+ } else {
+ // A solver that is stuck after the supported human techniques is at least
+ // hard. Exact node count distinguishes bounded search effort without using
+ // elapsed time, keeping the score stable across devices.
+ score = Math.round(
+ 70 +
+ emptyRatio * 8 +
+ Math.min(22, Math.log2(Math.max(1, exact.nodes)) * 2.1),
+ );
+ }
+ score = Math.max(0, Math.min(100, score));
+ const level = levelForScore(score);
+ const hardest =
+ hardestTechnique === undefined
+ ? "direct placements"
+ : hardestTechnique.replaceAll("-", " ");
+ const summary =
+ logical.status === "solved"
+ ? `Solved logically in ${String(logical.steps.length)} steps; hardest supported technique: ${hardest}.`
+ : `Unique, but the supported logical solver became ${logical.status}; exact search visited ${String(exact.nodes)} nodes.`;
+
+ return {
+ score,
+ level,
+ label: titleCase(level),
+ uniqueness: "unique",
+ clueCount,
+ emptyCount,
+ logicalStatus: logical.status,
+ logicalSteps: logical.steps.length,
+ ...(hardestTechnique === undefined ? {} : { hardestTechnique }),
+ techniqueCounts,
+ exactNodes: exact.nodes,
+ exactTruncated: false,
+ summary,
+ };
+}
diff --git a/src/solver/index.ts b/src/solver/index.ts
index 9493a6b..8f208f5 100644
--- a/src/solver/index.ts
+++ b/src/solver/index.ts
@@ -1,4 +1,6 @@
+export * from "./difficulty";
export * from "./exact";
export * from "./generator";
export * from "./killer";
export * from "./logical";
+export * from "./variantGenerator";
diff --git a/src/solver/variantGenerator.ts b/src/solver/variantGenerator.ts
new file mode 100644
index 0000000..0355229
--- /dev/null
+++ b/src/solver/variantGenerator.ts
@@ -0,0 +1,680 @@
+import {
+ classicRegions,
+ normalizePuzzle,
+ orthogonalNeighbours,
+ type NormalizedPuzzle,
+ type PuzzleDefinition,
+ type VariantConstraint,
+} from "../domain";
+import {
+ evaluateDifficulty,
+ type DifficultyAssessment,
+ type DifficultyOptions,
+} from "./difficulty";
+import { solveExact } from "./exact";
+import {
+ generateClassic,
+ minimizePuzzle,
+ type ClueSymmetry,
+} from "./generator";
+import { seededRandom, shuffled, type RandomSource } from "./random";
+
+export const GENERATOR_VARIANTS = [
+ {
+ id: "classic",
+ label: "Classic",
+ description: "Rows, columns and rectangular regions.",
+ supportedSizes: [4, 6, 9, 12, 16],
+ },
+ {
+ id: "diagonal",
+ label: "Diagonal (X)",
+ description: "Both long diagonals also contain every digit once.",
+ supportedSizes: [4, 6, 9],
+ },
+ {
+ id: "anti-knight",
+ label: "Anti-knight",
+ description: "Equal digits cannot be a chess knight's move apart.",
+ supportedSizes: [4, 6, 9],
+ },
+ {
+ id: "anti-king",
+ label: "Anti-king",
+ description: "Equal digits cannot touch diagonally like chess kings.",
+ supportedSizes: [6, 9],
+ },
+ {
+ id: "non-consecutive",
+ label: "Non-consecutive",
+ description: "Orthogonal neighbours cannot differ by one.",
+ supportedSizes: [6],
+ },
+ {
+ id: "killer",
+ label: "Killer",
+ description: "A complete partition of no-repeat sum cages.",
+ supportedSizes: [4, 6, 9],
+ },
+ {
+ id: "thermo",
+ label: "Thermo",
+ description: "Digits increase strictly from each bulb to its tip.",
+ supportedSizes: [4, 6, 9],
+ },
+ {
+ id: "arrow",
+ label: "Arrow",
+ description: "Digits on each arrow line sum to its bulb.",
+ supportedSizes: [6, 9],
+ },
+ {
+ id: "kropki",
+ label: "Kropki dots",
+ description: "Marked neighbours are consecutive or in a 1:2 ratio.",
+ supportedSizes: [4, 6, 9],
+ },
+ {
+ id: "xv",
+ label: "Sum pairs (5/10)",
+ description: "Numbered neighbouring pairs sum to 5 or 10.",
+ supportedSizes: [6, 9],
+ },
+ {
+ id: "inequality",
+ label: "Inequality",
+ description: "Each marked pair follows the shown less-than direction.",
+ supportedSizes: [4, 6, 9],
+ },
+ {
+ id: "renban",
+ label: "Renban",
+ description: "Digits on each purple line form a consecutive set.",
+ supportedSizes: [4, 6, 9],
+ },
+ {
+ id: "palindrome",
+ label: "Palindrome",
+ description: "Digits read the same in either direction along each line.",
+ supportedSizes: [4, 6, 9],
+ },
+] as const;
+
+export type GeneratorVariant = (typeof GENERATOR_VARIANTS)[number]["id"];
+export type GenerationDifficultyTarget =
+ "beginner" | "easy" | "medium" | "hard" | "expert";
+
+export interface GenerateVariantOptions {
+ readonly variant?: GeneratorVariant;
+ readonly size?: number;
+ readonly boxRows?: number;
+ readonly boxColumns?: number;
+ readonly seed?: string | number;
+ /** A clue-removal target. The returned rating is evidence-based, not promised. */
+ readonly targetDifficulty?: GenerationDifficultyTarget;
+ readonly targetClues?: number;
+ readonly symmetry?: ClueSymmetry;
+ /** Number of local markings requested. Global rules, Killer and diagonal use structural counts. */
+ readonly constraintCount?: number;
+ readonly maxChecks?: number;
+ readonly solveMaxNodes?: number;
+ readonly solveTimeoutMs?: number;
+ readonly difficulty?: DifficultyOptions;
+}
+
+export interface GeneratedVariantPuzzle {
+ readonly puzzle: NormalizedPuzzle;
+ readonly difficulty: DifficultyAssessment;
+ readonly variant: GeneratorVariant;
+ readonly seed: string | number;
+ readonly generatedConstraintCount: number;
+}
+
+const DEFAULT_CONSTRAINT_COUNTS: Readonly<
+ Record<
+ Exclude<
+ GeneratorVariant,
+ | "classic"
+ | "diagonal"
+ | "anti-knight"
+ | "anti-king"
+ | "non-consecutive"
+ | "killer"
+ >,
+ number
+ >
+> = {
+ thermo: 6,
+ arrow: 4,
+ kropki: 10,
+ xv: 8,
+ inequality: 12,
+ renban: 5,
+ palindrome: 5,
+};
+
+const RULES: Readonly> = {
+ classic:
+ "Place every digit exactly once in each row, column and outlined region.",
+ diagonal:
+ "Place every digit exactly once in each row, column, outlined region and on each marked long diagonal.",
+ "anti-knight":
+ "Place every digit exactly once in each row, column and outlined region. Equal digits may not be a chess knight's move apart.",
+ "anti-king":
+ "Place every digit exactly once in each row, column and outlined region. Equal digits may not touch diagonally, like kings in chess.",
+ "non-consecutive":
+ "Place every digit exactly once in each row, column and outlined region. Orthogonally adjacent digits may not be consecutive.",
+ killer:
+ "Place every digit exactly once in each row, column and outlined region. Digits in each dashed cage sum to its clue and do not repeat within that cage.",
+ thermo:
+ "Place every digit exactly once in each row, column and outlined region. Digits increase strictly from each thermometer bulb to its tip.",
+ arrow:
+ "Place every digit exactly once in each row, column and outlined region. Digits on each arrow line sum to the digit in its bulb.",
+ kropki:
+ "Place every digit exactly once in each row, column and outlined region. A white dot marks consecutive digits; a black dot marks a 1:2 ratio. Only marked pairs are constrained.",
+ xv: "Place every digit exactly once in each row, column and outlined region. A numbered pair badge gives the required sum of 5 or 10. Only marked pairs are constrained.",
+ inequality:
+ "Place every digit exactly once in each row, column and outlined region. Each inequality points from the lesser digit toward the greater digit.",
+ renban:
+ "Place every digit exactly once in each row, column and outlined region. Digits on each renban line are distinct and form a consecutive set in any order.",
+ palindrome:
+ "Place every digit exactly once in each row, column and outlined region. Digits on each palindrome line read the same from either end.",
+};
+
+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 getDefinition(variant: string) {
+ return GENERATOR_VARIANTS.find((definition) => definition.id === variant);
+}
+
+function clueTarget(size: number, target: GenerationDifficultyTarget): number {
+ const ratio: Record = {
+ beginner: 0.58,
+ easy: 0.49,
+ medium: 0.4,
+ hard: 0.33,
+ expert: 0.27,
+ };
+ return Math.max(size, Math.round(size * size * ratio[target]));
+}
+
+function allEdges(size: number): Array {
+ const edges: Array = [];
+ for (let cell = 0; cell < size * size; cell += 1) {
+ const row = Math.floor(cell / size);
+ const column = cell % size;
+ if (column + 1 < size) edges.push([cell, cell + 1]);
+ if (row + 1 < size) edges.push([cell, cell + size]);
+ }
+ return edges;
+}
+
+function kingNeighbours(size: number, cell: number): number[] {
+ const row = Math.floor(cell / size);
+ const column = cell % size;
+ const result: number[] = [];
+ for (let dr = -1; dr <= 1; dr += 1) {
+ for (let dc = -1; dc <= 1; dc += 1) {
+ if (dr === 0 && dc === 0) continue;
+ const nextRow = row + dr;
+ const nextColumn = column + dc;
+ if (
+ nextRow >= 0 &&
+ nextRow < size &&
+ nextColumn >= 0 &&
+ nextColumn < size
+ ) {
+ result.push(nextRow * size + nextColumn);
+ }
+ }
+ }
+ return result;
+}
+
+function selectConstraints(
+ candidates: readonly T[],
+ count: number,
+ random: RandomSource,
+): T[] {
+ return shuffled(candidates, random).slice(0, count);
+}
+
+function killerCages(
+ size: number,
+ solution: readonly number[],
+ random: RandomSource,
+): VariantConstraint[] {
+ const unassigned = new Set(
+ Array.from({ length: size * size }, (_, cell) => cell),
+ );
+ const constraints: VariantConstraint[] = [];
+ for (const start of shuffled([...unassigned], random)) {
+ if (!unassigned.has(start)) continue;
+ const target = 2 + Math.floor(random() * Math.min(3, size - 1));
+ const cells = [start];
+ const digits = new Set([solution[start]]);
+ unassigned.delete(start);
+ while (cells.length < target) {
+ const frontier = shuffled(
+ [
+ ...new Set(cells.flatMap((cell) => orthogonalNeighbours(size, cell))),
+ ].filter(
+ (cell) =>
+ unassigned.has(cell) && !digits.has(solution[cell] as number),
+ ),
+ random,
+ );
+ const next = frontier[0];
+ if (next === undefined) break;
+ cells.push(next);
+ digits.add(solution[next]);
+ unassigned.delete(next);
+ }
+ constraints.push({
+ type: "killer-cage",
+ cells,
+ sum: cells.reduce((sum, cell) => sum + (solution[cell] ?? 0), 0),
+ });
+ }
+ return constraints;
+}
+
+function thermoCandidates(
+ size: number,
+ solution: readonly number[],
+ random: RandomSource,
+): VariantConstraint[] {
+ const found: VariantConstraint[] = [];
+ const seen = new Set();
+ const visit = (path: number[]): void => {
+ if (path.length >= 3) {
+ const key = path.join(":");
+ if (!seen.has(key)) {
+ seen.add(key);
+ found.push({ type: "thermo", cells: [...path] });
+ }
+ }
+ if (path.length >= Math.min(5, size)) return;
+ const last = path.at(-1);
+ if (last === undefined) return;
+ for (const next of shuffled(orthogonalNeighbours(size, last), random)) {
+ if (
+ path.includes(next) ||
+ (solution[next] ?? 0) <= (solution[last] ?? 0)
+ ) {
+ continue;
+ }
+ visit([...path, next]);
+ }
+ };
+ for (const cell of shuffled(
+ Array.from({ length: size * size }, (_, index) => index),
+ random,
+ )) {
+ visit([cell]);
+ }
+ if (found.length > 0) return found;
+ return allEdges(size).map(([a, b]) => ({
+ type: "thermo",
+ cells: (solution[a] ?? 0) < (solution[b] ?? 0) ? [a, b] : [b, a],
+ }));
+}
+
+function arrowCandidates(
+ size: number,
+ solution: readonly number[],
+): VariantConstraint[] {
+ const found: VariantConstraint[] = [];
+ const seen = new Set();
+ for (let bulb = 0; bulb < size * size; bulb += 1) {
+ for (const first of kingNeighbours(size, bulb)) {
+ for (const second of kingNeighbours(size, first)) {
+ if (second === bulb || second === first) continue;
+ if (
+ (solution[first] ?? 0) + (solution[second] ?? 0) !==
+ solution[bulb]
+ ) {
+ continue;
+ }
+ const reverseLine = `${String(bulb)}:${String(second)}:${String(first)}`;
+ const key = `${String(bulb)}:${String(first)}:${String(second)}`;
+ if (seen.has(key) || seen.has(reverseLine)) continue;
+ seen.add(key);
+ found.push({ type: "arrow", bulb: [bulb], line: [first, second] });
+ }
+ }
+ }
+ return found;
+}
+
+function relationCandidates(
+ variant: "kropki" | "xv" | "inequality",
+ size: number,
+ solution: readonly number[],
+): VariantConstraint[] {
+ const constraints: VariantConstraint[] = [];
+ for (const [a, b] of allEdges(size)) {
+ const av = solution[a] ?? 0;
+ const bv = solution[b] ?? 0;
+ if (variant === "inequality") {
+ constraints.push(
+ av < bv
+ ? { type: "inequality", lesser: a, greater: b }
+ : { type: "inequality", lesser: b, greater: a },
+ );
+ } else if (variant === "kropki") {
+ if (Math.abs(av - bv) === 1) {
+ constraints.push({ type: "kropki", a, b, kind: "white" });
+ }
+ if (av === bv * 2 || bv === av * 2) {
+ constraints.push({ type: "kropki", a, b, kind: "black" });
+ }
+ } else {
+ const total = av + bv;
+ if (total === 5 || total === 10) {
+ constraints.push({ type: "xv", a, b, total });
+ }
+ }
+ }
+ return constraints;
+}
+
+function renbanCandidates(
+ size: number,
+ solution: readonly number[],
+ random: RandomSource,
+): VariantConstraint[] {
+ const found: VariantConstraint[] = [];
+ const seen = new Set();
+ const visit = (path: number[]): void => {
+ if (path.length >= 2) {
+ const digits = path.map((cell) => solution[cell] ?? 0);
+ const valid =
+ new Set(digits).size === digits.length &&
+ Math.max(...digits) - Math.min(...digits) === path.length - 1;
+ if (valid) {
+ const direct = path.join(":");
+ const reverse = [...path].reverse().join(":");
+ if (!seen.has(direct) && !seen.has(reverse)) {
+ seen.add(direct);
+ found.push({ type: "renban", cells: [...path] });
+ }
+ }
+ }
+ if (path.length >= Math.min(5, size)) return;
+ const last = path.at(-1);
+ if (last === undefined) return;
+ for (const next of shuffled(orthogonalNeighbours(size, last), random)) {
+ if (!path.includes(next)) visit([...path, next]);
+ }
+ };
+ for (const cell of shuffled(
+ Array.from({ length: size * size }, (_, index) => index),
+ random,
+ )) {
+ visit([cell]);
+ }
+ return found.sort(
+ (left, right) =>
+ (right.type === "renban" ? right.cells.length : 0) -
+ (left.type === "renban" ? left.cells.length : 0),
+ );
+}
+
+function palindromeCandidates(
+ size: number,
+ solution: readonly number[],
+): VariantConstraint[] {
+ const candidates: Array<{
+ readonly constraint: VariantConstraint;
+ readonly distance: number;
+ }> = [];
+ for (let a = 0; a < solution.length; a += 1) {
+ for (let b = a + 1; b < solution.length; b += 1) {
+ if (solution[a] !== solution[b]) continue;
+ const distance =
+ Math.abs(Math.floor(a / size) - Math.floor(b / size)) +
+ Math.abs((a % size) - (b % size));
+ candidates.push({
+ constraint: { type: "palindrome", cells: [a, b] },
+ distance,
+ });
+ }
+ }
+ return candidates
+ .sort((left, right) => left.distance - right.distance)
+ .map(({ constraint }) => constraint);
+}
+
+function localConstraints(
+ variant: Exclude<
+ GeneratorVariant,
+ "classic" | "diagonal" | "anti-knight" | "anti-king" | "non-consecutive"
+ >,
+ size: number,
+ solution: readonly number[],
+ count: number,
+ random: RandomSource,
+): VariantConstraint[] {
+ if (variant === "killer") return killerCages(size, solution, random);
+ const candidates = (() => {
+ switch (variant) {
+ case "thermo":
+ return thermoCandidates(size, solution, random);
+ case "arrow":
+ return arrowCandidates(size, solution);
+ case "kropki":
+ case "xv":
+ case "inequality":
+ return relationCandidates(variant, size, solution);
+ case "renban":
+ return renbanCandidates(size, solution, random);
+ case "palindrome":
+ return palindromeCandidates(size, solution);
+ }
+ })();
+ if (candidates.length === 0) {
+ throw new Error(
+ `The seeded solution did not yield a valid ${variant} marking. Try another seed.`,
+ );
+ }
+ return selectConstraints(
+ candidates,
+ Math.min(count, candidates.length),
+ random,
+ );
+}
+
+function fullSolution(
+ variant: GeneratorVariant,
+ size: number,
+ boxRows: number | undefined,
+ boxColumns: number | undefined,
+ seed: string | number,
+ maxNodes: number,
+ timeoutMs: number,
+): NormalizedPuzzle {
+ const globalVariant =
+ variant === "diagonal" ||
+ variant === "anti-knight" ||
+ variant === "anti-king" ||
+ variant === "non-consecutive";
+ if (!globalVariant) {
+ return generateClassic({
+ size,
+ boxRows,
+ boxColumns,
+ seed,
+ targetClues: size * size,
+ maxChecks: 1,
+ });
+ }
+ const constraints: VariantConstraint[] =
+ variant === "diagonal"
+ ? [
+ { type: "diagonal", direction: "main" },
+ { type: "diagonal", direction: "anti" },
+ ]
+ : [{ type: variant }];
+ const empty: PuzzleDefinition = {
+ version: 1,
+ size,
+ givens: new Array(size * size).fill(0),
+ regions: classicRegions(size, boxRows, boxColumns),
+ constraints,
+ };
+ const solved = solveExact(empty, {
+ maxSolutions: 1,
+ maxNodes,
+ timeoutMs,
+ seed: `${String(seed)}:${variant}-solution`,
+ });
+ const solution = solved.solutions[0];
+ if (solution === undefined) {
+ throw new Error(
+ `Could not construct a ${variant} solution within the ${String(timeoutMs)} ms / ${String(maxNodes)} node generation limit.`,
+ );
+ }
+ return normalizePuzzle({ ...empty, givens: solution, solution });
+}
+
+/**
+ * Creates a unique, seedable variant puzzle. Generation is suitable for a Web
+ * Worker: every exact search has explicit node/time caps and worker termination
+ * can cancel the complete operation.
+ */
+export function generateVariant(
+ options: GenerateVariantOptions = {},
+): GeneratedVariantPuzzle {
+ const variant = options.variant ?? "classic";
+ const definition = getDefinition(variant);
+ if (definition === undefined) {
+ throw new RangeError(`Unsupported generator variant: ${String(variant)}.`);
+ }
+ const size = boundedInteger(options.size, 9, 4, 16, "size");
+ if (!(definition.supportedSizes as readonly number[]).includes(size)) {
+ throw new RangeError(
+ `${definition.label} generation supports sizes ${definition.supportedSizes.join(", ")}.`,
+ );
+ }
+ const seed = options.seed ?? "sudoku-tools";
+ const targetDifficulty = options.targetDifficulty ?? "medium";
+ const targetClues = boundedInteger(
+ options.targetClues,
+ clueTarget(size, targetDifficulty),
+ 0,
+ size * size,
+ "targetClues",
+ );
+ const maxChecks = boundedInteger(
+ options.maxChecks,
+ Math.min(size * size, 72),
+ 1,
+ size * size * 10,
+ "maxChecks",
+ );
+ const solveMaxNodes = boundedInteger(
+ options.solveMaxNodes,
+ 500_000,
+ 1,
+ 100_000_000,
+ "solveMaxNodes",
+ );
+ const solveTimeoutMs = boundedInteger(
+ options.solveTimeoutMs,
+ 2_000,
+ 1,
+ 120_000,
+ "solveTimeoutMs",
+ );
+ const requestedConstraintCount = boundedInteger(
+ options.constraintCount,
+ variant === "classic" ||
+ variant === "diagonal" ||
+ variant === "anti-knight" ||
+ variant === "anti-king" ||
+ variant === "non-consecutive" ||
+ variant === "killer"
+ ? 1
+ : DEFAULT_CONSTRAINT_COUNTS[variant],
+ 1,
+ size * size * 2,
+ "constraintCount",
+ );
+ const random = seededRandom(`${String(seed)}:${variant}:constraints`);
+ const full = fullSolution(
+ variant,
+ size,
+ options.boxRows,
+ options.boxColumns,
+ seed,
+ solveMaxNodes,
+ solveTimeoutMs,
+ );
+ const solution = full.solution;
+ if (solution === undefined) {
+ throw new Error("Internal generator error: the completed grid was lost.");
+ }
+ const constraints: VariantConstraint[] =
+ variant === "classic"
+ ? []
+ : variant === "diagonal" ||
+ variant === "anti-knight" ||
+ variant === "anti-king" ||
+ variant === "non-consecutive"
+ ? [...full.constraints]
+ : localConstraints(
+ variant,
+ size,
+ solution,
+ requestedConstraintCount,
+ random,
+ );
+ const title = `${definition.label} · ${String(seed)}`.slice(0, 256);
+ const complete = normalizePuzzle({
+ version: 1,
+ size,
+ givens: solution,
+ regions: full.regions,
+ constraints,
+ solution,
+ title,
+ author: "Sudoku Tools generator",
+ rules: RULES[variant],
+ });
+ const puzzle = minimizePuzzle(complete, {
+ seed: `${String(seed)}:${variant}:clues`,
+ targetClues,
+ symmetry: options.symmetry ?? "rotational",
+ maxChecks,
+ solveMaxNodes,
+ solveTimeoutMs,
+ });
+ const difficulty = evaluateDifficulty(puzzle, options.difficulty);
+ if (difficulty.uniqueness !== "unique") {
+ throw new Error(
+ "Generated puzzle did not pass the bounded uniqueness audit; no puzzle was returned.",
+ );
+ }
+ return {
+ puzzle,
+ difficulty,
+ variant,
+ seed,
+ generatedConstraintCount: constraints.length,
+ };
+}
diff --git a/src/state/gameplayHelpers.ts b/src/state/gameplayHelpers.ts
new file mode 100644
index 0000000..0680b48
--- /dev/null
+++ b/src/state/gameplayHelpers.ts
@@ -0,0 +1,121 @@
+import { MAX_PUZZLE_SIZE, MIN_PUZZLE_SIZE } from "../domain/types";
+
+export type DigitCompletionStatus = "undone" | "done" | "overdone";
+
+/** The gameplay-facing count for one symbol in a completed Sudoku grid. */
+export interface DigitCompletion {
+ readonly digit: number;
+ readonly placed: number;
+ readonly target: number;
+ readonly remaining: number;
+ readonly excess: number;
+ readonly status: DigitCompletionStatus;
+}
+
+function assertPuzzleSize(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}.`,
+ );
+ }
+}
+
+function isDigit(value: number | undefined, size: number): value is number {
+ return (
+ Number.isInteger(value) && value !== undefined && value > 0 && value <= size
+ );
+}
+
+/**
+ * Counts placed digits for a completion bar. A valid solved size-N Sudoku has
+ * exactly N copies of every digit. Invalid or out-of-range values are ignored
+ * so a damaged in-progress document cannot make the helper itself fail.
+ */
+export function digitCompletions(
+ size: number,
+ values: readonly number[],
+): readonly DigitCompletion[] {
+ assertPuzzleSize(size);
+ const counts = new Array(size + 1).fill(0);
+ const cellCount = size * size;
+
+ for (let cell = 0; cell < Math.min(values.length, cellCount); cell += 1) {
+ const value = values[cell];
+ if (isDigit(value, size)) counts[value] = (counts[value] ?? 0) + 1;
+ }
+
+ return Array.from({ length: size }, (_, index) => {
+ const digit = index + 1;
+ const placed = counts[digit] ?? 0;
+ const delta = size - placed;
+ return {
+ digit,
+ placed,
+ target: size,
+ remaining: Math.max(0, delta),
+ excess: Math.max(0, -delta),
+ status: delta > 0 ? "undone" : delta === 0 ? "done" : "overdone",
+ };
+ });
+}
+
+/** Returns the valid placed digit at a grid cell, or null for an empty/bad cell. */
+export function placedDigitAt(
+ size: number,
+ values: readonly number[],
+ cell: number,
+): number | null {
+ assertPuzzleSize(size);
+ if (!Number.isInteger(cell) || cell < 0 || cell >= size * size) return null;
+ const value = values[cell];
+ return isDigit(value, size) ? value : null;
+}
+
+/** Finds every grid cell containing a digit, in stable row-major order. */
+export function matchingDigitCells(
+ size: number,
+ values: readonly number[],
+ digit: number,
+): readonly number[] {
+ assertPuzzleSize(size);
+ if (!isDigit(digit, size)) return [];
+ const cells: number[] = [];
+ const cellCount = Math.min(values.length, size * size);
+ for (let cell = 0; cell < cellCount; cell += 1) {
+ if (values[cell] === digit) cells.push(cell);
+ }
+ return cells;
+}
+
+/**
+ * Resolves the cells to highlight after activating digit matching on a filled
+ * cell (for example with Ctrl-click or Cmd-click). Empty cells yield no match.
+ */
+export function matchingDigitCellsAt(
+ size: number,
+ values: readonly number[],
+ cell: number,
+): readonly number[] {
+ const digit = placedDigitAt(size, values, cell);
+ return digit === null ? [] : matchingDigitCells(size, values, digit);
+}
+
+/**
+ * Toggles a digit highlight from a modified click. Calling this for an empty
+ * or invalid cell preserves the current highlight, leaving normal cell
+ * selection semantics to the UI.
+ */
+export function toggledDigitHighlight(
+ size: number,
+ values: readonly number[],
+ cell: number,
+ currentDigit: number | null,
+): number | null {
+ const clickedDigit = placedDigitAt(size, values, cell);
+ if (clickedDigit === null) return currentDigit;
+ return clickedDigit === currentDigit ? null : clickedDigit;
+}
diff --git a/src/styles.css b/src/styles.css
index 9f577c7..73c3f9a 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -520,7 +520,7 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
}
.workspace-tabs {
- grid-template-columns: repeat(4, minmax(0, 1fr));
+ grid-template-columns: repeat(5, minmax(0, 1fr));
}
.workspace-tabs button,
@@ -587,6 +587,7 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
.side-panel,
.play-panel,
.setter-panel,
+.generator-workspace,
.solve-workspace,
.helpers-workspace {
min-width: 0;
@@ -643,6 +644,100 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
filter: saturate(0.6);
}
+.digit-completion {
+ width: min(100%, 43rem);
+ display: grid;
+ gap: 0.45rem;
+ margin-inline: auto;
+ padding: 0.62rem;
+ border: 1px solid var(--toolbox-border);
+ border-radius: 0.78rem;
+ background: var(--toolbox-surface);
+}
+
+.digit-completion__heading {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 0.2rem 0.75rem;
+ color: var(--toolbox-text);
+ font-size: 0.75rem;
+ font-weight: 760;
+}
+
+.digit-completion__heading small {
+ color: var(--toolbox-muted);
+ font-size: 0.66rem;
+ font-weight: 620;
+}
+
+.digit-completion__bar {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(2.5rem, 1fr));
+ gap: 0.32rem;
+}
+
+.digit-completion__bar--wide {
+ grid-template-columns: repeat(auto-fit, minmax(2.25rem, 1fr));
+}
+
+.digit-completion__digit {
+ min-height: 2.4rem !important;
+ display: grid !important;
+ grid-template-columns: 1fr !important;
+ gap: 0.05rem !important;
+ padding: 0.28rem !important;
+ font-variant-numeric: tabular-nums;
+}
+
+.digit-completion__digit strong {
+ font-size: 0.9rem;
+ line-height: 1;
+}
+
+.digit-completion__digit span {
+ color: var(--toolbox-muted);
+ font-size: 0.58rem;
+ font-weight: 700;
+ line-height: 1;
+}
+
+.digit-completion__digit.is-done {
+ border-color: color-mix(
+ in srgb,
+ var(--toolbox-muted) 28%,
+ var(--toolbox-border)
+ );
+ background: var(--toolbox-surface-soft);
+ color: var(--toolbox-muted);
+ opacity: 0.66;
+}
+
+.digit-completion__digit.is-overdone {
+ border-color: color-mix(
+ in srgb,
+ var(--toolbox-danger) 55%,
+ var(--toolbox-border)
+ );
+ background: color-mix(
+ in srgb,
+ var(--toolbox-danger) 12%,
+ var(--toolbox-surface)
+ );
+ color: var(--toolbox-danger);
+}
+
+.digit-completion__digit.is-overdone span {
+ color: var(--toolbox-danger);
+}
+
+.digit-completion__digit.is-highlighted {
+ border-color: var(--toolbox-accent);
+ box-shadow: 0 0 0 2px
+ color-mix(in srgb, var(--toolbox-accent) 22%, transparent);
+}
+
.side-panel {
padding: clamp(0.85rem, 1.7vw, 1.2rem);
}
@@ -801,6 +896,29 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
display: flex;
}
+.generator-form {
+ background: color-mix(
+ in srgb,
+ var(--toolbox-accent-soft) 32%,
+ var(--toolbox-surface)
+ );
+}
+
+.generator-description {
+ padding: 0.55rem 0.65rem;
+ border-left: 3px solid var(--toolbox-accent);
+ background: color-mix(in srgb, var(--toolbox-accent-soft) 52%, transparent);
+ color: var(--toolbox-muted);
+ font-size: 0.8rem;
+}
+
+.difficulty-card code {
+ color: var(--toolbox-text);
+ font-family:
+ ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
+ overflow-wrap: anywhere;
+}
+
.feedback-callout,
.status-line,
.error-callout,
@@ -941,6 +1059,14 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
);
}
+.sudoku-cell.is-digit-highlighted {
+ background: color-mix(
+ in srgb,
+ var(--toolbox-accent) 12%,
+ var(--cell-fill)
+ ) !important;
+}
+
.sudoku-cell.is-selected {
background: color-mix(
in srgb,
@@ -1169,21 +1295,37 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
fill: color-mix(in srgb, var(--toolbox-text) 88%, black);
}
-.constraint-label {
+.constraint-xv circle {
+ fill: color-mix(in srgb, var(--toolbox-surface) 92%, var(--toolbox-accent));
+ stroke: color-mix(in srgb, var(--toolbox-accent) 68%, var(--toolbox-text));
+ stroke-width: clamp(1.2px, 0.2cqi, 2px);
+}
+
+.constraint-xv text {
dominant-baseline: central;
fill: var(--toolbox-text);
font-family: var(--toolbox-font);
- font-size: 0.28px;
+ font-size: 0.19px;
font-weight: 850;
paint-order: stroke;
stroke: var(--toolbox-surface);
stroke-linejoin: round;
- stroke-width: 0.07px;
+ stroke-width: 0.035px;
text-anchor: middle;
}
-.constraint-label--inequality {
- font-size: 0.38px;
+.constraint-inequality path {
+ fill: none;
+ stroke: color-mix(in srgb, var(--toolbox-text) 88%, var(--toolbox-accent));
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: clamp(2px, 0.32cqi, 3px);
+}
+
+.constraint-inequality-tip {
+ fill: var(--toolbox-accent);
+ stroke: var(--toolbox-surface);
+ stroke-width: clamp(0.8px, 0.12cqi, 1.2px);
}
/* Number and colour entry */
@@ -1883,6 +2025,10 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
flex-direction: column;
}
+ .workspace-tabs {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
.board-toolbar > :where(.toolbar-group, .action-row) {
width: 100%;
}
@@ -1979,6 +2125,10 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
min-height: 2.7rem;
}
+ .workspace-tabs {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
.board-surface {
padding: 0.25rem;
}
@@ -2078,6 +2228,7 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
.workspace-tabs,
.workbench-status,
.board-toolbar,
+ .digit-completion,
.side-panel,
.number-pad,
.paused-cover,
diff --git a/src/workers/protocol.ts b/src/workers/protocol.ts
index 55b719c..2259b83 100644
--- a/src/workers/protocol.ts
+++ b/src/workers/protocol.ts
@@ -2,7 +2,11 @@ import type { PuzzleDefinition } from "../domain";
import type {
ExactSolveOptions,
ExactSolveResult,
+ DifficultyAssessment,
+ DifficultyOptions,
GenerateClassicOptions,
+ GeneratedVariantPuzzle,
+ GenerateVariantOptions,
KillerCombinationOptions,
KillerCombinationResult,
LogicalSolveOptions,
@@ -23,6 +27,15 @@ export type SolverWorkerOperation =
readonly options?: LogicalSolveOptions;
}
| { readonly kind: "generate"; readonly options?: GenerateClassicOptions }
+ | {
+ readonly kind: "generate-variant";
+ readonly options?: GenerateVariantOptions;
+ }
+ | {
+ readonly kind: "difficulty";
+ readonly puzzle: PuzzleDefinition;
+ readonly options?: DifficultyOptions;
+ }
| {
readonly kind: "minimize";
readonly puzzle: PuzzleDefinition;
@@ -42,6 +55,8 @@ export type SolverWorkerValue =
| ExactSolveResult
| LogicalSolveResult
| NormalizedPuzzle
+ | GeneratedVariantPuzzle
+ | DifficultyAssessment
| KillerCombinationResult;
export interface SolverWorkerError {
diff --git a/src/workers/solver.worker.ts b/src/workers/solver.worker.ts
index 60afc79..92fd7b8 100644
--- a/src/workers/solver.worker.ts
+++ b/src/workers/solver.worker.ts
@@ -2,7 +2,9 @@
import { PuzzleValidationError } from "../domain";
import {
+ evaluateDifficulty,
generateClassic,
+ generateVariant,
killerDigitCombinations,
minimizePuzzle,
solveExact,
@@ -25,6 +27,10 @@ function run(request: SolverWorkerRequest): SolverWorkerValue {
return solveLogically(operation.puzzle, operation.options);
case "generate":
return generateClassic(operation.options);
+ case "generate-variant":
+ return generateVariant(operation.options);
+ case "difficulty":
+ return evaluateDifficulty(operation.puzzle, operation.options);
case "minimize":
return minimizePuzzle(operation.puzzle, operation.options);
case "killer-combinations":
diff --git a/tests/browser/workbench.spec.ts b/tests/browser/workbench.spec.ts
index 0b481e3..0449465 100644
--- a/tests/browser/workbench.spec.ts
+++ b/tests/browser/workbench.spec.ts
@@ -32,6 +32,16 @@ test("loads standalone and keeps the core play workflow local", async ({
grid.getByRole("gridcell", { name: "Row 1, column 3, empty" }),
).toBeVisible();
+ const placedFour = grid.getByRole("gridcell", {
+ name: "Row 1, column 1, 4",
+ });
+ await placedFour.click({ modifiers: ["Control"] });
+ expect(await grid.locator(".is-digit-highlighted").count()).toBeGreaterThan(
+ 1,
+ );
+ await placedFour.click({ modifiers: ["Control"] });
+ await expect(grid.locator(".is-digit-highlighted")).toHaveCount(0);
+
await page.getByRole("button", { name: "Helpers" }).click();
await expect(
page.getByRole("heading", { name: "Sudoku helpers" }),
@@ -54,3 +64,76 @@ test("loads standalone and keeps the core play workflow local", async ({
expect(runtimeErrors).toEqual([]);
});
+
+test("replaces setter cages and generates a rated variant", async ({
+ page,
+}) => {
+ const runtimeErrors: string[] = [];
+ page.on("pageerror", (error) => runtimeErrors.push(error.message));
+ page.on("console", (message) => {
+ if (message.type() === "error") runtimeErrors.push(message.text());
+ });
+
+ await page.goto("/deep/nested/sudoku/");
+ await page.getByRole("button", { name: "New blank" }).click();
+ const grid = page.getByRole("grid", { name: "9 by 9 Sudoku grid" });
+ await grid.getByRole("gridcell", { name: "Row 1, column 1, empty" }).click();
+ await grid
+ .getByRole("gridcell", { name: "Row 1, column 2, empty" })
+ .click({ modifiers: ["Shift"] });
+
+ await page.getByRole("spinbutton", { name: "Cage sum" }).fill("3");
+ await page.getByRole("button", { name: "Add / replace cage" }).click();
+ await expect(page.getByText("3 cage · 2 cells")).toBeVisible();
+ await page.getByRole("spinbutton", { name: "Cage sum" }).fill("4");
+ await page.getByRole("button", { name: "Add / replace cage" }).click();
+ await expect(page.getByText("3 cage · 2 cells")).toHaveCount(0);
+ await expect(page.getByText("4 cage · 2 cells")).toBeVisible();
+ await page.getByRole("button", { name: "Remove selected cage" }).click();
+ await expect(page.getByText("4 cage · 2 cells")).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Generate", exact: true }).click();
+ await page.getByLabel("Variant").selectOption("thermo");
+ await page
+ .getByRole("combobox", { name: "Grid", exact: true })
+ .selectOption("4");
+ await page.getByLabel("Requested profile").selectOption("beginner");
+ await page.getByLabel("Seed").fill("browser-thermo");
+ await page.getByRole("button", { name: "Generate Thermo" }).click();
+
+ await expect(
+ page.getByRole("heading", { name: "Thermo · browser-thermo" }),
+ ).toBeVisible({ timeout: 60_000 });
+ await expect(page.getByText("Difficulty assessment")).toBeVisible();
+ await expect(page.locator(".difficulty-card .status-pill")).toHaveText(
+ "unique",
+ );
+ await expect(page.locator(".constraint-thermo")).not.toHaveCount(0);
+
+ expect(runtimeErrors).toEqual([]);
+});
+
+test("uses distinct numbered sum badges and directional inequalities", async ({
+ page,
+}) => {
+ await page.goto("/deep/nested/sudoku/");
+ const examples = page.getByLabel("Open built-in puzzle");
+
+ await examples.selectOption("xv");
+ await expect(
+ page.getByRole("heading", { name: "Five or ten" }),
+ ).toBeVisible();
+ await expect(page.locator(".constraint-xv--5 text").first()).toHaveText("5");
+ await expect(page.locator(".constraint-xv--10 text").first()).toHaveText(
+ "10",
+ );
+
+ await examples.selectOption("inequality");
+ await expect(
+ page.getByRole("heading", { name: "Lesser and greater" }),
+ ).toBeVisible();
+ await expect(page.locator(".constraint-inequality")).not.toHaveCount(0);
+ await expect(
+ page.locator(".constraint-inequality-tip").first(),
+ ).toBeVisible();
+});
diff --git a/tests/components/constraintEditing.test.ts b/tests/components/constraintEditing.test.ts
new file mode 100644
index 0000000..4e36efb
--- /dev/null
+++ b/tests/components/constraintEditing.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "vitest";
+import type { VariantConstraint } from "../../src/domain";
+import {
+ removeKillerCagesAtCells,
+ replaceOverlappingKillerCages,
+ selectionTouchesKillerCage,
+} from "../../src/components/constraintEditing";
+
+const constraints: VariantConstraint[] = [
+ { type: "killer-cage", cells: [0, 1], sum: 3 },
+ { type: "killer-cage", cells: [2, 3], sum: 7 },
+ { type: "thermo", cells: [0, 4] },
+];
+
+describe("setter cage editing", () => {
+ it("replaces every overlapping cage while preserving other rules", () => {
+ expect(
+ replaceOverlappingKillerCages(constraints, {
+ type: "killer-cage",
+ cells: [1, 2],
+ sum: 5,
+ }),
+ ).toEqual([
+ { type: "thermo", cells: [0, 4] },
+ { type: "killer-cage", cells: [1, 2], sum: 5 },
+ ]);
+ });
+
+ it("removes cages by any selected member cell", () => {
+ expect(removeKillerCagesAtCells(constraints, [1])).toEqual([
+ { type: "killer-cage", cells: [2, 3], sum: 7 },
+ { type: "thermo", cells: [0, 4] },
+ ]);
+ expect(selectionTouchesKillerCage(constraints, [1])).toBe(true);
+ expect(selectionTouchesKillerCage(constraints, [4])).toBe(false);
+ });
+});
diff --git a/tests/components/digitCompletionBar.test.tsx b/tests/components/digitCompletionBar.test.tsx
new file mode 100644
index 0000000..802f782
--- /dev/null
+++ b/tests/components/digitCompletionBar.test.tsx
@@ -0,0 +1,32 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { DigitCompletionBar } from "../../src/components/DigitCompletionBar";
+import { digitCompletions } from "../../src/state/gameplayHelpers";
+
+describe("digit completion bar", () => {
+ it("renders complete digits muted and excess digits as overdone", () => {
+ const values = new Array(81).fill(0);
+ values.fill(1, 0, 9);
+ values.fill(2, 9, 19);
+
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByRole("button", { name: /Digit 1: complete/u }),
+ ).toHaveClass("is-done");
+ expect(
+ screen.getByRole("button", { name: /Digit 2: overdone by 1/u }),
+ ).toHaveClass("is-overdone");
+ expect(
+ screen.getByRole("button", { name: /Digit 3: 9 remaining/u }),
+ ).toHaveClass("is-undone");
+ });
+});
diff --git a/tests/components/generatorWorkspace.test.tsx b/tests/components/generatorWorkspace.test.tsx
new file mode 100644
index 0000000..beb0d43
--- /dev/null
+++ b/tests/components/generatorWorkspace.test.tsx
@@ -0,0 +1,62 @@
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { GeneratorWorkspace } from "../../src/components/GeneratorWorkspace";
+
+describe("Sudoku generator workspace", () => {
+ it("offers only reliable sizes and submits an explicit local recipe", async () => {
+ const user = userEvent.setup();
+ const onGenerate = vi.fn();
+ render(
+ ,
+ );
+
+ await user.selectOptions(screen.getByLabelText("Variant"), "anti-king");
+ const grid = screen.getByLabelText("Grid");
+ expect(
+ within(grid)
+ .getAllByRole("option")
+ .map((option) => option.textContent),
+ ).toEqual(["6 × 6", "9 × 9"]);
+ expect(
+ screen.queryByLabelText("Requested markings"),
+ ).not.toBeInTheDocument();
+
+ await user.selectOptions(screen.getByLabelText("Variant"), "thermo");
+ expect(screen.getByLabelText("Requested markings")).toBeInTheDocument();
+ await user.selectOptions(screen.getByLabelText("Grid"), "4");
+ await user.selectOptions(
+ screen.getByLabelText("Requested profile"),
+ "easy",
+ );
+ await user.clear(screen.getByLabelText("Seed"));
+ await user.type(screen.getByLabelText("Seed"), "repeatable-demo");
+ await user.click(screen.getByRole("button", { name: "Generate Thermo" }));
+
+ expect(onGenerate).toHaveBeenCalledWith({
+ variant: "thermo",
+ size: 4,
+ targetDifficulty: "easy",
+ symmetry: "rotational",
+ constraintCount: 8,
+ seed: "repeatable-demo",
+ });
+ });
+
+ it("exposes independent rating for the current puzzle", async () => {
+ const user = userEvent.setup();
+ const onRate = vi.fn();
+ render(
+ ,
+ );
+
+ await user.click(
+ screen.getByRole("button", { name: "Rate current puzzle" }),
+ );
+ expect(onRate).toHaveBeenCalledOnce();
+ });
+});
diff --git a/tests/components/sudokuBoard.test.tsx b/tests/components/sudokuBoard.test.tsx
new file mode 100644
index 0000000..451f7a9
--- /dev/null
+++ b/tests/components/sudokuBoard.test.tsx
@@ -0,0 +1,44 @@
+import { render } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { normalizePuzzle } from "../../src/domain";
+import { SudokuBoard } from "../../src/components/SudokuBoard";
+
+describe("Sudoku board constraint visuals", () => {
+ it("distinguishes XV sums from directional inequalities", () => {
+ const puzzle = normalizePuzzle({
+ version: 1,
+ size: 4,
+ givens: new Array(16).fill(0),
+ constraints: [
+ { type: "xv", a: 0, b: 1, total: 5 },
+ { type: "xv", a: 4, b: 5, total: 10 },
+ { type: "inequality", lesser: 8, greater: 9 },
+ ],
+ });
+
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector(".constraint-xv--5 text")).toHaveTextContent(
+ "5",
+ );
+ expect(
+ container.querySelector(".constraint-xv--10 text"),
+ ).toHaveTextContent("10");
+ expect(
+ container.querySelector(".constraint-inequality path"),
+ ).toHaveAttribute("d", "M0.12 -0.17L-0.12 0L0.12 0.17");
+ expect(
+ container.querySelector(".constraint-inequality-tip"),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/tests/components/workbench.test.tsx b/tests/components/workbench.test.tsx
index d50cf58..4356256 100644
--- a/tests/components/workbench.test.tsx
+++ b/tests/components/workbench.test.tsx
@@ -63,6 +63,102 @@ describe("Sudoku workbench", () => {
expect(undo).toBeDisabled();
});
+ it("shows digit progress and toggles matching-digit highlights separately from selection", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const completion = screen.getByRole("group", {
+ name: "Highlight matching digits",
+ });
+ expect(within(completion).getAllByRole("button")).toHaveLength(9);
+
+ const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" });
+ const four = within(grid).getByRole("gridcell", {
+ name: "Row 1, column 1, 4",
+ });
+ fireEvent.pointerDown(four, { buttons: 1, ctrlKey: true });
+
+ const highlighted = grid.querySelectorAll(".is-digit-highlighted");
+ expect(highlighted.length).toBeGreaterThan(1);
+ expect(four).toHaveAttribute("aria-selected", "true");
+
+ fireEvent.pointerDown(four, { buttons: 1, ctrlKey: true });
+ expect(grid.querySelectorAll(".is-digit-highlighted")).toHaveLength(0);
+
+ await user.click(screen.getByLabelText("Show digit completion bar"));
+ expect(
+ screen.queryByRole("group", { name: "Highlight matching digits" }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("replaces and removes setter cages through the selected cells", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole("button", { name: "New blank" }));
+ const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" });
+ fireEvent.pointerDown(
+ within(grid).getByRole("gridcell", {
+ name: "Row 1, column 2, empty",
+ }),
+ { buttons: 1, shiftKey: true },
+ );
+
+ const cageSum = screen.getByRole("spinbutton", { name: "Cage sum" });
+ await user.clear(cageSum);
+ await user.type(cageSum, "3");
+ await user.click(
+ screen.getByRole("button", { name: "Add / replace cage" }),
+ );
+ expect(screen.getByText("3 cage · 2 cells")).toBeInTheDocument();
+
+ await user.clear(cageSum);
+ await user.type(cageSum, "4");
+ await user.click(
+ screen.getByRole("button", { name: "Add / replace cage" }),
+ );
+ expect(screen.queryByText("3 cage · 2 cells")).not.toBeInTheDocument();
+ expect(screen.getByText("4 cage · 2 cells")).toBeInTheDocument();
+
+ await user.click(
+ screen.getByRole("button", { name: "Remove selected cage" }),
+ );
+ expect(screen.queryByText("4 cage · 2 cells")).not.toBeInTheDocument();
+ });
+
+ it("opens the expanded built-in variant examples", async () => {
+ const user = userEvent.setup();
+ const { container } = render( );
+
+ await user.selectOptions(
+ screen.getByLabelText("Open built-in puzzle"),
+ "thermo",
+ );
+ expect(
+ screen.getByRole("heading", { name: "Warm fronts" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("grid", { name: "6 by 6 Sudoku grid" }),
+ ).toBeInTheDocument();
+ expect(
+ container.querySelectorAll(".constraint-thermo").length,
+ ).toBeGreaterThan(0);
+
+ await user.selectOptions(
+ screen.getByLabelText("Open built-in puzzle"),
+ "xv",
+ );
+ expect(
+ screen.getByRole("heading", { name: "Five or ten" }),
+ ).toBeInTheDocument();
+ expect(container.querySelector(".constraint-xv--5 text")).toHaveTextContent(
+ "5",
+ );
+ expect(
+ container.querySelector(".constraint-xv--10 text"),
+ ).toHaveTextContent("10");
+ });
+
it("switches between setting, solving, playing and helper workspaces", async () => {
const user = userEvent.setup();
render( );
@@ -75,6 +171,14 @@ describe("Sudoku workbench", () => {
screen.getByRole("heading", { name: "Enter givens" }),
).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Generate" }));
+ expect(
+ screen.getByRole("heading", { name: "Sudoku generator" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Rate current puzzle" }),
+ ).toBeInTheDocument();
+
await user.click(screen.getByRole("button", { name: "Solve" }));
expect(
screen.getByRole("heading", { name: "Solve and verify" }),
diff --git a/tests/data/samples.test.ts b/tests/data/samples.test.ts
index 39554ed..da61254 100644
--- a/tests/data/samples.test.ts
+++ b/tests/data/samples.test.ts
@@ -1,6 +1,14 @@
import { describe, expect, it } from "vitest";
-import { SAMPLE_PUZZLES } from "../../src/data/samples";
-import { solveExact, solveLogically } from "../../src/solver";
+import {
+ CLASSIC_SAMPLE,
+ SAMPLE_CATALOG,
+ SAMPLE_PUZZLES,
+} from "../../src/data/samples";
+import {
+ GENERATOR_VARIANTS,
+ solveExact,
+ solveLogically,
+} from "../../src/solver";
describe("bundled original samples", () => {
it.each(SAMPLE_PUZZLES)("ships $title as a unique puzzle", (puzzle) => {
@@ -10,6 +18,39 @@ describe("bundled original samples", () => {
});
it("solves the generated classic with the supported logical techniques", () => {
- expect(solveLogically(SAMPLE_PUZZLES[0]).status).toBe("solved");
+ expect(solveLogically(CLASSIC_SAMPLE).status).toBe("solved");
+ });
+
+ it("provides a named example for every advertised generator variant", () => {
+ expect(new Set(SAMPLE_CATALOG.map(({ id }) => id)).size).toBe(
+ SAMPLE_CATALOG.length,
+ );
+ for (const { id } of GENERATOR_VARIANTS) {
+ expect(SAMPLE_CATALOG.some((entry) => entry.id === id)).toBe(true);
+ }
+ });
+
+ it("also demonstrates every supported global chess/adjacency rule", () => {
+ const constraintTypes = new Set(
+ SAMPLE_PUZZLES.flatMap((puzzle) =>
+ (puzzle.constraints ?? []).map(({ type }) => type),
+ ),
+ );
+ expect(constraintTypes).toEqual(
+ new Set([
+ "diagonal",
+ "anti-knight",
+ "anti-king",
+ "non-consecutive",
+ "killer-cage",
+ "thermo",
+ "arrow",
+ "kropki",
+ "xv",
+ "inequality",
+ "renban",
+ "palindrome",
+ ]),
+ );
});
});
diff --git a/tests/solver/variantGenerator.test.ts b/tests/solver/variantGenerator.test.ts
new file mode 100644
index 0000000..0f4488c
--- /dev/null
+++ b/tests/solver/variantGenerator.test.ts
@@ -0,0 +1,203 @@
+import { describe, expect, it } from "vitest";
+import { classicRegions, type PuzzleDefinition } from "../../src/domain";
+import {
+ evaluateDifficulty,
+ generateVariant,
+ GENERATOR_VARIANTS,
+ solveExact,
+ type GeneratorVariant,
+} from "../../src/solver";
+
+const solution4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const;
+
+const puzzle4: PuzzleDefinition = {
+ version: 1,
+ size: 4,
+ givens: [1, 0, 0, 4, 0, 4, 1, 0, 4, 0, 2, 0, 0, 1, 0, 3],
+ solution: solution4,
+ regions: classicRegions(4),
+ constraints: [],
+};
+
+describe("difficulty assessment", () => {
+ it("rates a unique puzzle from deterministic solver evidence", () => {
+ const first = evaluateDifficulty(puzzle4);
+ const second = evaluateDifficulty(puzzle4);
+ expect(first.uniqueness).toBe("unique");
+ expect(first.score).not.toBeNull();
+ expect(first).toEqual(second);
+ expect(first.logicalSteps).toBeGreaterThan(0);
+ });
+
+ it("does not rate puzzles with multiple solutions", () => {
+ const assessment = evaluateDifficulty({
+ ...puzzle4,
+ givens: new Array(16).fill(0),
+ solution: undefined,
+ });
+ expect(assessment.uniqueness).toBe("multiple");
+ expect(assessment.level).toBe("unrated");
+ expect(assessment.score).toBeNull();
+ });
+
+ it("never claims uniqueness after a bounded search is cut short", () => {
+ const assessment = evaluateDifficulty(puzzle4, { exactMaxNodes: 1 });
+ expect(assessment.uniqueness).toBe("unknown");
+ expect(assessment.score).toBeNull();
+ });
+});
+
+describe("variant generator", () => {
+ it("advertises only explicit, material variant definitions", () => {
+ expect(GENERATOR_VARIANTS.map(({ id }) => id)).toEqual([
+ "classic",
+ "diagonal",
+ "anti-knight",
+ "anti-king",
+ "non-consecutive",
+ "killer",
+ "thermo",
+ "arrow",
+ "kropki",
+ "xv",
+ "inequality",
+ "renban",
+ "palindrome",
+ ]);
+ expect(
+ GENERATOR_VARIANTS.every(
+ ({ description, supportedSizes }) =>
+ description.length > 0 && supportedSizes.length > 0,
+ ),
+ ).toBe(true);
+ });
+
+ it.each([
+ ["classic", 4],
+ ["diagonal", 4],
+ ["anti-knight", 4],
+ ["anti-king", 6],
+ ["non-consecutive", 6],
+ ["killer", 4],
+ ["thermo", 4],
+ ["kropki", 4],
+ ["inequality", 4],
+ ["renban", 4],
+ ["palindrome", 4],
+ ["arrow", 6],
+ ["xv", 6],
+ ] as const)("generates a unique %s puzzle", (variant, size) => {
+ const generated = generateVariant({
+ variant,
+ size,
+ seed: `test-${variant}`,
+ targetDifficulty: "beginner",
+ maxChecks: size * size,
+ solveTimeoutMs: 2_000,
+ });
+ const exact = solveExact(generated.puzzle, {
+ maxSolutions: 2,
+ timeoutMs: 5_000,
+ });
+ expect(exact.count).toBe(1);
+ expect(exact.truncated).toBe(false);
+ expect(exact.solutions[0]).toEqual(generated.puzzle.solution);
+ expect(generated.difficulty.uniqueness).toBe("unique");
+ if (variant === "classic") {
+ expect(generated.puzzle.constraints).toHaveLength(0);
+ } else if (variant === "diagonal") {
+ expect(
+ generated.puzzle.constraints.filter(({ type }) => type === "diagonal"),
+ ).toHaveLength(2);
+ } else if (variant === "killer") {
+ expect(
+ generated.puzzle.constraints.some(({ type }) => type === "killer-cage"),
+ ).toBe(true);
+ } else {
+ expect(
+ generated.puzzle.constraints.some(({ type }) => type === variant),
+ ).toBe(true);
+ }
+ });
+
+ it.each(["classic", "killer", "thermo", "arrow"] as const)(
+ "is deterministic for %s generation",
+ (variant) => {
+ const size = variant === "arrow" ? 6 : 4;
+ const options = {
+ variant,
+ size,
+ seed: "repeatable",
+ targetClues: Math.ceil((size * size) / 2),
+ maxChecks: size * size,
+ } as const;
+ expect(generateVariant(options)).toEqual(generateVariant(options));
+ },
+ );
+
+ it.each(["diagonal", "anti-knight", "anti-king", "killer", "arrow"] as const)(
+ "keeps practical 9x9 %s generation bounded",
+ (variant) => {
+ const generated = generateVariant({
+ variant,
+ size: 9,
+ seed: `practical-${variant}`,
+ targetClues: 50,
+ maxChecks: 12,
+ solveMaxNodes: 500_000,
+ solveTimeoutMs: 2_000,
+ });
+ expect(generated.puzzle.size).toBe(9);
+ expect(generated.difficulty.uniqueness).toBe("unique");
+ },
+ );
+
+ it.each([
+ ["anti-knight", 9],
+ ["anti-king", 9],
+ ["non-consecutive", 6],
+ ] as const)(
+ "constructs supported global %s grids across deterministic seeds",
+ (variant, size) => {
+ for (let seed = 0; seed < 5; seed += 1) {
+ const generated = generateVariant({
+ variant,
+ size,
+ seed,
+ targetClues: size * size,
+ maxChecks: 1,
+ solveMaxNodes: 500_000,
+ solveTimeoutMs: 2_000,
+ });
+ expect(generated.difficulty.uniqueness).toBe("unique");
+ }
+ },
+ );
+
+ it("rejects sizes outside a variant's reliable advertised range", () => {
+ expect(() => generateVariant({ variant: "arrow", size: 4 })).toThrow(
+ /supports sizes 6, 9/,
+ );
+ expect(() =>
+ generateVariant({ variant: "non-consecutive", size: 9 }),
+ ).toThrow(/supports sizes 6/);
+ });
+
+ it("labels generated rules only for the requested variant", () => {
+ const generated = generateVariant({
+ variant: "xv",
+ size: 6,
+ seed: "rules",
+ targetClues: 30,
+ maxChecks: 6,
+ });
+ expect(generated.puzzle.rules).toMatch(/numbered pair badge/i);
+ expect(generated.puzzle.rules).toMatch(/Only marked pairs/i);
+ expect(generated.puzzle.rules).not.toMatch(/Kropki|negative/i);
+ });
+
+ it("keeps the public variant ID type exhaustive", () => {
+ const ids: GeneratorVariant[] = GENERATOR_VARIANTS.map(({ id }) => id);
+ expect(new Set(ids).size).toBe(GENERATOR_VARIANTS.length);
+ });
+});
diff --git a/tests/state/gameplayHelpers.test.ts b/tests/state/gameplayHelpers.test.ts
new file mode 100644
index 0000000..8e2db06
--- /dev/null
+++ b/tests/state/gameplayHelpers.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it } from "vitest";
+import {
+ digitCompletions,
+ matchingDigitCells,
+ matchingDigitCellsAt,
+ placedDigitAt,
+ toggledDigitHighlight,
+} from "../../src/state/gameplayHelpers";
+
+describe("digit completion helpers", () => {
+ it.each([4, 6, 9, 12, 16])(
+ "reports undone, done and overdone digits on a %i by %i grid",
+ (size) => {
+ const values = new Array(size * size).fill(0);
+ values.fill(1, 0, size - 1);
+ values.fill(2, size, size * 2);
+ values.fill(3, size * 2, size * 3 + 1);
+
+ const progress = digitCompletions(size, values);
+
+ expect(progress).toHaveLength(size);
+ expect(progress[0]).toEqual({
+ digit: 1,
+ placed: size - 1,
+ target: size,
+ remaining: 1,
+ excess: 0,
+ status: "undone",
+ });
+ expect(progress[1]).toEqual({
+ digit: 2,
+ placed: size,
+ target: size,
+ remaining: 0,
+ excess: 0,
+ status: "done",
+ });
+ expect(progress[2]).toEqual({
+ digit: 3,
+ placed: size + 1,
+ target: size,
+ remaining: 0,
+ excess: 1,
+ status: "overdone",
+ });
+ },
+ );
+
+ it("ignores empty, invalid and out-of-grid values without mutating input", () => {
+ const values = [1, 1, 0, -1, 5, Number.NaN, ...new Array(12).fill(0), 1];
+ const before = [...values];
+
+ expect(digitCompletions(4, values)[0]).toMatchObject({
+ placed: 2,
+ remaining: 2,
+ status: "undone",
+ });
+ expect(values).toEqual(before);
+ });
+
+ it.each([0, 3, 17, 4.5, Number.NaN])(
+ "rejects unsupported puzzle size %s",
+ (size) => {
+ expect(() => digitCompletions(size, [])).toThrow(RangeError);
+ },
+ );
+});
+
+describe("digit match highlighting", () => {
+ const values = [4, 0, 2, 4, 0, 3, 4, 0, 2, 0, 3, 0, 4, 2, 0, 3];
+
+ it("resolves a placed digit and all of its matching cells", () => {
+ expect(placedDigitAt(4, values, 3)).toBe(4);
+ expect(matchingDigitCells(4, values, 4)).toEqual([0, 3, 6, 12]);
+ expect(matchingDigitCellsAt(4, values, 3)).toEqual([0, 3, 6, 12]);
+ });
+
+ it("treats empty, invalid and out-of-grid cells as no match", () => {
+ expect(placedDigitAt(4, values, 1)).toBeNull();
+ expect(placedDigitAt(4, values, -1)).toBeNull();
+ expect(placedDigitAt(4, values, 16)).toBeNull();
+ expect(matchingDigitCellsAt(4, values, 1)).toEqual([]);
+ expect(matchingDigitCells(4, values, 0)).toEqual([]);
+ expect(matchingDigitCells(4, values, 5)).toEqual([]);
+ });
+
+ it("toggles repeated Ctrl/Cmd-style activation and preserves empty clicks", () => {
+ expect(toggledDigitHighlight(4, values, 0, null)).toBe(4);
+ expect(toggledDigitHighlight(4, values, 3, 4)).toBeNull();
+ expect(toggledDigitHighlight(4, values, 2, 4)).toBe(2);
+ expect(toggledDigitHighlight(4, values, 1, 4)).toBe(4);
+ });
+
+ it("never returns cells beyond the declared grid", () => {
+ expect(matchingDigitCells(4, [...values, 4, 4], 4)).toEqual([0, 3, 6, 12]);
+ });
+});