feat: add gameplay assists and variant generator
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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 0–100 result is calculated afterwards from logical techniques,
|
||||||
|
clue load and reproducible exact-search evidence. A timeout or node limit is
|
||||||
|
reported as unknown and never promoted to a uniqueness or difficulty claim.
|
||||||
|
|
||||||
## Privacy and storage
|
## Privacy and storage
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 0–100 result is calculated afterwards from logical techniques,
|
||||||
|
clue load and reproducible exact-search evidence. A timeout or node limit is
|
||||||
|
reported as unknown and never promoted to a uniqueness or difficulty claim.
|
||||||
|
|
||||||
## Privacy and storage
|
## Privacy and storage
|
||||||
|
|
||||||
|
|||||||
@@ -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 < second
|
1st < 2nd (inequality)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!need(2)}
|
||||||
|
onClick={() =>
|
||||||
|
append({
|
||||||
|
type: "inequality",
|
||||||
|
lesser: selection[1]!,
|
||||||
|
greater: selection[0]!,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
1st > 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 & uniqueness
|
Check definition & 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 < second</option>
|
<option value="less">
|
||||||
<option value="greater">First > second</option>
|
First cell < second cell · inequality
|
||||||
|
</option>
|
||||||
|
<option value="greater">
|
||||||
|
First cell > second cell · inequality
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
@@ -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])}` : "",
|
||||||
|
|||||||
+168
-41
@@ -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;
|
if (workerRef.current === null) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setFeedback({
|
setFeedback({
|
||||||
kind: "info",
|
kind: "info",
|
||||||
message: "Generating a unique puzzle locally…",
|
message:
|
||||||
|
"Generating and rating a uniquely checked puzzle locally. The current board stays available until it is ready…",
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const generated = (await workerRef.current.request(
|
const generated = (await workerRef.current.request(
|
||||||
{
|
{ kind: "generate-variant", options },
|
||||||
kind: "generate",
|
{ timeoutMs: 240_000 },
|
||||||
options: {
|
)) as GeneratedVariantPuzzle;
|
||||||
size: puzzle.size,
|
loadPuzzle(generated.puzzle);
|
||||||
targetClues: Math.max(
|
setGeneration(generated);
|
||||||
puzzle.size,
|
setDifficulty(generated.difficulty);
|
||||||
Math.round(puzzle.size * puzzle.size * 0.42),
|
setWorkspace("generate");
|
||||||
),
|
|
||||||
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: "success",
|
||||||
message:
|
message: `Generated a unique ${generated.variant} puzzle. Rated ${generated.difficulty.label}${generated.difficulty.score === null ? "" : ` (${String(generated.difficulty.score)}/100)`}.`,
|
||||||
"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]);
|
},
|
||||||
|
[loadPuzzle],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rateCurrentPuzzle = useCallback(async () => {
|
||||||
|
if (workerRef.current === null) return;
|
||||||
|
if (normalized.error) {
|
||||||
|
setFeedback({ kind: "error", message: normalized.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setFeedback({
|
||||||
|
kind: "info",
|
||||||
|
message: "Checking uniqueness and rating the current puzzle locally…",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const assessment = (await workerRef.current.request(
|
||||||
|
{ kind: "difficulty", puzzle: normalized.puzzle },
|
||||||
|
{ timeoutMs: 60_000 },
|
||||||
|
)) as DifficultyAssessment;
|
||||||
|
setGeneration(undefined);
|
||||||
|
setDifficulty(assessment);
|
||||||
|
setFeedback({
|
||||||
|
kind: assessment.uniqueness === "unique" ? "success" : "info",
|
||||||
|
message: assessment.summary,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setFeedback({ kind: "error", message: errorMessage(error) });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [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}
|
||||||
|
|||||||
@@ -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
@@ -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 1–9 exactly once in every row, column and outlined 3×3 region.",
|
rules: "Place 1–9 exactly once in every row, column and outlined 3×3 region.",
|
||||||
@@ -32,4 +43,242 @@ export const KILLER_SAMPLE: PuzzleDefinition = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SAMPLE_PUZZLES = [CLASSIC_SAMPLE, KILLER_SAMPLE] as const;
|
export const DIAGONAL_SAMPLE: PuzzleDefinition = {
|
||||||
|
version: 1,
|
||||||
|
size: 9,
|
||||||
|
givens: grid(
|
||||||
|
"080000037",
|
||||||
|
"013090020",
|
||||||
|
"207305860",
|
||||||
|
"408003016",
|
||||||
|
"700601008",
|
||||||
|
"130900702",
|
||||||
|
"021509604",
|
||||||
|
"060030950",
|
||||||
|
"570000080",
|
||||||
|
),
|
||||||
|
constraints: [
|
||||||
|
{ type: "diagonal", direction: "main" },
|
||||||
|
{ type: "diagonal", direction: "anti" },
|
||||||
|
],
|
||||||
|
title: "Crossing paths",
|
||||||
|
author: "Sudoku Tools",
|
||||||
|
rules:
|
||||||
|
"Place 1–9 once in every row, column, region and on both marked long diagonals.",
|
||||||
|
solution: grid(
|
||||||
|
"985426137",
|
||||||
|
"613897425",
|
||||||
|
"247315869",
|
||||||
|
"498273516",
|
||||||
|
"752641398",
|
||||||
|
"136958742",
|
||||||
|
"321589674",
|
||||||
|
"864732951",
|
||||||
|
"579164283",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const THERMO_SAMPLE: PuzzleDefinition = {
|
||||||
|
version: 1,
|
||||||
|
size: 6,
|
||||||
|
givens: grid("310240", "245300", "100003", "400002", "004621", "021034"),
|
||||||
|
constraints: [
|
||||||
|
{ type: "thermo", cells: [3, 9, 15, 16] },
|
||||||
|
{ type: "thermo", cells: [6, 7, 13] },
|
||||||
|
{ type: "thermo", cells: [12, 13, 19] },
|
||||||
|
{ type: "thermo", cells: [29, 23, 22] },
|
||||||
|
],
|
||||||
|
title: "Warm fronts",
|
||||||
|
author: "Sudoku Tools",
|
||||||
|
rules:
|
||||||
|
"Place 1–6 once in every row, column and region. Digits increase strictly from each thermometer bulb to its tip.",
|
||||||
|
solution: grid("316245", "245316", "152463", "463152", "534621", "621534"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ARROW_SAMPLE: PuzzleDefinition = {
|
||||||
|
version: 1,
|
||||||
|
size: 6,
|
||||||
|
givens: grid("201045", "450103", "500200", "002001", "603012", "120304"),
|
||||||
|
constraints: [
|
||||||
|
{ type: "arrow", bulb: [19], line: [14, 20] },
|
||||||
|
{ type: "arrow", bulb: [7], line: [6, 13] },
|
||||||
|
{ type: "arrow", bulb: [24], line: [25, 20] },
|
||||||
|
{ type: "arrow", bulb: [6], line: [13, 18] },
|
||||||
|
],
|
||||||
|
title: "Follow the sum",
|
||||||
|
author: "Sudoku Tools",
|
||||||
|
rules:
|
||||||
|
"Place 1–6 once in every row, column and region. Digits on each arrow line sum to the digit in its bulb.",
|
||||||
|
solution: grid("231645", "456123", "514236", "362451", "643512", "125364"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KROPKI_SAMPLE: PuzzleDefinition = {
|
||||||
|
version: 1,
|
||||||
|
size: 6,
|
||||||
|
givens: grid("250001", "134500", "360400", "002013", "005236", "600054"),
|
||||||
|
constraints: [
|
||||||
|
{ type: "kropki", a: 14, b: 20, kind: "black" },
|
||||||
|
{ type: "kropki", a: 27, b: 28, kind: "white" },
|
||||||
|
{ type: "kropki", a: 5, b: 11, kind: "white" },
|
||||||
|
{ type: "kropki", a: 0, b: 6, kind: "black" },
|
||||||
|
],
|
||||||
|
title: "Black and white",
|
||||||
|
author: "Sudoku Tools",
|
||||||
|
rules:
|
||||||
|
"Place 1–6 once in every row, column and region. White dots mark consecutive digits and black dots mark a 1:2 ratio. Unmarked pairs have no extra rule.",
|
||||||
|
solution: grid("256341", "134562", "361425", "542613", "415236", "623154"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const XV_SAMPLE: PuzzleDefinition = {
|
||||||
|
version: 1,
|
||||||
|
size: 6,
|
||||||
|
givens: grid("153000", "420000", "201645", "564302", "000026", "000453"),
|
||||||
|
constraints: [
|
||||||
|
{ type: "xv", a: 4, b: 5, total: 10 },
|
||||||
|
{ type: "xv", a: 14, b: 20, total: 5 },
|
||||||
|
{ type: "xv", a: 19, b: 25, total: 10 },
|
||||||
|
{ type: "xv", a: 12, b: 13, total: 5 },
|
||||||
|
],
|
||||||
|
title: "Five or ten",
|
||||||
|
author: "Sudoku Tools",
|
||||||
|
rules:
|
||||||
|
"Place 1–6 once in every row, column and region. Numbered pair badges show a required sum of 5 or 10. Unmarked pairs have no extra rule.",
|
||||||
|
solution: grid("153264", "426531", "231645", "564312", "345126", "612453"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const INEQUALITY_SAMPLE: PuzzleDefinition = {
|
||||||
|
version: 1,
|
||||||
|
size: 6,
|
||||||
|
givens: grid("152300", "340105", "520000", "000032", "605043", "004651"),
|
||||||
|
constraints: [
|
||||||
|
{ type: "inequality", lesser: 9, greater: 15 },
|
||||||
|
{ type: "inequality", lesser: 5, greater: 4 },
|
||||||
|
{ type: "inequality", lesser: 18, greater: 12 },
|
||||||
|
{ type: "inequality", lesser: 9, greater: 3 },
|
||||||
|
{ type: "inequality", lesser: 23, greater: 29 },
|
||||||
|
{ type: "inequality", lesser: 13, greater: 14 },
|
||||||
|
{ type: "inequality", lesser: 29, greater: 28 },
|
||||||
|
{ type: "inequality", lesser: 22, greater: 28 },
|
||||||
|
],
|
||||||
|
title: "Lesser and greater",
|
||||||
|
author: "Sudoku Tools",
|
||||||
|
rules:
|
||||||
|
"Place 1–6 once in every row, column and region. Each inequality opens toward the greater digit.",
|
||||||
|
solution: grid("152364", "346125", "523416", "461532", "615243", "234651"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RENBAN_SAMPLE: PuzzleDefinition = {
|
||||||
|
version: 1,
|
||||||
|
size: 6,
|
||||||
|
givens: grid("104230", "002060", "050312", "213050", "040500", "025103"),
|
||||||
|
constraints: [
|
||||||
|
{ type: "renban", cells: [9, 8, 7, 13] },
|
||||||
|
{ type: "renban", cells: [20, 21, 27, 28, 34] },
|
||||||
|
{ type: "renban", cells: [9, 15, 16, 17] },
|
||||||
|
{ type: "renban", cells: [34, 35, 29, 28, 22] },
|
||||||
|
],
|
||||||
|
title: "Consecutive company",
|
||||||
|
author: "Sudoku Tools",
|
||||||
|
rules:
|
||||||
|
"Place 1–6 once in every row, column and region. Each renban line contains a distinct consecutive set in any order.",
|
||||||
|
solution: grid("164235", "532461", "456312", "213654", "341526", "625143"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PALINDROME_SAMPLE: PuzzleDefinition = {
|
||||||
|
version: 1,
|
||||||
|
size: 6,
|
||||||
|
givens: grid("062501", "540060", "005023", "420600", "050034", "104250"),
|
||||||
|
constraints: [
|
||||||
|
{ type: "palindrome", cells: [4, 18] },
|
||||||
|
{ type: "palindrome", cells: [10, 26] },
|
||||||
|
{ type: "palindrome", cells: [27, 30] },
|
||||||
|
{ type: "palindrome", cells: [24, 33] },
|
||||||
|
],
|
||||||
|
title: "Mirror lines",
|
||||||
|
author: "Sudoku Tools",
|
||||||
|
rules:
|
||||||
|
"Place 1–6 once in every row, column and region. Digits on every palindrome line read the same from either end.",
|
||||||
|
solution: grid("362541", "541362", "615423", "423615", "256134", "134256"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ANTI_KNIGHT_SAMPLE: PuzzleDefinition = {
|
||||||
|
version: 1,
|
||||||
|
size: 4,
|
||||||
|
givens: grid("0124", "4003", "2001", "1340"),
|
||||||
|
constraints: [{ type: "anti-knight" }],
|
||||||
|
title: "No knight's move",
|
||||||
|
author: "Sudoku Tools",
|
||||||
|
rules:
|
||||||
|
"Place 1–4 once in every row, column and region. Equal digits may not be a chess knight's move apart.",
|
||||||
|
solution: grid("3124", "4213", "2431", "1342"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ANTI_KING_SAMPLE: PuzzleDefinition = {
|
||||||
|
version: 1,
|
||||||
|
size: 6,
|
||||||
|
givens: grid("010034", "604051", "526000", "000562", "360405", "450020"),
|
||||||
|
constraints: [{ type: "anti-king" }],
|
||||||
|
title: "Kings keep apart",
|
||||||
|
author: "Sudoku Tools",
|
||||||
|
rules:
|
||||||
|
"Place 1–6 once in every row, column and region. Equal digits may not touch diagonally, like kings in chess.",
|
||||||
|
solution: grid("215634", "634251", "526143", "143562", "362415", "451326"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NON_CONSECUTIVE_SAMPLE: PuzzleDefinition = {
|
||||||
|
version: 1,
|
||||||
|
size: 6,
|
||||||
|
givens: grid("010264", "602030", "060053", "530020", "050602", "426010"),
|
||||||
|
constraints: [{ type: "non-consecutive" }],
|
||||||
|
title: "Mind the gap",
|
||||||
|
author: "Sudoku Tools",
|
||||||
|
rules:
|
||||||
|
"Place 1–6 once in every row, column and region. Orthogonally adjacent digits may not be consecutive.",
|
||||||
|
solution: grid("315264", "642531", "264153", "531426", "153642", "426315"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SamplePuzzleEntry {
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly puzzle: PuzzleDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SAMPLE_CATALOG: readonly SamplePuzzleEntry[] = [
|
||||||
|
{ id: "classic", label: "Classic 9 × 9", puzzle: CLASSIC_SAMPLE },
|
||||||
|
{ id: "killer", label: "Killer 4 × 4", puzzle: KILLER_SAMPLE },
|
||||||
|
{ id: "diagonal", label: "Diagonal 9 × 9", puzzle: DIAGONAL_SAMPLE },
|
||||||
|
{ id: "thermo", label: "Thermo 6 × 6", puzzle: THERMO_SAMPLE },
|
||||||
|
{ id: "arrow", label: "Arrow 6 × 6", puzzle: ARROW_SAMPLE },
|
||||||
|
{ id: "kropki", label: "Kropki 6 × 6", puzzle: KROPKI_SAMPLE },
|
||||||
|
{ id: "xv", label: "Sum pairs 6 × 6", puzzle: XV_SAMPLE },
|
||||||
|
{
|
||||||
|
id: "inequality",
|
||||||
|
label: "Inequality 6 × 6",
|
||||||
|
puzzle: INEQUALITY_SAMPLE,
|
||||||
|
},
|
||||||
|
{ id: "renban", label: "Renban 6 × 6", puzzle: RENBAN_SAMPLE },
|
||||||
|
{
|
||||||
|
id: "palindrome",
|
||||||
|
label: "Palindrome 6 × 6",
|
||||||
|
puzzle: PALINDROME_SAMPLE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "anti-knight",
|
||||||
|
label: "Anti-knight 4 × 4",
|
||||||
|
puzzle: ANTI_KNIGHT_SAMPLE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "anti-king",
|
||||||
|
label: "Anti-king 6 × 6",
|
||||||
|
puzzle: ANTI_KING_SAMPLE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "non-consecutive",
|
||||||
|
label: "Non-consecutive 6 × 6",
|
||||||
|
puzzle: NON_CONSECUTIVE_SAMPLE,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SAMPLE_PUZZLES: readonly PuzzleDefinition[] = SAMPLE_CATALOG.map(
|
||||||
|
({ puzzle }) => puzzle,
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import {
|
||||||
|
normalizePuzzle,
|
||||||
|
type NormalizedPuzzle,
|
||||||
|
type PuzzleDefinition,
|
||||||
|
} from "../domain";
|
||||||
|
import { solveExact, type ExactLimitReason } from "./exact";
|
||||||
|
import {
|
||||||
|
solveLogically,
|
||||||
|
type LogicalSolveStatus,
|
||||||
|
type LogicalTechnique,
|
||||||
|
} from "./logical";
|
||||||
|
|
||||||
|
export type DifficultyLevel =
|
||||||
|
"beginner" | "easy" | "medium" | "hard" | "expert" | "extreme" | "unrated";
|
||||||
|
|
||||||
|
export type UniquenessStatus = "unique" | "multiple" | "none" | "unknown";
|
||||||
|
|
||||||
|
export interface DifficultyOptions {
|
||||||
|
/** Maximum logical steps considered by the human-style solver. */
|
||||||
|
readonly logicalMaxSteps?: number;
|
||||||
|
/** Maximum exact-search nodes used to verify uniqueness. */
|
||||||
|
readonly exactMaxNodes?: number;
|
||||||
|
/** Wall-clock safety limit for uniqueness verification. */
|
||||||
|
readonly exactTimeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DifficultyAssessment {
|
||||||
|
/** A stable 0–100 estimate, or null when the puzzle cannot be rated safely. */
|
||||||
|
readonly score: number | null;
|
||||||
|
readonly level: DifficultyLevel;
|
||||||
|
readonly label: string;
|
||||||
|
readonly uniqueness: UniquenessStatus;
|
||||||
|
readonly clueCount: number;
|
||||||
|
readonly emptyCount: number;
|
||||||
|
readonly logicalStatus: LogicalSolveStatus;
|
||||||
|
readonly logicalSteps: number;
|
||||||
|
readonly hardestTechnique?: LogicalTechnique;
|
||||||
|
readonly techniqueCounts: Readonly<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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" }),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user