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 ## 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 ## 0.1.0 - 2026-08-30
- Initial local-first Sudoku setting, playing, solving and analysis workbench. - 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, - **Play** — keyboard, mouse and touch entry; multi-cell selection; values,
corner/centre notes and colours; undo/redo; conflict highlighting; timer and corner/centre notes and colours; undo/redo; 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 - **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 - **Solve** — exact solution counting plus an original human-style engine whose
steps include structured evidence, placements and eliminations. steps include structured evidence, placements and eliminations.
- **Helpers** — Killer combinations and candidate-aware assignments, 45-rule - **Helpers** — Killer combinations and candidate-aware assignments, 45-rule
residuals, and Kropki, XV or inequality relation pairs. residuals, and Kropki, XV or inequality relation pairs.
Classic grids and common variants share one bounded puzzle model: irregular Classic grids and common variants share one bounded puzzle model: irregular
regions, diagonals, Killer cages, thermometers, arrows, Kropki and XV clues, regions, diagonals, Killer cages, thermometers, arrows, Kropki and numbered
inequalities, renban lines, palindromes, anti-knight, anti-king and 5/10 sum-pair clues, inequalities, renban lines, palindromes, anti-knight,
non-consecutive rules. Import accepts compact grid strings, this project's JSON anti-king and non-consecutive rules. Thirteen bundled, uniquely checked examples
format, share fragments, and the common f-puzzles fields supported by the demonstrate every supported constraint. Import accepts compact grid strings,
model. Unknown constraints are reported rather than silently discarded. 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 ## Privacy and storage
+13
View File
@@ -4,6 +4,19 @@ All notable changes are documented here.
## Unreleased ## 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 ## 0.1.0 - 2026-08-30
- Initial local-first Sudoku setting, playing, solving and analysis workbench. - 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, - **Play** — keyboard, mouse and touch entry; multi-cell selection; values,
corner/centre notes and colours; undo/redo; conflict highlighting; timer and corner/centre notes and colours; undo/redo; 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 - **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 - **Solve** — exact solution counting plus an original human-style engine whose
steps include structured evidence, placements and eliminations. steps include structured evidence, placements and eliminations.
- **Helpers** — Killer combinations and candidate-aware assignments, 45-rule - **Helpers** — Killer combinations and candidate-aware assignments, 45-rule
residuals, and Kropki, XV or inequality relation pairs. residuals, and Kropki, XV or inequality relation pairs.
Classic grids and common variants share one bounded puzzle model: irregular Classic grids and common variants share one bounded puzzle model: irregular
regions, diagonals, Killer cages, thermometers, arrows, Kropki and XV clues, regions, diagonals, Killer cages, thermometers, arrows, Kropki and numbered
inequalities, renban lines, palindromes, anti-knight, anti-king and 5/10 sum-pair clues, inequalities, renban lines, palindromes, anti-knight,
non-consecutive rules. Import accepts compact grid strings, this project's JSON anti-king and non-consecutive rules. Thirteen bundled, uniquely checked examples
format, share fragments, and the common f-puzzles fields supported by the demonstrate every supported constraint. Import accepts compact grid strings,
model. Unknown constraints are reported rather than silently discarded. 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 ## Privacy and storage
+64 -15
View File
@@ -1,5 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import type { PuzzleDefinition, VariantConstraint } from "../domain/types"; import type { PuzzleDefinition, VariantConstraint } from "../domain/types";
import {
removeKillerCagesAtCells,
replaceOverlappingKillerCages,
selectionTouchesKillerCage,
} from "./constraintEditing";
interface ConstraintEditorProps { interface ConstraintEditorProps {
puzzle: PuzzleDefinition; puzzle: PuzzleDefinition;
@@ -34,7 +39,7 @@ function describeConstraint(constraint: VariantConstraint, size: number) {
case "kropki": case "kropki":
return `${constraint.kind} dot · ${cell(constraint.a)}${cell(constraint.b)}`; return `${constraint.kind} dot · ${cell(constraint.a)}${cell(constraint.b)}`;
case "xv": 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": case "inequality":
return `${cell(constraint.lesser)} < ${cell(constraint.greater)}`; return `${cell(constraint.lesser)} < ${cell(constraint.greater)}`;
} }
@@ -57,6 +62,17 @@ export function ConstraintEditor({
onChange({ ...puzzle, constraints: [...constraints, constraint] }); onChange({ ...puzzle, constraints: [...constraints, constraint] });
const need = (count: number) => selection.length === count; const need = (count: number) => selection.length === count;
const atLeast = (count: number) => selection.length >= count; const atLeast = (count: number) => selection.length >= count;
const cageCellCount = Math.max(1, selection.length);
const 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 = ( const toggleGlobal = (
type: "anti-knight" | "anti-king" | "non-consecutive", type: "anti-knight" | "anti-king" | "non-consecutive",
@@ -143,22 +159,46 @@ export function ConstraintEditor({
Cage sum Cage sum
<input <input
type="number" type="number"
min="1" min={minimumCageSum}
max={puzzle.size * puzzle.size} max={maximumCageSum}
value={cageSum} value={cageSum}
onChange={(event) => setCageSum(Number(event.target.value))} onChange={(event) => setCageSum(Number(event.target.value))}
/> />
</label> </label>
<button <button
type="button" type="button"
disabled={!atLeast(1) || !Number.isInteger(cageSum)} disabled={!validCage}
onClick={() => 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> </button>
</div> </div>
<p className="muted">
Adding replaces any cage touching the selection. Removing clears every
cage touching a selected cell.
</p>
<div className="button-grid"> <div className="button-grid">
<button <button
type="button" type="button"
@@ -234,7 +274,7 @@ export function ConstraintEditor({
}) })
} }
> >
V pair Sum 5 (XV)
</button> </button>
<button <button
type="button" type="button"
@@ -248,7 +288,7 @@ export function ConstraintEditor({
}) })
} }
> >
X pair Sum 10 (XV)
</button> </button>
<button <button
type="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> </button>
</div> </div>
<div className="inline-fields"> <div className="inline-fields">
@@ -374,12 +427,8 @@ export function ConstraintEditor({
<button type="button" onClick={onCheck} disabled={busy}> <button type="button" onClick={onCheck} disabled={busy}>
Check definition &amp; uniqueness Check definition &amp; uniqueness
</button> </button>
<button <button type="button" onClick={onGenerate} disabled={busy}>
type="button" Open generator
onClick={onGenerate}
disabled={busy || puzzle.size > 9}
>
Generate classic
</button> </button>
</section> </section>
</div> </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> <Modal open={open} title="Sudoku Tools help" onClose={onClose} wide>
<div className="help-grid"> <div className="help-grid">
<section> <section>
<h3>Four complementary workspaces</h3> <h3>Five complementary workspaces</h3>
<p> <p>
<strong>Play</strong> keeps values, two kinds of notes, colours, <strong>Play</strong> keeps values, two kinds of notes, colours,
history and elapsed time. <strong>Set</strong> edits clues and history and elapsed time. <strong>Set</strong> edits clues and
constraints. <strong>Solve</strong> explains logical steps and can constraints. <strong>Generate</strong> constructs and rates bounded,
verify uniqueness. <strong>Helpers</strong> answers focused seedable variants. <strong>Solve</strong> explains logical steps and
can verify uniqueness. <strong>Helpers</strong> answers focused
questions without changing the board. questions without changing the board.
</p> </p>
</section> </section>
@@ -47,6 +48,10 @@ export function HelpDialog({
<dt>Ctrl/ + Z/Y</dt> <dt>Ctrl/ + Z/Y</dt>
<dd>Undo or redo</dd> <dd>Undo or redo</dd>
</div> </div>
<div>
<dt>Ctrl/ + click</dt>
<dd>Highlight every placed copy of that digit</dd>
</div>
</dl> </dl>
</section> </section>
<section> <section>
@@ -59,6 +64,16 @@ export function HelpDialog({
explanation. explanation.
</p> </p>
</section> </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> <section>
<h3>Import and privacy</h3> <h3>Import and privacy</h3>
<p> <p>
+10 -6
View File
@@ -362,12 +362,16 @@ export function HelpersWorkspace({
value={relation} value={relation}
onChange={(event) => setRelation(event.target.value)} onChange={(event) => setRelation(event.target.value)}
> >
<option value="white">White Kropki · difference 1</option> <option value="white">White dot · difference 1</option>
<option value="black">Black Kropki · ratio 1:2</option> <option value="black">Black dot · ratio 1:2</option>
<option value="v">V · sum 5</option> <option value="v">Sum = 5 · XV rule</option>
<option value="x">X · sum 10</option> <option value="x">Sum = 10 · XV rule</option>
<option value="less">First &lt; second</option> <option value="less">
<option value="greater">First &gt; second</option> First cell &lt; second cell · inequality
</option>
<option value="greater">
First cell &gt; second cell · inequality
</option>
</select> </select>
</label> </label>
<label> <label>
+26 -15
View File
@@ -10,6 +10,7 @@ interface SudokuBoardProps {
colors?: readonly number[]; colors?: readonly number[];
candidates?: readonly number[]; candidates?: readonly number[];
selected: ReadonlySet<number>; selected: ReadonlySet<number>;
highlighted?: ReadonlySet<number>;
conflicts?: ReadonlySet<number>; conflicts?: ReadonlySet<number>;
activeCell: number; activeCell: number;
showCandidates?: boolean; showCandidates?: boolean;
@@ -150,25 +151,33 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
r="0.115" 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); const rotation = Math.atan2(b.y - a.y, b.x - a.x) * (180 / Math.PI);
return ( return (
<text <g
key={index} key={index}
className={`constraint-label constraint-label--${constraint.type}`} className="constraint-inequality"
x={x} transform={`translate(${String(x)} ${String(y)}) rotate(${String(rotation)})`}
y={y}
transform={
constraint.type === "inequality"
? `rotate(${String(rotation)} ${String(x)} ${String(y)})`
: undefined
}
> >
{constraint.type === "xv" <path d="M0.12 -0.17L-0.12 0L0.12 0.17" />
? constraint.total === 5 <circle
? "V" className="constraint-inequality-tip"
: "X" cx="-0.12"
: "<"} cy="0"
</text> r="0.035"
/>
</g>
); );
} }
return null; return null;
@@ -198,6 +207,7 @@ export function SudokuBoard({
colors = [], colors = [],
candidates = [], candidates = [],
selected, selected,
highlighted = new Set<number>(),
conflicts = new Set<number>(), conflicts = new Set<number>(),
activeCell, activeCell,
showCandidates, showCandidates,
@@ -237,6 +247,7 @@ export function SudokuBoard({
className={[ className={[
"sudoku-cell", "sudoku-cell",
isGiven ? "is-given" : "", isGiven ? "is-given" : "",
highlighted.has(cell) ? "is-digit-highlighted" : "",
selected.has(cell) ? "is-selected" : "", selected.has(cell) ? "is-selected" : "",
conflicts.has(cell) ? "has-conflict" : "", conflicts.has(cell) ? "has-conflict" : "",
colors[cell] ? `has-color-${String(colors[cell])}` : "", colors[cell] ? `has-color-${String(colors[cell])}` : "",
+171 -44
View File
@@ -24,8 +24,14 @@ import {
toDomainPuzzle, toDomainPuzzle,
type SudokuDocument, type SudokuDocument,
} from "../formats"; } from "../formats";
import { CLASSIC_SAMPLE, KILLER_SAMPLE } from "../data/samples"; import { CLASSIC_SAMPLE, SAMPLE_CATALOG } from "../data/samples";
import type { ExactSolveResult, LogicalSolveResult } from "../solver"; import type {
DifficultyAssessment,
ExactSolveResult,
GeneratedVariantPuzzle,
GenerateVariantOptions,
LogicalSolveResult,
} from "../solver";
import { import {
createProjectLibrary, createProjectLibrary,
createProjectRecord, createProjectRecord,
@@ -46,8 +52,15 @@ import {
type PlaySession, type PlaySession,
type PlaySnapshot, type PlaySnapshot,
} from "../state/session"; } from "../state/session";
import {
digitCompletions,
matchingDigitCells,
toggledDigitHighlight,
} from "../state/gameplayHelpers";
import { createSolverWorkerClient, type SolverWorkerClient } from "../workers"; import { createSolverWorkerClient, type SolverWorkerClient } from "../workers";
import { ConstraintEditor } from "./ConstraintEditor"; import { ConstraintEditor } from "./ConstraintEditor";
import { DigitCompletionBar } from "./DigitCompletionBar";
import { GeneratorWorkspace } from "./GeneratorWorkspace";
import { HelpersWorkspace } from "./HelpersWorkspace"; import { HelpersWorkspace } from "./HelpersWorkspace";
import { ImportExportDialog } from "./ImportExportDialog"; import { ImportExportDialog } from "./ImportExportDialog";
import { LibraryDialog } from "./LibraryDialog"; import { LibraryDialog } from "./LibraryDialog";
@@ -55,7 +68,7 @@ import { NumberPad } from "./NumberPad";
import { SolveWorkspace } from "./SolveWorkspace"; import { SolveWorkspace } from "./SolveWorkspace";
import { SudokuBoard } from "./SudokuBoard"; import { SudokuBoard } from "./SudokuBoard";
type Workspace = "play" | "set" | "solve" | "helpers"; type Workspace = "play" | "set" | "generate" | "solve" | "helpers";
type FeedbackKind = "info" | "success" | "error"; type FeedbackKind = "info" | "success" | "error";
interface Feedback { interface Feedback {
@@ -231,6 +244,7 @@ function errorMessage(error: unknown): string {
} }
function constraintLabel(type: string): string { function constraintLabel(type: string): string {
if (type === "xv") return "Sum pairs (5/10)";
return type return type
.split("-") .split("-")
.map((part) => part[0]?.toUpperCase() + part.slice(1)) .map((part) => part[0]?.toUpperCase() + part.slice(1))
@@ -247,12 +261,17 @@ export function Workbench() {
const [entryMode, setEntryMode] = useState<EntryMode>("value"); const [entryMode, setEntryMode] = useState<EntryMode>("value");
const [showCandidates, setShowCandidates] = useState(false); const [showCandidates, setShowCandidates] = useState(false);
const [showConflicts, setShowConflicts] = useState(true); 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 [past, setPast] = useState<HistoryEntry[]>([]);
const [future, setFuture] = useState<HistoryEntry[]>([]); const [future, setFuture] = useState<HistoryEntry[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState<Feedback>(); const [feedback, setFeedback] = useState<Feedback>();
const [logical, setLogical] = useState<LogicalSolveResult>(); const [logical, setLogical] = useState<LogicalSolveResult>();
const [exact, setExact] = useState<ExactSolveResult>(); const [exact, setExact] = useState<ExactSolveResult>();
const [difficulty, setDifficulty] = useState<DifficultyAssessment>();
const [generation, setGeneration] = useState<GeneratedVariantPuzzle>();
const [solveError, setSolveError] = useState<string>(); const [solveError, setSolveError] = useState<string>();
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [libraryOpen, setLibraryOpen] = useState(false); const [libraryOpen, setLibraryOpen] = useState(false);
@@ -270,6 +289,19 @@ export function Workbench() {
const normalized = useMemo(() => safeNormalize(puzzle), [puzzle]); const normalized = useMemo(() => safeNormalize(puzzle), [puzzle]);
const selectedSet = useMemo(() => new Set(selection), [selection]); 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(() => { const candidateMasks = useMemo(() => {
if (normalized.error) return session.values.map(() => 0); if (normalized.error) return session.values.map(() => 0);
try { try {
@@ -307,6 +339,8 @@ export function Workbench() {
const clearAnalysis = useCallback(() => { const clearAnalysis = useCallback(() => {
setLogical(undefined); setLogical(undefined);
setExact(undefined); setExact(undefined);
setDifficulty(undefined);
setGeneration(undefined);
setSolveError(undefined); setSolveError(undefined);
}, []); }, []);
@@ -346,6 +380,7 @@ export function Workbench() {
setSession(sessionFromDocument(valid, progress)); setSession(sessionFromDocument(valid, progress));
setSelection([0]); setSelection([0]);
setActiveCell(0); setActiveCell(0);
setHighlightedDigit(null);
setPast([]); setPast([]);
setFuture([]); setFuture([]);
setCurrentProjectId(projectId); setCurrentProjectId(projectId);
@@ -362,6 +397,7 @@ export function Workbench() {
setSession(sessionFromProgress(valid, record.progress)); setSession(sessionFromProgress(valid, record.progress));
setSelection([0]); setSelection([0]);
setActiveCell(0); setActiveCell(0);
setHighlightedDigit(null);
setPast([]); setPast([]);
setFuture([]); setFuture([]);
setCurrentProjectId(record.id); setCurrentProjectId(record.id);
@@ -454,11 +490,33 @@ export function Workbench() {
if (session.paused) return; if (session.paused) return;
event.preventDefault(); event.preventDefault();
event.currentTarget.focus(); 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; draggingRef.current = true;
const additive = event.shiftKey || event.ctrlKey || event.metaKey; const additive = event.shiftKey || command;
changeSelection(cell, additive, event.ctrlKey || event.metaKey); changeSelection(cell, additive, command);
}, },
[changeSelection, session.paused], [
changeSelection,
enableDigitHighlight,
puzzle.size,
session.paused,
session.values,
workspace,
],
); );
const handlePointerEnter = useCallback( const handlePointerEnter = useCallback(
@@ -750,47 +808,65 @@ export function Workbench() {
} }
}, [puzzle]); }, [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 (workerRef.current === null) return;
if (normalized.error) {
setFeedback({ kind: "error", message: normalized.error });
return;
}
setBusy(true); setBusy(true);
setFeedback({ setFeedback({
kind: "info", kind: "info",
message: "Generating a unique puzzle locally…", message: "Checking uniqueness and rating the current puzzle locally…",
}); });
try { try {
const generated = (await workerRef.current.request( const assessment = (await workerRef.current.request(
{ { kind: "difficulty", puzzle: normalized.puzzle },
kind: "generate", { timeoutMs: 60_000 },
options: { )) as DifficultyAssessment;
size: puzzle.size, setGeneration(undefined);
targetClues: Math.max( setDifficulty(assessment);
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");
setFeedback({ setFeedback({
kind: "success", kind: assessment.uniqueness === "unique" ? "success" : "info",
message: message: assessment.summary,
"A unique rotationally symmetric puzzle was generated locally.",
}); });
} catch (error) { } catch (error) {
setFeedback({ kind: "error", message: errorMessage(error) }); setFeedback({ kind: "error", message: errorMessage(error) });
} finally { } finally {
setBusy(false); setBusy(false);
} }
}, [loadPuzzle, puzzle.size]); }, [normalized]);
const checkBoard = useCallback(() => { const checkBoard = useCallback(() => {
if (normalized.error) { if (normalized.error) {
@@ -990,20 +1066,24 @@ export function Workbench() {
<select <select
defaultValue="" defaultValue=""
onChange={(event) => { onChange={(event) => {
const sample = const sample = SAMPLE_CATALOG.find(
event.target.value === "killer" ({ id }) => id === event.target.value,
? KILLER_SAMPLE );
: CLASSIC_SAMPLE; if (sample !== undefined) {
loadPuzzle(cloneSample(sample)); loadPuzzle(cloneSample(sample.puzzle));
setWorkspace("play"); setWorkspace("play");
}
event.target.value = ""; event.target.value = "";
}} }}
> >
<option value="" disabled> <option value="" disabled>
Built-in examples Built-in examples
</option> </option>
<option value="classic">Classic 9 × 9</option> {SAMPLE_CATALOG.map((sample) => (
<option value="killer">Pocket Killer 4 × 4</option> <option key={sample.id} value={sample.id}>
{sample.label}
</option>
))}
</select> </select>
</label> </label>
<button type="button" onClick={() => setImportOpen(true)}> <button type="button" onClick={() => setImportOpen(true)}>
@@ -1029,6 +1109,7 @@ export function Workbench() {
[ [
["play", "Play"], ["play", "Play"],
["set", "Set"], ["set", "Set"],
["generate", "Generate"],
["solve", "Solve"], ["solve", "Solve"],
["helpers", "Helpers"], ["helpers", "Helpers"],
] as const ] as const
@@ -1110,6 +1191,7 @@ export function Workbench() {
colors={session.colors} colors={session.colors}
candidates={candidateMasks} candidates={candidateMasks}
selected={selectedSet} selected={selectedSet}
highlighted={highlightedCells}
conflicts={conflictCells} conflicts={conflictCells}
activeCell={activeCell} activeCell={activeCell}
showCandidates={showCandidates} showCandidates={showCandidates}
@@ -1130,6 +1212,19 @@ export function Workbench() {
</button> </button>
)} )}
</div> </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"> <div className="board-meta">
<section className="rule-card"> <section className="rule-card">
<p className="eyebrow">Rules</p> <p className="eyebrow">Rules</p>
@@ -1206,6 +1301,28 @@ export function Workbench() {
/> />
Highlight conflicts immediately Highlight conflicts immediately
</label> </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>
<section className="panel-section"> <section className="panel-section">
<button <button
@@ -1218,7 +1335,7 @@ export function Workbench() {
</section> </section>
<p className="muted shortcut-note"> <p className="muted shortcut-note">
Z/X/C/V change mode · arrows move · Shift extends · Ctrl/ Z Z/X/C/V change mode · arrows move · Shift extends · Ctrl/ Z
undoes. undoes · Ctrl/-click a placed digit highlights its matches.
</p> </p>
</div> </div>
)} )}
@@ -1253,11 +1370,21 @@ export function Workbench() {
) )
} }
onCheck={() => void checkDefinition()} onCheck={() => void checkDefinition()}
onGenerate={() => void generatePuzzle()} onGenerate={() => setWorkspace("generate")}
/> />
</div> </div>
)} )}
{workspace === "generate" && (
<GeneratorWorkspace
busy={busy}
assessment={difficulty}
generation={generation}
onGenerate={(options) => void generateConfigured(options)}
onRate={() => void rateCurrentPuzzle()}
/>
)}
{workspace === "solve" && ( {workspace === "solve" && (
<SolveWorkspace <SolveWorkspace
size={puzzle.size} 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"; import type { PuzzleDefinition } from "../domain/types";
const classicGrid = function grid(...rows: readonly string[]): number[] {
"460000001000300476009060050804000607000854000102000805030040900928001000500000013"; return [...rows.join("")].map(Number);
}
export const CLASSIC_SAMPLE: PuzzleDefinition = { export const CLASSIC_SAMPLE: PuzzleDefinition = {
version: 1, version: 1,
size: 9, size: 9,
givens: [...classicGrid].map(Number), givens: grid(
"460000001",
"000300476",
"009060050",
"804000607",
"000854000",
"102000805",
"030040900",
"928001000",
"500000013",
),
title: "A first classic", title: "A first classic",
author: "Sudoku Tools", author: "Sudoku Tools",
rules: "Place 19 exactly once in every row, column and outlined 3×3 region.", 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 "./exact";
export * from "./generator"; export * from "./generator";
export * from "./killer"; export * from "./killer";
export * from "./logical"; 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 { .workspace-tabs {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
} }
.workspace-tabs button, .workspace-tabs button,
@@ -587,6 +587,7 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
.side-panel, .side-panel,
.play-panel, .play-panel,
.setter-panel, .setter-panel,
.generator-workspace,
.solve-workspace, .solve-workspace,
.helpers-workspace { .helpers-workspace {
min-width: 0; min-width: 0;
@@ -643,6 +644,100 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
filter: saturate(0.6); 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 { .side-panel {
padding: clamp(0.85rem, 1.7vw, 1.2rem); padding: clamp(0.85rem, 1.7vw, 1.2rem);
} }
@@ -801,6 +896,29 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
display: flex; 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, .feedback-callout,
.status-line, .status-line,
.error-callout, .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 { .sudoku-cell.is-selected {
background: color-mix( background: color-mix(
in srgb, 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); 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; dominant-baseline: central;
fill: var(--toolbox-text); fill: var(--toolbox-text);
font-family: var(--toolbox-font); font-family: var(--toolbox-font);
font-size: 0.28px; font-size: 0.19px;
font-weight: 850; font-weight: 850;
paint-order: stroke; paint-order: stroke;
stroke: var(--toolbox-surface); stroke: var(--toolbox-surface);
stroke-linejoin: round; stroke-linejoin: round;
stroke-width: 0.07px; stroke-width: 0.035px;
text-anchor: middle; text-anchor: middle;
} }
.constraint-label--inequality { .constraint-inequality path {
font-size: 0.38px; 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 */ /* Number and colour entry */
@@ -1883,6 +2025,10 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
flex-direction: column; flex-direction: column;
} }
.workspace-tabs {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.board-toolbar > :where(.toolbar-group, .action-row) { .board-toolbar > :where(.toolbar-group, .action-row) {
width: 100%; width: 100%;
} }
@@ -1979,6 +2125,10 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
min-height: 2.7rem; min-height: 2.7rem;
} }
.workspace-tabs {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.board-surface { .board-surface {
padding: 0.25rem; padding: 0.25rem;
} }
@@ -2078,6 +2228,7 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
.workspace-tabs, .workspace-tabs,
.workbench-status, .workbench-status,
.board-toolbar, .board-toolbar,
.digit-completion,
.side-panel, .side-panel,
.number-pad, .number-pad,
.paused-cover, .paused-cover,
+15
View File
@@ -2,7 +2,11 @@ import type { PuzzleDefinition } from "../domain";
import type { import type {
ExactSolveOptions, ExactSolveOptions,
ExactSolveResult, ExactSolveResult,
DifficultyAssessment,
DifficultyOptions,
GenerateClassicOptions, GenerateClassicOptions,
GeneratedVariantPuzzle,
GenerateVariantOptions,
KillerCombinationOptions, KillerCombinationOptions,
KillerCombinationResult, KillerCombinationResult,
LogicalSolveOptions, LogicalSolveOptions,
@@ -23,6 +27,15 @@ export type SolverWorkerOperation =
readonly options?: LogicalSolveOptions; readonly options?: LogicalSolveOptions;
} }
| { readonly kind: "generate"; readonly options?: GenerateClassicOptions } | { 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 kind: "minimize";
readonly puzzle: PuzzleDefinition; readonly puzzle: PuzzleDefinition;
@@ -42,6 +55,8 @@ export type SolverWorkerValue =
| ExactSolveResult | ExactSolveResult
| LogicalSolveResult | LogicalSolveResult
| NormalizedPuzzle | NormalizedPuzzle
| GeneratedVariantPuzzle
| DifficultyAssessment
| KillerCombinationResult; | KillerCombinationResult;
export interface SolverWorkerError { export interface SolverWorkerError {
+6
View File
@@ -2,7 +2,9 @@
import { PuzzleValidationError } from "../domain"; import { PuzzleValidationError } from "../domain";
import { import {
evaluateDifficulty,
generateClassic, generateClassic,
generateVariant,
killerDigitCombinations, killerDigitCombinations,
minimizePuzzle, minimizePuzzle,
solveExact, solveExact,
@@ -25,6 +27,10 @@ function run(request: SolverWorkerRequest): SolverWorkerValue {
return solveLogically(operation.puzzle, operation.options); return solveLogically(operation.puzzle, operation.options);
case "generate": case "generate":
return generateClassic(operation.options); return generateClassic(operation.options);
case "generate-variant":
return generateVariant(operation.options);
case "difficulty":
return evaluateDifficulty(operation.puzzle, operation.options);
case "minimize": case "minimize":
return minimizePuzzle(operation.puzzle, operation.options); return minimizePuzzle(operation.puzzle, operation.options);
case "killer-combinations": 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" }), grid.getByRole("gridcell", { name: "Row 1, column 3, empty" }),
).toBeVisible(); ).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 page.getByRole("button", { name: "Helpers" }).click();
await expect( await expect(
page.getByRole("heading", { name: "Sudoku helpers" }), page.getByRole("heading", { name: "Sudoku helpers" }),
@@ -54,3 +64,76 @@ test("loads standalone and keeps the core play workflow local", async ({
expect(runtimeErrors).toEqual([]); 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(); 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 () => { it("switches between setting, solving, playing and helper workspaces", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<Workbench />); render(<Workbench />);
@@ -75,6 +171,14 @@ describe("Sudoku workbench", () => {
screen.getByRole("heading", { name: "Enter givens" }), screen.getByRole("heading", { name: "Enter givens" }),
).toBeInTheDocument(); ).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" })); await user.click(screen.getByRole("button", { name: "Solve" }));
expect( expect(
screen.getByRole("heading", { name: "Solve and verify" }), screen.getByRole("heading", { name: "Solve and verify" }),
+44 -3
View File
@@ -1,6 +1,14 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { SAMPLE_PUZZLES } from "../../src/data/samples"; import {
import { solveExact, solveLogically } from "../../src/solver"; CLASSIC_SAMPLE,
SAMPLE_CATALOG,
SAMPLE_PUZZLES,
} from "../../src/data/samples";
import {
GENERATOR_VARIANTS,
solveExact,
solveLogically,
} from "../../src/solver";
describe("bundled original samples", () => { describe("bundled original samples", () => {
it.each(SAMPLE_PUZZLES)("ships $title as a unique puzzle", (puzzle) => { 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", () => { 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]);
});
});