feat: add gameplay assists and variant generator

This commit is contained in:
2026-08-30 17:47:10 +02:00
parent 659640b231
commit 4a9869baa0
29 changed files with 2899 additions and 110 deletions
+13
View File
@@ -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.
+19 -7
View File
@@ -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 0100 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
+13
View File
@@ -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.
+19 -7
View File
@@ -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 0100 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
+64 -15
View File
@@ -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
<input
type="number"
min="1"
max={puzzle.size * puzzle.size}
min={minimumCageSum}
max={maximumCageSum}
value={cageSum}
onChange={(event) => setCageSum(Number(event.target.value))}
/>
</label>
<button
type="button"
disabled={!atLeast(1) || !Number.isInteger(cageSum)}
disabled={!validCage}
onClick={() =>
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
</button>
<button
type="button"
className="danger"
disabled={!selectedCageExists}
onClick={() =>
onChange({
...puzzle,
constraints: removeKillerCagesAtCells(constraints, selection),
})
}
>
Remove selected cage
</button>
</div>
<p className="muted">
Adding replaces any cage touching the selection. Removing clears every
cage touching a selected cell.
</p>
<div className="button-grid">
<button
type="button"
@@ -234,7 +274,7 @@ export function ConstraintEditor({
})
}
>
V pair
Sum 5 (XV)
</button>
<button
type="button"
@@ -248,7 +288,7 @@ export function ConstraintEditor({
})
}
>
X pair
Sum 10 (XV)
</button>
<button
type="button"
@@ -261,7 +301,20 @@ export function ConstraintEditor({
})
}
>
First &lt; second
1st &lt; 2nd (inequality)
</button>
<button
type="button"
disabled={!need(2)}
onClick={() =>
append({
type: "inequality",
lesser: selection[1]!,
greater: selection[0]!,
})
}
>
1st &gt; 2nd (inequality)
</button>
</div>
<div className="inline-fields">
@@ -374,12 +427,8 @@ export function ConstraintEditor({
<button type="button" onClick={onCheck} disabled={busy}>
Check definition &amp; uniqueness
</button>
<button
type="button"
onClick={onGenerate}
disabled={busy || puzzle.size > 9}
>
Generate classic
<button type="button" onClick={onGenerate} disabled={busy}>
Open generator
</button>
</section>
</div>
+57
View File
@@ -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 (
<section className="digit-completion" aria-label="Digit completion">
<div className="digit-completion__heading">
<span>Digit progress</span>
<small>muted = complete · red = too many</small>
</div>
<div
className={`digit-completion__bar${size > 9 ? " digit-completion__bar--wide" : ""}`}
role="group"
aria-label="Highlight matching digits"
>
{completions.map((item) => (
<button
key={item.digit}
type="button"
className={`digit-completion__digit is-${item.status}${highlightedDigit === item.digit ? " is-highlighted" : ""}`}
aria-label={completionLabel(item, size)}
aria-pressed={highlightedDigit === item.digit}
disabled={!highlightingEnabled}
title={completionLabel(item, size)}
onClick={() => onHighlight(item.digit)}
>
<strong>{symbolFor(item.digit, size)}</strong>
<span>
{item.placed}/{item.target}
</span>
</button>
))}
</div>
</section>
);
}
+243
View File
@@ -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<GeneratorVariant>("classic");
const definition = useMemo(
() => GENERATOR_VARIANTS.find((item) => item.id === variant)!,
[variant],
);
const [size, setSize] = useState(9);
const [targetDifficulty, setTargetDifficulty] =
useState<GenerationDifficultyTarget>("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<HTMLFormElement>) => {
event.preventDefault();
onGenerate({
variant,
size,
targetDifficulty,
symmetry,
...(usesMarkingCount ? { constraintCount } : {}),
seed: seed.trim() || `local-${Date.now().toString(36)}`,
});
};
return (
<div className="generator-workspace stack">
<div>
<p className="eyebrow">Bounded local construction</p>
<h2>Sudoku generator</h2>
<p className="muted">
Build a seedable, uniquely checked puzzle in the worker. The requested
level guides clue removal; the finished puzzle receives an independent
evidence-based rating.
</p>
</div>
<form className="panel-section generator-form" onSubmit={submit}>
<div className="field-grid">
<label>
Variant
<select
value={variant}
onChange={(event) => {
const next = event.target.value as GeneratorVariant;
const nextDefinition = GENERATOR_VARIANTS.find(
(item) => item.id === next,
)!;
setVariant(next);
if (
!(
nextDefinition.supportedSizes as readonly number[]
).includes(size)
) {
setSize(nextDefinition.supportedSizes[0]);
}
}}
>
{GENERATOR_VARIANTS.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
</label>
<label>
Grid
<select
value={size}
onChange={(event) => setSize(Number(event.target.value))}
>
{definition.supportedSizes.map((supportedSize) => (
<option key={supportedSize} value={supportedSize}>
{supportedSize} × {supportedSize}
</option>
))}
</select>
</label>
<label>
Requested profile
<select
value={targetDifficulty}
onChange={(event) =>
setTargetDifficulty(
event.target.value as GenerationDifficultyTarget,
)
}
>
{difficultyTargets.map((target) => (
<option key={target} value={target}>
{target[0]!.toUpperCase() + target.slice(1)}
</option>
))}
</select>
</label>
<label>
Given symmetry
<select
value={symmetry}
onChange={(event) =>
setSymmetry(event.target.value as "none" | "rotational")
}
>
<option value="rotational">Rotational</option>
<option value="none">None</option>
</select>
</label>
{usesMarkingCount && (
<label>
Requested markings
<input
type="number"
min="1"
max={size * size * 2}
value={constraintCount}
onChange={(event) =>
setConstraintCount(Number(event.target.value))
}
/>
</label>
)}
<label>
Seed
<input
value={seed}
maxLength={128}
placeholder="blank = fresh local seed"
onChange={(event) => setSeed(event.target.value)}
/>
</label>
</div>
<p className="generator-description">{definition.description}</p>
<div className="action-row">
<button className="primary-button" type="submit" disabled={busy}>
{busy ? "Working locally…" : `Generate ${definition.label}`}
</button>
<button type="button" disabled={busy} onClick={onRate}>
Rate current puzzle
</button>
</div>
<p className="muted">
Difficulty is an estimate from reproducible solver evidence, not a
universal promise. Uniqueness is never claimed after a safety limit.
</p>
</form>
{assessment && (
<section
className="analysis-summary difficulty-card"
aria-live="polite"
>
<div className="section-heading">
<div>
<p className="eyebrow">Difficulty assessment</p>
<h3>{assessment.label}</h3>
</div>
<span
className={`status-pill${assessment.uniqueness === "unique" ? " status-solved" : " status-invalid"}`}
>
{assessment.uniqueness}
</span>
</div>
<div className="metric-row">
<span>
Score <strong>{assessment.score ?? "—"}/100</strong>
</span>
<span>
Givens <strong>{assessment.clueCount}</strong>
</span>
<span>
Logical steps <strong>{assessment.logicalSteps}</strong>
</span>
<span>
Hardest{" "}
<strong>{techniqueLabel(assessment.hardestTechnique)}</strong>
</span>
<span>
Search nodes <strong>{assessment.exactNodes}</strong>
</span>
{generation && (
<span>
Markings <strong>{generation.generatedConstraintCount}</strong>
</span>
)}
</div>
<p>{assessment.summary}</p>
{generation && (
<p className="muted">
Seed: <code>{String(generation.seed)}</code>
</p>
)}
</section>
)}
</div>
);
}
+18 -3
View File
@@ -11,12 +11,13 @@ export function HelpDialog({
<Modal open={open} title="Sudoku Tools help" onClose={onClose} wide>
<div className="help-grid">
<section>
<h3>Four complementary workspaces</h3>
<h3>Five complementary workspaces</h3>
<p>
<strong>Play</strong> keeps values, two kinds of notes, colours,
history and elapsed time. <strong>Set</strong> edits clues and
constraints. <strong>Solve</strong> explains logical steps and can
verify uniqueness. <strong>Helpers</strong> answers focused
constraints. <strong>Generate</strong> constructs and rates bounded,
seedable variants. <strong>Solve</strong> explains logical steps and
can verify uniqueness. <strong>Helpers</strong> answers focused
questions without changing the board.
</p>
</section>
@@ -47,6 +48,10 @@ export function HelpDialog({
<dt>Ctrl/ + Z/Y</dt>
<dd>Undo or redo</dd>
</div>
<div>
<dt>Ctrl/ + click</dt>
<dd>Highlight every placed copy of that digit</dd>
</div>
</dl>
</section>
<section>
@@ -59,6 +64,16 @@ export function HelpDialog({
explanation.
</p>
</section>
<section>
<h3>Generation and ratings</h3>
<p>
Generation runs in a worker with explicit time and search limits. A
requested level guides clue removal; the reported rating is then
calculated independently from logical techniques, clue load and
bounded exact-search evidence. A limit never becomes a false
uniqueness claim.
</p>
</section>
<section>
<h3>Import and privacy</h3>
<p>
+10 -6
View File
@@ -362,12 +362,16 @@ export function HelpersWorkspace({
value={relation}
onChange={(event) => setRelation(event.target.value)}
>
<option value="white">White Kropki · difference 1</option>
<option value="black">Black Kropki · ratio 1:2</option>
<option value="v">V · sum 5</option>
<option value="x">X · sum 10</option>
<option value="less">First &lt; second</option>
<option value="greater">First &gt; second</option>
<option value="white">White dot · difference 1</option>
<option value="black">Black dot · ratio 1:2</option>
<option value="v">Sum = 5 · XV rule</option>
<option value="x">Sum = 10 · XV rule</option>
<option value="less">
First cell &lt; second cell · inequality
</option>
<option value="greater">
First cell &gt; second cell · inequality
</option>
</select>
</label>
<label>
+26 -15
View File
@@ -10,6 +10,7 @@ interface SudokuBoardProps {
colors?: readonly number[];
candidates?: readonly number[];
selected: ReadonlySet<number>;
highlighted?: ReadonlySet<number>;
conflicts?: ReadonlySet<number>;
activeCell: number;
showCandidates?: boolean;
@@ -150,25 +151,33 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
r="0.115"
/>
);
if (constraint.type === "xv")
return (
<g
key={index}
className={`constraint-xv constraint-xv--${String(constraint.total)}`}
>
<circle cx={x} cy={y} r="0.18" />
<text x={x} y={y}>
{constraint.total}
</text>
</g>
);
const rotation = Math.atan2(b.y - a.y, b.x - a.x) * (180 / Math.PI);
return (
<text
<g
key={index}
className={`constraint-label constraint-label--${constraint.type}`}
x={x}
y={y}
transform={
constraint.type === "inequality"
? `rotate(${String(rotation)} ${String(x)} ${String(y)})`
: undefined
}
className="constraint-inequality"
transform={`translate(${String(x)} ${String(y)}) rotate(${String(rotation)})`}
>
{constraint.type === "xv"
? constraint.total === 5
? "V"
: "X"
: "<"}
</text>
<path d="M0.12 -0.17L-0.12 0L0.12 0.17" />
<circle
className="constraint-inequality-tip"
cx="-0.12"
cy="0"
r="0.035"
/>
</g>
);
}
return null;
@@ -198,6 +207,7 @@ export function SudokuBoard({
colors = [],
candidates = [],
selected,
highlighted = new Set<number>(),
conflicts = new Set<number>(),
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])}` : "",
+171 -44
View File
@@ -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<EntryMode>("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<number | null>(null);
const [past, setPast] = useState<HistoryEntry[]>([]);
const [future, setFuture] = useState<HistoryEntry[]>([]);
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState<Feedback>();
const [logical, setLogical] = useState<LogicalSolveResult>();
const [exact, setExact] = useState<ExactSolveResult>();
const [difficulty, setDifficulty] = useState<DifficultyAssessment>();
const [generation, setGeneration] = useState<GeneratedVariantPuzzle>();
const [solveError, setSolveError] = useState<string>();
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<number>()
: 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() {
<select
defaultValue=""
onChange={(event) => {
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 = "";
}}
>
<option value="" disabled>
Built-in examples
</option>
<option value="classic">Classic 9 × 9</option>
<option value="killer">Pocket Killer 4 × 4</option>
{SAMPLE_CATALOG.map((sample) => (
<option key={sample.id} value={sample.id}>
{sample.label}
</option>
))}
</select>
</label>
<button type="button" onClick={() => 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() {
</button>
)}
</div>
{workspace === "play" && showDigitCompletion && (
<DigitCompletionBar
size={puzzle.size}
completions={completionData}
highlightedDigit={highlightedDigit}
highlightingEnabled={enableDigitHighlight}
onHighlight={(digit) =>
setHighlightedDigit((current) =>
current === digit ? null : digit,
)
}
/>
)}
<div className="board-meta">
<section className="rule-card">
<p className="eyebrow">Rules</p>
@@ -1206,6 +1301,28 @@ export function Workbench() {
/>
Highlight conflicts immediately
</label>
<label className="option-row">
<input
type="checkbox"
checked={showDigitCompletion}
onChange={(event) =>
setShowDigitCompletion(event.target.checked)
}
/>
Show digit completion bar
</label>
<label className="option-row">
<input
type="checkbox"
checked={enableDigitHighlight}
onChange={(event) => {
const enabled = event.target.checked;
setEnableDigitHighlight(enabled);
if (!enabled) setHighlightedDigit(null);
}}
/>
Enable matching-digit highlighting (Ctrl/-click or bar)
</label>
</section>
<section className="panel-section">
<button
@@ -1218,7 +1335,7 @@ export function Workbench() {
</section>
<p className="muted shortcut-note">
Z/X/C/V change mode · arrows move · Shift extends · Ctrl/ Z
undoes.
undoes · Ctrl/-click a placed digit highlights its matches.
</p>
</div>
)}
@@ -1253,11 +1370,21 @@ export function Workbench() {
)
}
onCheck={() => void checkDefinition()}
onGenerate={() => void generatePuzzle()}
onGenerate={() => setWorkspace("generate")}
/>
</div>
)}
{workspace === "generate" && (
<GeneratorWorkspace
busy={busy}
assessment={difficulty}
generation={generation}
onGenerate={(options) => void generateConfigured(options)}
onRate={() => void rateCurrentPuzzle()}
/>
)}
{workspace === "solve" && (
<SolveWorkspace
size={puzzle.size}
+52
View File
@@ -0,0 +1,52 @@
import type { KillerCageConstraint, VariantConstraint } from "../domain/types";
function touchesAnyCell(
constraint: KillerCageConstraint,
cells: ReadonlySet<number>,
): 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),
);
}
+253 -4
View File
@@ -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 19 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 19 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 16 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 16 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 16 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 16 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 16 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 16 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 16 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 14 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 16 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 16 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,
);
+254
View File
@@ -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 0100 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<Partial<Record<LogicalTechnique, number>>>;
readonly exactNodes: number;
readonly exactTruncated: boolean;
readonly exactLimitReason?: ExactLimitReason;
readonly summary: string;
}
const TECHNIQUE_WEIGHT: Readonly<Record<LogicalTechnique, number>> = {
"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<DifficultyLevel, "unrated"> {
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<UniquenessStatus, "unique">,
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<Record<LogicalTechnique, number>> = {};
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,
};
}
+2
View File
@@ -1,4 +1,6 @@
export * from "./difficulty";
export * from "./exact";
export * from "./generator";
export * from "./killer";
export * from "./logical";
export * from "./variantGenerator";
+680
View File
@@ -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<Record<GeneratorVariant, string>> = {
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<GenerationDifficultyTarget, number> = {
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<readonly [number, number]> {
const edges: Array<readonly [number, number]> = [];
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<T extends VariantConstraint>(
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<string>();
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<string>();
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<string>();
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<number>(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,
};
}
+121
View File
@@ -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<number>(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;
}
+157 -6
View File
@@ -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,
+15
View File
@@ -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 {
+6
View File
@@ -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":
+83
View File
@@ -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();
});
@@ -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);
});
});
@@ -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<number>(81).fill(0);
values.fill(1, 0, 9);
values.fill(2, 9, 19);
render(
<DigitCompletionBar
size={9}
completions={digitCompletions(9, values)}
highlightedDigit={null}
highlightingEnabled
onHighlight={vi.fn()}
/>,
);
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");
});
});
@@ -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(
<GeneratorWorkspace
busy={false}
onGenerate={onGenerate}
onRate={vi.fn()}
/>,
);
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(
<GeneratorWorkspace busy={false} onGenerate={vi.fn()} onRate={onRate} />,
);
await user.click(
screen.getByRole("button", { name: "Rate current puzzle" }),
);
expect(onRate).toHaveBeenCalledOnce();
});
});
+44
View File
@@ -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<number>(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(
<SudokuBoard
puzzle={puzzle}
values={puzzle.givens}
selected={new Set([0])}
activeCell={0}
onCellPointerDown={vi.fn()}
onCellPointerEnter={vi.fn()}
onKeyDown={vi.fn()}
/>,
);
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();
});
});
+104
View File
@@ -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(<Workbench />);
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(<Workbench />);
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(<Workbench />);
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(<Workbench />);
@@ -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" }),
+44 -3
View File
@@ -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",
]),
);
});
});
+203
View File
@@ -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<number>(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);
});
});
+97
View File
@@ -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<number>(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]);
});
});