diff --git a/CHANGELOG.md b/CHANGELOG.md index df37137..efc7a58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes are documented here. ## Unreleased +- Added optional digit-completion counts with muted complete digits and red + over-completion warnings. +- Added toggleable Ctrl/Command-click matching-digit highlights without turning + the highlights into an editable multi-selection. +- Fixed cage replacement and removal in the setter so overlapping cages cannot + remain accidentally active. +- Replaced ambiguous V/X pair labels on the board with numbered 5/10 badges and + gave inequalities a directional chevron with a marked lesser-value tip. +- Added thirteen uniquely checked built-in examples covering every supported + constraint family. +- Added bounded, seedable generation for classic and twelve variant families, + plus independent uniqueness and evidence-based difficulty assessment. + ## 0.1.0 - 2026-08-30 - Initial local-first Sudoku setting, playing, solving and analysis workbench. diff --git a/README.md b/README.md index f36b5a1..941c390 100644 --- a/README.md +++ b/README.md @@ -9,20 +9,32 @@ release also runs independently from any static HTTPS host or local preview. - **Play** — keyboard, mouse and touch entry; multi-cell selection; values, corner/centre notes and colours; undo/redo; conflict highlighting; timer and - local progress. + local progress; optional digit-completion counts and matching-digit + highlights. - **Set** — givens, metadata, regions and typed constraints; uniqueness checks - and seeded classic puzzle generation. + with overlap-safe cage replacement and selection-based cage removal. +- **Generate** — seedable, bounded construction for classic and 12 variant + families; independent uniqueness verification and evidence-based difficulty + assessment. - **Solve** — exact solution counting plus an original human-style engine whose steps include structured evidence, placements and eliminations. - **Helpers** — Killer combinations and candidate-aware assignments, 45-rule residuals, and Kropki, XV or inequality relation pairs. Classic grids and common variants share one bounded puzzle model: irregular -regions, diagonals, Killer cages, thermometers, arrows, Kropki and XV clues, -inequalities, renban lines, palindromes, anti-knight, anti-king and -non-consecutive rules. Import accepts compact grid strings, this project's JSON -format, share fragments, and the common f-puzzles fields supported by the -model. Unknown constraints are reported rather than silently discarded. +regions, diagonals, Killer cages, thermometers, arrows, Kropki and numbered +5/10 sum-pair clues, inequalities, renban lines, palindromes, anti-knight, +anti-king and non-consecutive rules. Thirteen bundled, uniquely checked examples +demonstrate every supported constraint. Import accepts compact grid strings, +this project's JSON format, share fragments, and the common f-puzzles fields +supported by the model. Unknown constraints are reported rather than silently +discarded. + +Generation supports only size/variant combinations which pass the bounded +construction checks. The requested difficulty controls clue-removal targets; +the displayed 0–100 result is calculated afterwards from logical techniques, +clue load and reproducible exact-search evidence. A timeout or node limit is +reported as unknown and never promoted to a uniqueness or difficulty claim. ## Privacy and storage diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index df37137..efc7a58 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes are documented here. ## Unreleased +- Added optional digit-completion counts with muted complete digits and red + over-completion warnings. +- Added toggleable Ctrl/Command-click matching-digit highlights without turning + the highlights into an editable multi-selection. +- Fixed cage replacement and removal in the setter so overlapping cages cannot + remain accidentally active. +- Replaced ambiguous V/X pair labels on the board with numbered 5/10 badges and + gave inequalities a directional chevron with a marked lesser-value tip. +- Added thirteen uniquely checked built-in examples covering every supported + constraint family. +- Added bounded, seedable generation for classic and twelve variant families, + plus independent uniqueness and evidence-based difficulty assessment. + ## 0.1.0 - 2026-08-30 - Initial local-first Sudoku setting, playing, solving and analysis workbench. diff --git a/public/README.md b/public/README.md index f36b5a1..941c390 100644 --- a/public/README.md +++ b/public/README.md @@ -9,20 +9,32 @@ release also runs independently from any static HTTPS host or local preview. - **Play** — keyboard, mouse and touch entry; multi-cell selection; values, corner/centre notes and colours; undo/redo; conflict highlighting; timer and - local progress. + local progress; optional digit-completion counts and matching-digit + highlights. - **Set** — givens, metadata, regions and typed constraints; uniqueness checks - and seeded classic puzzle generation. + with overlap-safe cage replacement and selection-based cage removal. +- **Generate** — seedable, bounded construction for classic and 12 variant + families; independent uniqueness verification and evidence-based difficulty + assessment. - **Solve** — exact solution counting plus an original human-style engine whose steps include structured evidence, placements and eliminations. - **Helpers** — Killer combinations and candidate-aware assignments, 45-rule residuals, and Kropki, XV or inequality relation pairs. Classic grids and common variants share one bounded puzzle model: irregular -regions, diagonals, Killer cages, thermometers, arrows, Kropki and XV clues, -inequalities, renban lines, palindromes, anti-knight, anti-king and -non-consecutive rules. Import accepts compact grid strings, this project's JSON -format, share fragments, and the common f-puzzles fields supported by the -model. Unknown constraints are reported rather than silently discarded. +regions, diagonals, Killer cages, thermometers, arrows, Kropki and numbered +5/10 sum-pair clues, inequalities, renban lines, palindromes, anti-knight, +anti-king and non-consecutive rules. Thirteen bundled, uniquely checked examples +demonstrate every supported constraint. Import accepts compact grid strings, +this project's JSON format, share fragments, and the common f-puzzles fields +supported by the model. Unknown constraints are reported rather than silently +discarded. + +Generation supports only size/variant combinations which pass the bounded +construction checks. The requested difficulty controls clue-removal targets; +the displayed 0–100 result is calculated afterwards from logical techniques, +clue load and reproducible exact-search evidence. A timeout or node limit is +reported as unknown and never promoted to a uniqueness or difficulty claim. ## Privacy and storage diff --git a/src/components/ConstraintEditor.tsx b/src/components/ConstraintEditor.tsx index 168a17c..0ba5e27 100644 --- a/src/components/ConstraintEditor.tsx +++ b/src/components/ConstraintEditor.tsx @@ -1,5 +1,10 @@ import { useState } from "react"; import type { PuzzleDefinition, VariantConstraint } from "../domain/types"; +import { + removeKillerCagesAtCells, + replaceOverlappingKillerCages, + selectionTouchesKillerCage, +} from "./constraintEditing"; interface ConstraintEditorProps { puzzle: PuzzleDefinition; @@ -34,7 +39,7 @@ function describeConstraint(constraint: VariantConstraint, size: number) { case "kropki": return `${constraint.kind} dot · ${cell(constraint.a)}–${cell(constraint.b)}`; case "xv": - return `${constraint.total === 5 ? "V" : "X"} · ${cell(constraint.a)}–${cell(constraint.b)}`; + return `sum ${String(constraint.total)} pair · ${cell(constraint.a)}–${cell(constraint.b)}`; case "inequality": return `${cell(constraint.lesser)} < ${cell(constraint.greater)}`; } @@ -57,6 +62,17 @@ export function ConstraintEditor({ onChange({ ...puzzle, constraints: [...constraints, constraint] }); const need = (count: number) => selection.length === count; const atLeast = (count: number) => selection.length >= count; + const cageCellCount = Math.max(1, selection.length); + const minimumCageSum = (cageCellCount * (cageCellCount + 1)) / 2; + const maximumCageSum = + (cageCellCount * (2 * puzzle.size - cageCellCount + 1)) / 2; + const validCage = + selection.length >= 1 && + selection.length <= puzzle.size && + Number.isInteger(cageSum) && + cageSum >= minimumCageSum && + cageSum <= maximumCageSum; + const selectedCageExists = selectionTouchesKillerCage(constraints, selection); const toggleGlobal = ( type: "anti-knight" | "anti-king" | "non-consecutive", @@ -143,22 +159,46 @@ export function ConstraintEditor({ Cage sum setCageSum(Number(event.target.value))} /> + +

+ Adding replaces any cage touching the selection. Removing clears every + cage touching a selected cell. +

+
@@ -374,12 +427,8 @@ export function ConstraintEditor({ -
diff --git a/src/components/DigitCompletionBar.tsx b/src/components/DigitCompletionBar.tsx new file mode 100644 index 0000000..ab17488 --- /dev/null +++ b/src/components/DigitCompletionBar.tsx @@ -0,0 +1,57 @@ +import type { DigitCompletion } from "../state/gameplayHelpers"; +import { symbolFor } from "../state/session"; + +function completionLabel(item: DigitCompletion, size: number): string { + const symbol = symbolFor(item.digit, size); + if (item.status === "done") + return `Digit ${symbol}: complete, ${String(item.placed)} of ${String(item.target)} placed`; + if (item.status === "overdone") + return `Digit ${symbol}: overdone by ${String(item.excess)}, ${String(item.placed)} of ${String(item.target)} placed`; + return `Digit ${symbol}: ${String(item.remaining)} remaining, ${String(item.placed)} of ${String(item.target)} placed`; +} + +export function DigitCompletionBar({ + size, + completions, + highlightedDigit, + highlightingEnabled, + onHighlight, +}: { + size: number; + completions: readonly DigitCompletion[]; + highlightedDigit: number | null; + highlightingEnabled: boolean; + onHighlight: (digit: number) => void; +}) { + return ( +
+
+ Digit progress + muted = complete · red = too many +
+
9 ? " digit-completion__bar--wide" : ""}`} + role="group" + aria-label="Highlight matching digits" + > + {completions.map((item) => ( + + ))} +
+
+ ); +} diff --git a/src/components/GeneratorWorkspace.tsx b/src/components/GeneratorWorkspace.tsx new file mode 100644 index 0000000..71275ee --- /dev/null +++ b/src/components/GeneratorWorkspace.tsx @@ -0,0 +1,243 @@ +import { useMemo, useState, type FormEvent } from "react"; +import { + GENERATOR_VARIANTS, + type DifficultyAssessment, + type GeneratedVariantPuzzle, + type GenerationDifficultyTarget, + type GeneratorVariant, + type GenerateVariantOptions, +} from "../solver"; + +const difficultyTargets: readonly GenerationDifficultyTarget[] = [ + "beginner", + "easy", + "medium", + "hard", + "expert", +]; + +function techniqueLabel(value: string | undefined): string { + return value === undefined + ? "Direct placements" + : value + .split("-") + .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`) + .join(" "); +} + +export function GeneratorWorkspace({ + busy, + assessment, + generation, + onGenerate, + onRate, +}: { + busy: boolean; + assessment?: DifficultyAssessment; + generation?: GeneratedVariantPuzzle; + onGenerate: (options: GenerateVariantOptions) => void; + onRate: () => void; +}) { + const [variant, setVariant] = useState("classic"); + const definition = useMemo( + () => GENERATOR_VARIANTS.find((item) => item.id === variant)!, + [variant], + ); + const [size, setSize] = useState(9); + const [targetDifficulty, setTargetDifficulty] = + useState("medium"); + const [symmetry, setSymmetry] = useState<"none" | "rotational">("rotational"); + const [constraintCount, setConstraintCount] = useState(8); + const [seed, setSeed] = useState(""); + const usesMarkingCount = ![ + "classic", + "diagonal", + "killer", + "anti-knight", + "anti-king", + "non-consecutive", + ].includes(variant); + + const submit = (event: FormEvent) => { + event.preventDefault(); + onGenerate({ + variant, + size, + targetDifficulty, + symmetry, + ...(usesMarkingCount ? { constraintCount } : {}), + seed: seed.trim() || `local-${Date.now().toString(36)}`, + }); + }; + + return ( +
+
+

Bounded local construction

+

Sudoku generator

+

+ Build a seedable, uniquely checked puzzle in the worker. The requested + level guides clue removal; the finished puzzle receives an independent + evidence-based rating. +

+
+ +
+
+ + + + + {usesMarkingCount && ( + + )} + +
+

{definition.description}

+
+ + +
+

+ Difficulty is an estimate from reproducible solver evidence, not a + universal promise. Uniqueness is never claimed after a safety limit. +

+
+ + {assessment && ( +
+
+
+

Difficulty assessment

+

{assessment.label}

+
+ + {assessment.uniqueness} + +
+
+ + Score {assessment.score ?? "—"}/100 + + + Givens {assessment.clueCount} + + + Logical steps {assessment.logicalSteps} + + + Hardest{" "} + {techniqueLabel(assessment.hardestTechnique)} + + + Search nodes {assessment.exactNodes} + + {generation && ( + + Markings {generation.generatedConstraintCount} + + )} +
+

{assessment.summary}

+ {generation && ( +

+ Seed: {String(generation.seed)} +

+ )} +
+ )} +
+ ); +} diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx index 90ddb90..ecb63a3 100644 --- a/src/components/HelpDialog.tsx +++ b/src/components/HelpDialog.tsx @@ -11,12 +11,13 @@ export function HelpDialog({
-

Four complementary workspaces

+

Five complementary workspaces

Play keeps values, two kinds of notes, colours, history and elapsed time. Set edits clues and - constraints. Solve explains logical steps and can - verify uniqueness. Helpers answers focused + constraints. Generate constructs and rates bounded, + seedable variants. Solve explains logical steps and + can verify uniqueness. Helpers answers focused questions without changing the board.

@@ -47,6 +48,10 @@ export function HelpDialog({
Ctrl/⌘ + Z/Y
Undo or redo
+
+
Ctrl/⌘ + click
+
Highlight every placed copy of that digit
+
@@ -59,6 +64,16 @@ export function HelpDialog({ explanation.

+
+

Generation and ratings

+

+ 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. +

+

Import and privacy

diff --git a/src/components/HelpersWorkspace.tsx b/src/components/HelpersWorkspace.tsx index e018701..9ffcc71 100644 --- a/src/components/HelpersWorkspace.tsx +++ b/src/components/HelpersWorkspace.tsx @@ -362,12 +362,16 @@ export function HelpersWorkspace({ value={relation} onChange={(event) => setRelation(event.target.value)} > - - - - - - + + + + + + )} + {workspace === "play" && showDigitCompletion && ( + + setHighlightedDigit((current) => + current === digit ? null : digit, + ) + } + /> + )}

Rules

@@ -1206,6 +1301,28 @@ export function Workbench() { /> Highlight conflicts immediately + +
)} @@ -1253,11 +1370,21 @@ export function Workbench() { ) } onCheck={() => void checkDefinition()} - onGenerate={() => void generatePuzzle()} + onGenerate={() => setWorkspace("generate")} /> )} + {workspace === "generate" && ( + void generateConfigured(options)} + onRate={() => void rateCurrentPuzzle()} + /> + )} + {workspace === "solve" && ( , +): boolean { + return constraint.cells.some((cell) => cells.has(cell)); +} + +/** + * Setter cages form a partition: drawing a cage replaces every existing cage + * that touches its cells instead of leaving hidden, overlapping sum rules. + */ +export function replaceOverlappingKillerCages( + constraints: readonly VariantConstraint[], + cage: KillerCageConstraint, +): VariantConstraint[] { + const cells = new Set(cage.cells); + return [ + ...constraints.filter( + (constraint) => + constraint.type !== "killer-cage" || !touchesAnyCell(constraint, cells), + ), + { ...cage, cells: [...cage.cells] }, + ]; +} + +/** Remove every cage touching at least one selected cell. */ +export function removeKillerCagesAtCells( + constraints: readonly VariantConstraint[], + cells: readonly number[], +): VariantConstraint[] { + if (cells.length === 0) return [...constraints]; + const selected = new Set(cells); + return constraints.filter( + (constraint) => + constraint.type !== "killer-cage" || + !touchesAnyCell(constraint, selected), + ); +} + +export function selectionTouchesKillerCage( + constraints: readonly VariantConstraint[], + cells: readonly number[], +): boolean { + if (cells.length === 0) return false; + const selected = new Set(cells); + return constraints.some( + (constraint) => + constraint.type === "killer-cage" && touchesAnyCell(constraint, selected), + ); +} diff --git a/src/data/samples.ts b/src/data/samples.ts index 54e10a8..d3cb437 100644 --- a/src/data/samples.ts +++ b/src/data/samples.ts @@ -1,12 +1,23 @@ import type { PuzzleDefinition } from "../domain/types"; -const classicGrid = - "460000001000300476009060050804000607000854000102000805030040900928001000500000013"; +function grid(...rows: readonly string[]): number[] { + return [...rows.join("")].map(Number); +} export const CLASSIC_SAMPLE: PuzzleDefinition = { version: 1, size: 9, - givens: [...classicGrid].map(Number), + givens: grid( + "460000001", + "000300476", + "009060050", + "804000607", + "000854000", + "102000805", + "030040900", + "928001000", + "500000013", + ), title: "A first classic", author: "Sudoku Tools", rules: "Place 1–9 exactly once in every row, column and outlined 3×3 region.", @@ -32,4 +43,242 @@ export const KILLER_SAMPLE: PuzzleDefinition = { ], }; -export const SAMPLE_PUZZLES = [CLASSIC_SAMPLE, KILLER_SAMPLE] as const; +export const DIAGONAL_SAMPLE: PuzzleDefinition = { + version: 1, + size: 9, + givens: grid( + "080000037", + "013090020", + "207305860", + "408003016", + "700601008", + "130900702", + "021509604", + "060030950", + "570000080", + ), + constraints: [ + { type: "diagonal", direction: "main" }, + { type: "diagonal", direction: "anti" }, + ], + title: "Crossing paths", + author: "Sudoku Tools", + rules: + "Place 1–9 once in every row, column, region and on both marked long diagonals.", + solution: grid( + "985426137", + "613897425", + "247315869", + "498273516", + "752641398", + "136958742", + "321589674", + "864732951", + "579164283", + ), +}; + +export const THERMO_SAMPLE: PuzzleDefinition = { + version: 1, + size: 6, + givens: grid("310240", "245300", "100003", "400002", "004621", "021034"), + constraints: [ + { type: "thermo", cells: [3, 9, 15, 16] }, + { type: "thermo", cells: [6, 7, 13] }, + { type: "thermo", cells: [12, 13, 19] }, + { type: "thermo", cells: [29, 23, 22] }, + ], + title: "Warm fronts", + author: "Sudoku Tools", + rules: + "Place 1–6 once in every row, column and region. Digits increase strictly from each thermometer bulb to its tip.", + solution: grid("316245", "245316", "152463", "463152", "534621", "621534"), +}; + +export const ARROW_SAMPLE: PuzzleDefinition = { + version: 1, + size: 6, + givens: grid("201045", "450103", "500200", "002001", "603012", "120304"), + constraints: [ + { type: "arrow", bulb: [19], line: [14, 20] }, + { type: "arrow", bulb: [7], line: [6, 13] }, + { type: "arrow", bulb: [24], line: [25, 20] }, + { type: "arrow", bulb: [6], line: [13, 18] }, + ], + title: "Follow the sum", + author: "Sudoku Tools", + rules: + "Place 1–6 once in every row, column and region. Digits on each arrow line sum to the digit in its bulb.", + solution: grid("231645", "456123", "514236", "362451", "643512", "125364"), +}; + +export const KROPKI_SAMPLE: PuzzleDefinition = { + version: 1, + size: 6, + givens: grid("250001", "134500", "360400", "002013", "005236", "600054"), + constraints: [ + { type: "kropki", a: 14, b: 20, kind: "black" }, + { type: "kropki", a: 27, b: 28, kind: "white" }, + { type: "kropki", a: 5, b: 11, kind: "white" }, + { type: "kropki", a: 0, b: 6, kind: "black" }, + ], + title: "Black and white", + author: "Sudoku Tools", + rules: + "Place 1–6 once in every row, column and region. White dots mark consecutive digits and black dots mark a 1:2 ratio. Unmarked pairs have no extra rule.", + solution: grid("256341", "134562", "361425", "542613", "415236", "623154"), +}; + +export const XV_SAMPLE: PuzzleDefinition = { + version: 1, + size: 6, + givens: grid("153000", "420000", "201645", "564302", "000026", "000453"), + constraints: [ + { type: "xv", a: 4, b: 5, total: 10 }, + { type: "xv", a: 14, b: 20, total: 5 }, + { type: "xv", a: 19, b: 25, total: 10 }, + { type: "xv", a: 12, b: 13, total: 5 }, + ], + title: "Five or ten", + author: "Sudoku Tools", + rules: + "Place 1–6 once in every row, column and region. Numbered pair badges show a required sum of 5 or 10. Unmarked pairs have no extra rule.", + solution: grid("153264", "426531", "231645", "564312", "345126", "612453"), +}; + +export const INEQUALITY_SAMPLE: PuzzleDefinition = { + version: 1, + size: 6, + givens: grid("152300", "340105", "520000", "000032", "605043", "004651"), + constraints: [ + { type: "inequality", lesser: 9, greater: 15 }, + { type: "inequality", lesser: 5, greater: 4 }, + { type: "inequality", lesser: 18, greater: 12 }, + { type: "inequality", lesser: 9, greater: 3 }, + { type: "inequality", lesser: 23, greater: 29 }, + { type: "inequality", lesser: 13, greater: 14 }, + { type: "inequality", lesser: 29, greater: 28 }, + { type: "inequality", lesser: 22, greater: 28 }, + ], + title: "Lesser and greater", + author: "Sudoku Tools", + rules: + "Place 1–6 once in every row, column and region. Each inequality opens toward the greater digit.", + solution: grid("152364", "346125", "523416", "461532", "615243", "234651"), +}; + +export const RENBAN_SAMPLE: PuzzleDefinition = { + version: 1, + size: 6, + givens: grid("104230", "002060", "050312", "213050", "040500", "025103"), + constraints: [ + { type: "renban", cells: [9, 8, 7, 13] }, + { type: "renban", cells: [20, 21, 27, 28, 34] }, + { type: "renban", cells: [9, 15, 16, 17] }, + { type: "renban", cells: [34, 35, 29, 28, 22] }, + ], + title: "Consecutive company", + author: "Sudoku Tools", + rules: + "Place 1–6 once in every row, column and region. Each renban line contains a distinct consecutive set in any order.", + solution: grid("164235", "532461", "456312", "213654", "341526", "625143"), +}; + +export const PALINDROME_SAMPLE: PuzzleDefinition = { + version: 1, + size: 6, + givens: grid("062501", "540060", "005023", "420600", "050034", "104250"), + constraints: [ + { type: "palindrome", cells: [4, 18] }, + { type: "palindrome", cells: [10, 26] }, + { type: "palindrome", cells: [27, 30] }, + { type: "palindrome", cells: [24, 33] }, + ], + title: "Mirror lines", + author: "Sudoku Tools", + rules: + "Place 1–6 once in every row, column and region. Digits on every palindrome line read the same from either end.", + solution: grid("362541", "541362", "615423", "423615", "256134", "134256"), +}; + +export const ANTI_KNIGHT_SAMPLE: PuzzleDefinition = { + version: 1, + size: 4, + givens: grid("0124", "4003", "2001", "1340"), + constraints: [{ type: "anti-knight" }], + title: "No knight's move", + author: "Sudoku Tools", + rules: + "Place 1–4 once in every row, column and region. Equal digits may not be a chess knight's move apart.", + solution: grid("3124", "4213", "2431", "1342"), +}; + +export const ANTI_KING_SAMPLE: PuzzleDefinition = { + version: 1, + size: 6, + givens: grid("010034", "604051", "526000", "000562", "360405", "450020"), + constraints: [{ type: "anti-king" }], + title: "Kings keep apart", + author: "Sudoku Tools", + rules: + "Place 1–6 once in every row, column and region. Equal digits may not touch diagonally, like kings in chess.", + solution: grid("215634", "634251", "526143", "143562", "362415", "451326"), +}; + +export const NON_CONSECUTIVE_SAMPLE: PuzzleDefinition = { + version: 1, + size: 6, + givens: grid("010264", "602030", "060053", "530020", "050602", "426010"), + constraints: [{ type: "non-consecutive" }], + title: "Mind the gap", + author: "Sudoku Tools", + rules: + "Place 1–6 once in every row, column and region. Orthogonally adjacent digits may not be consecutive.", + solution: grid("315264", "642531", "264153", "531426", "153642", "426315"), +}; + +export interface SamplePuzzleEntry { + readonly id: string; + readonly label: string; + readonly puzzle: PuzzleDefinition; +} + +export const SAMPLE_CATALOG: readonly SamplePuzzleEntry[] = [ + { id: "classic", label: "Classic 9 × 9", puzzle: CLASSIC_SAMPLE }, + { id: "killer", label: "Killer 4 × 4", puzzle: KILLER_SAMPLE }, + { id: "diagonal", label: "Diagonal 9 × 9", puzzle: DIAGONAL_SAMPLE }, + { id: "thermo", label: "Thermo 6 × 6", puzzle: THERMO_SAMPLE }, + { id: "arrow", label: "Arrow 6 × 6", puzzle: ARROW_SAMPLE }, + { id: "kropki", label: "Kropki 6 × 6", puzzle: KROPKI_SAMPLE }, + { id: "xv", label: "Sum pairs 6 × 6", puzzle: XV_SAMPLE }, + { + id: "inequality", + label: "Inequality 6 × 6", + puzzle: INEQUALITY_SAMPLE, + }, + { id: "renban", label: "Renban 6 × 6", puzzle: RENBAN_SAMPLE }, + { + id: "palindrome", + label: "Palindrome 6 × 6", + puzzle: PALINDROME_SAMPLE, + }, + { + id: "anti-knight", + label: "Anti-knight 4 × 4", + puzzle: ANTI_KNIGHT_SAMPLE, + }, + { + id: "anti-king", + label: "Anti-king 6 × 6", + puzzle: ANTI_KING_SAMPLE, + }, + { + id: "non-consecutive", + label: "Non-consecutive 6 × 6", + puzzle: NON_CONSECUTIVE_SAMPLE, + }, +]; + +export const SAMPLE_PUZZLES: readonly PuzzleDefinition[] = SAMPLE_CATALOG.map( + ({ puzzle }) => puzzle, +); diff --git a/src/solver/difficulty.ts b/src/solver/difficulty.ts new file mode 100644 index 0000000..0741568 --- /dev/null +++ b/src/solver/difficulty.ts @@ -0,0 +1,254 @@ +import { + normalizePuzzle, + type NormalizedPuzzle, + type PuzzleDefinition, +} from "../domain"; +import { solveExact, type ExactLimitReason } from "./exact"; +import { + solveLogically, + type LogicalSolveStatus, + type LogicalTechnique, +} from "./logical"; + +export type DifficultyLevel = + "beginner" | "easy" | "medium" | "hard" | "expert" | "extreme" | "unrated"; + +export type UniquenessStatus = "unique" | "multiple" | "none" | "unknown"; + +export interface DifficultyOptions { + /** Maximum logical steps considered by the human-style solver. */ + readonly logicalMaxSteps?: number; + /** Maximum exact-search nodes used to verify uniqueness. */ + readonly exactMaxNodes?: number; + /** Wall-clock safety limit for uniqueness verification. */ + readonly exactTimeoutMs?: number; +} + +export interface DifficultyAssessment { + /** A stable 0–100 estimate, or null when the puzzle cannot be rated safely. */ + readonly score: number | null; + readonly level: DifficultyLevel; + readonly label: string; + readonly uniqueness: UniquenessStatus; + readonly clueCount: number; + readonly emptyCount: number; + readonly logicalStatus: LogicalSolveStatus; + readonly logicalSteps: number; + readonly hardestTechnique?: LogicalTechnique; + readonly techniqueCounts: Readonly>>; + readonly exactNodes: number; + readonly exactTruncated: boolean; + readonly exactLimitReason?: ExactLimitReason; + readonly summary: string; +} + +const TECHNIQUE_WEIGHT: Readonly> = { + "naked-single": 3, + "hidden-single": 8, + "killer-cage": 16, + "naked-pair": 18, + pointing: 23, + claiming: 25, + "hidden-pair": 27, + "naked-triple": 31, + "hidden-triple": 36, + "naked-quad": 40, + "hidden-quad": 44, + "x-wing": 50, + "xy-wing": 57, + "xyz-wing": 61, + swordfish: 66, +}; + +function boundedInteger( + value: number | undefined, + fallback: number, + minimum: number, + maximum: number, + name: string, +): number { + const result = value ?? fallback; + if (!Number.isInteger(result) || result < minimum || result > maximum) { + throw new RangeError( + `${name} must be an integer from ${minimum} to ${maximum}.`, + ); + } + return result; +} + +function levelForScore(score: number): Exclude { + if (score <= 20) return "beginner"; + if (score <= 36) return "easy"; + if (score <= 54) return "medium"; + if (score <= 72) return "hard"; + if (score <= 88) return "expert"; + return "extreme"; +} + +function titleCase(value: string): string { + return `${value.charAt(0).toUpperCase()}${value.slice(1)}`; +} + +function unrated( + normalized: NormalizedPuzzle, + uniqueness: Exclude, + logicalStatus: LogicalSolveStatus, + logicalSteps: number, + exact: { + readonly nodes: number; + readonly truncated: boolean; + readonly limitReason?: ExactLimitReason; + }, + summary: string, +): DifficultyAssessment { + const clueCount = normalized.givens.filter((value) => value !== 0).length; + return { + score: null, + level: "unrated", + label: "Unrated", + uniqueness, + clueCount, + emptyCount: normalized.givens.length - clueCount, + logicalStatus, + logicalSteps, + techniqueCounts: {}, + exactNodes: exact.nodes, + exactTruncated: exact.truncated, + ...(exact.limitReason === undefined + ? {} + : { exactLimitReason: exact.limitReason }), + summary, + }; +} + +/** + * Rates a puzzle using only deterministic solver evidence. Uniqueness is never + * claimed when the exact search hits a node or time limit. + */ +export function evaluateDifficulty( + puzzle: PuzzleDefinition | NormalizedPuzzle, + options: DifficultyOptions = {}, +): DifficultyAssessment { + const normalized = normalizePuzzle(puzzle); + const logicalMaxSteps = boundedInteger( + options.logicalMaxSteps, + 2_000, + 1, + 10_000, + "logicalMaxSteps", + ); + const exactMaxNodes = boundedInteger( + options.exactMaxNodes, + 2_000_000, + 1, + 100_000_000, + "exactMaxNodes", + ); + const exactTimeoutMs = boundedInteger( + options.exactTimeoutMs, + 10_000, + 1, + 120_000, + "exactTimeoutMs", + ); + const logical = solveLogically(normalized, { maxSteps: logicalMaxSteps }); + const exact = solveExact(normalized, { + maxSolutions: 2, + maxNodes: exactMaxNodes, + timeoutMs: exactTimeoutMs, + }); + + if (exact.count === 0 && !exact.truncated) { + return unrated( + normalized, + "none", + logical.status, + logical.steps.length, + exact, + "The puzzle has no solution, so it cannot be rated.", + ); + } + if (exact.count >= 2) { + return unrated( + normalized, + "multiple", + logical.status, + logical.steps.length, + exact, + "The puzzle has multiple solutions, so a difficulty rating would be misleading.", + ); + } + if (exact.truncated) { + return unrated( + normalized, + "unknown", + logical.status, + logical.steps.length, + exact, + "The bounded uniqueness check did not finish; uniqueness and difficulty remain unknown.", + ); + } + + const techniqueCounts: Partial> = {}; + let hardestTechnique: LogicalTechnique | undefined; + let hardestWeight = 0; + for (const step of logical.steps) { + techniqueCounts[step.technique] = + (techniqueCounts[step.technique] ?? 0) + 1; + const weight = TECHNIQUE_WEIGHT[step.technique]; + if (weight >= hardestWeight) { + hardestWeight = weight; + hardestTechnique = step.technique; + } + } + + const clueCount = normalized.givens.filter((value) => value !== 0).length; + const emptyCount = normalized.givens.length - clueCount; + const emptyRatio = emptyCount / normalized.givens.length; + let score: number; + if (logical.status === "solved") { + const stepLoad = Math.min( + 13, + Math.round( + Math.log2(logical.steps.length + 1) * 2 + + logical.steps.length / normalized.size, + ), + ); + score = Math.round(3 + emptyRatio * 14 + hardestWeight * 0.78 + stepLoad); + } else { + // A solver that is stuck after the supported human techniques is at least + // hard. Exact node count distinguishes bounded search effort without using + // elapsed time, keeping the score stable across devices. + score = Math.round( + 70 + + emptyRatio * 8 + + Math.min(22, Math.log2(Math.max(1, exact.nodes)) * 2.1), + ); + } + score = Math.max(0, Math.min(100, score)); + const level = levelForScore(score); + const hardest = + hardestTechnique === undefined + ? "direct placements" + : hardestTechnique.replaceAll("-", " "); + const summary = + logical.status === "solved" + ? `Solved logically in ${String(logical.steps.length)} steps; hardest supported technique: ${hardest}.` + : `Unique, but the supported logical solver became ${logical.status}; exact search visited ${String(exact.nodes)} nodes.`; + + return { + score, + level, + label: titleCase(level), + uniqueness: "unique", + clueCount, + emptyCount, + logicalStatus: logical.status, + logicalSteps: logical.steps.length, + ...(hardestTechnique === undefined ? {} : { hardestTechnique }), + techniqueCounts, + exactNodes: exact.nodes, + exactTruncated: false, + summary, + }; +} diff --git a/src/solver/index.ts b/src/solver/index.ts index 9493a6b..8f208f5 100644 --- a/src/solver/index.ts +++ b/src/solver/index.ts @@ -1,4 +1,6 @@ +export * from "./difficulty"; export * from "./exact"; export * from "./generator"; export * from "./killer"; export * from "./logical"; +export * from "./variantGenerator"; diff --git a/src/solver/variantGenerator.ts b/src/solver/variantGenerator.ts new file mode 100644 index 0000000..0355229 --- /dev/null +++ b/src/solver/variantGenerator.ts @@ -0,0 +1,680 @@ +import { + classicRegions, + normalizePuzzle, + orthogonalNeighbours, + type NormalizedPuzzle, + type PuzzleDefinition, + type VariantConstraint, +} from "../domain"; +import { + evaluateDifficulty, + type DifficultyAssessment, + type DifficultyOptions, +} from "./difficulty"; +import { solveExact } from "./exact"; +import { + generateClassic, + minimizePuzzle, + type ClueSymmetry, +} from "./generator"; +import { seededRandom, shuffled, type RandomSource } from "./random"; + +export const GENERATOR_VARIANTS = [ + { + id: "classic", + label: "Classic", + description: "Rows, columns and rectangular regions.", + supportedSizes: [4, 6, 9, 12, 16], + }, + { + id: "diagonal", + label: "Diagonal (X)", + description: "Both long diagonals also contain every digit once.", + supportedSizes: [4, 6, 9], + }, + { + id: "anti-knight", + label: "Anti-knight", + description: "Equal digits cannot be a chess knight's move apart.", + supportedSizes: [4, 6, 9], + }, + { + id: "anti-king", + label: "Anti-king", + description: "Equal digits cannot touch diagonally like chess kings.", + supportedSizes: [6, 9], + }, + { + id: "non-consecutive", + label: "Non-consecutive", + description: "Orthogonal neighbours cannot differ by one.", + supportedSizes: [6], + }, + { + id: "killer", + label: "Killer", + description: "A complete partition of no-repeat sum cages.", + supportedSizes: [4, 6, 9], + }, + { + id: "thermo", + label: "Thermo", + description: "Digits increase strictly from each bulb to its tip.", + supportedSizes: [4, 6, 9], + }, + { + id: "arrow", + label: "Arrow", + description: "Digits on each arrow line sum to its bulb.", + supportedSizes: [6, 9], + }, + { + id: "kropki", + label: "Kropki dots", + description: "Marked neighbours are consecutive or in a 1:2 ratio.", + supportedSizes: [4, 6, 9], + }, + { + id: "xv", + label: "Sum pairs (5/10)", + description: "Numbered neighbouring pairs sum to 5 or 10.", + supportedSizes: [6, 9], + }, + { + id: "inequality", + label: "Inequality", + description: "Each marked pair follows the shown less-than direction.", + supportedSizes: [4, 6, 9], + }, + { + id: "renban", + label: "Renban", + description: "Digits on each purple line form a consecutive set.", + supportedSizes: [4, 6, 9], + }, + { + id: "palindrome", + label: "Palindrome", + description: "Digits read the same in either direction along each line.", + supportedSizes: [4, 6, 9], + }, +] as const; + +export type GeneratorVariant = (typeof GENERATOR_VARIANTS)[number]["id"]; +export type GenerationDifficultyTarget = + "beginner" | "easy" | "medium" | "hard" | "expert"; + +export interface GenerateVariantOptions { + readonly variant?: GeneratorVariant; + readonly size?: number; + readonly boxRows?: number; + readonly boxColumns?: number; + readonly seed?: string | number; + /** A clue-removal target. The returned rating is evidence-based, not promised. */ + readonly targetDifficulty?: GenerationDifficultyTarget; + readonly targetClues?: number; + readonly symmetry?: ClueSymmetry; + /** Number of local markings requested. Global rules, Killer and diagonal use structural counts. */ + readonly constraintCount?: number; + readonly maxChecks?: number; + readonly solveMaxNodes?: number; + readonly solveTimeoutMs?: number; + readonly difficulty?: DifficultyOptions; +} + +export interface GeneratedVariantPuzzle { + readonly puzzle: NormalizedPuzzle; + readonly difficulty: DifficultyAssessment; + readonly variant: GeneratorVariant; + readonly seed: string | number; + readonly generatedConstraintCount: number; +} + +const DEFAULT_CONSTRAINT_COUNTS: Readonly< + Record< + Exclude< + GeneratorVariant, + | "classic" + | "diagonal" + | "anti-knight" + | "anti-king" + | "non-consecutive" + | "killer" + >, + number + > +> = { + thermo: 6, + arrow: 4, + kropki: 10, + xv: 8, + inequality: 12, + renban: 5, + palindrome: 5, +}; + +const RULES: Readonly> = { + classic: + "Place every digit exactly once in each row, column and outlined region.", + diagonal: + "Place every digit exactly once in each row, column, outlined region and on each marked long diagonal.", + "anti-knight": + "Place every digit exactly once in each row, column and outlined region. Equal digits may not be a chess knight's move apart.", + "anti-king": + "Place every digit exactly once in each row, column and outlined region. Equal digits may not touch diagonally, like kings in chess.", + "non-consecutive": + "Place every digit exactly once in each row, column and outlined region. Orthogonally adjacent digits may not be consecutive.", + killer: + "Place every digit exactly once in each row, column and outlined region. Digits in each dashed cage sum to its clue and do not repeat within that cage.", + thermo: + "Place every digit exactly once in each row, column and outlined region. Digits increase strictly from each thermometer bulb to its tip.", + arrow: + "Place every digit exactly once in each row, column and outlined region. Digits on each arrow line sum to the digit in its bulb.", + kropki: + "Place every digit exactly once in each row, column and outlined region. A white dot marks consecutive digits; a black dot marks a 1:2 ratio. Only marked pairs are constrained.", + xv: "Place every digit exactly once in each row, column and outlined region. A numbered pair badge gives the required sum of 5 or 10. Only marked pairs are constrained.", + inequality: + "Place every digit exactly once in each row, column and outlined region. Each inequality points from the lesser digit toward the greater digit.", + renban: + "Place every digit exactly once in each row, column and outlined region. Digits on each renban line are distinct and form a consecutive set in any order.", + palindrome: + "Place every digit exactly once in each row, column and outlined region. Digits on each palindrome line read the same from either end.", +}; + +function boundedInteger( + value: number | undefined, + fallback: number, + minimum: number, + maximum: number, + name: string, +): number { + const result = value ?? fallback; + if (!Number.isInteger(result) || result < minimum || result > maximum) { + throw new RangeError( + `${name} must be an integer from ${minimum} to ${maximum}.`, + ); + } + return result; +} + +function getDefinition(variant: string) { + return GENERATOR_VARIANTS.find((definition) => definition.id === variant); +} + +function clueTarget(size: number, target: GenerationDifficultyTarget): number { + const ratio: Record = { + beginner: 0.58, + easy: 0.49, + medium: 0.4, + hard: 0.33, + expert: 0.27, + }; + return Math.max(size, Math.round(size * size * ratio[target])); +} + +function allEdges(size: number): Array { + const edges: Array = []; + for (let cell = 0; cell < size * size; cell += 1) { + const row = Math.floor(cell / size); + const column = cell % size; + if (column + 1 < size) edges.push([cell, cell + 1]); + if (row + 1 < size) edges.push([cell, cell + size]); + } + return edges; +} + +function kingNeighbours(size: number, cell: number): number[] { + const row = Math.floor(cell / size); + const column = cell % size; + const result: number[] = []; + for (let dr = -1; dr <= 1; dr += 1) { + for (let dc = -1; dc <= 1; dc += 1) { + if (dr === 0 && dc === 0) continue; + const nextRow = row + dr; + const nextColumn = column + dc; + if ( + nextRow >= 0 && + nextRow < size && + nextColumn >= 0 && + nextColumn < size + ) { + result.push(nextRow * size + nextColumn); + } + } + } + return result; +} + +function selectConstraints( + candidates: readonly T[], + count: number, + random: RandomSource, +): T[] { + return shuffled(candidates, random).slice(0, count); +} + +function killerCages( + size: number, + solution: readonly number[], + random: RandomSource, +): VariantConstraint[] { + const unassigned = new Set( + Array.from({ length: size * size }, (_, cell) => cell), + ); + const constraints: VariantConstraint[] = []; + for (const start of shuffled([...unassigned], random)) { + if (!unassigned.has(start)) continue; + const target = 2 + Math.floor(random() * Math.min(3, size - 1)); + const cells = [start]; + const digits = new Set([solution[start]]); + unassigned.delete(start); + while (cells.length < target) { + const frontier = shuffled( + [ + ...new Set(cells.flatMap((cell) => orthogonalNeighbours(size, cell))), + ].filter( + (cell) => + unassigned.has(cell) && !digits.has(solution[cell] as number), + ), + random, + ); + const next = frontier[0]; + if (next === undefined) break; + cells.push(next); + digits.add(solution[next]); + unassigned.delete(next); + } + constraints.push({ + type: "killer-cage", + cells, + sum: cells.reduce((sum, cell) => sum + (solution[cell] ?? 0), 0), + }); + } + return constraints; +} + +function thermoCandidates( + size: number, + solution: readonly number[], + random: RandomSource, +): VariantConstraint[] { + const found: VariantConstraint[] = []; + const seen = new Set(); + const visit = (path: number[]): void => { + if (path.length >= 3) { + const key = path.join(":"); + if (!seen.has(key)) { + seen.add(key); + found.push({ type: "thermo", cells: [...path] }); + } + } + if (path.length >= Math.min(5, size)) return; + const last = path.at(-1); + if (last === undefined) return; + for (const next of shuffled(orthogonalNeighbours(size, last), random)) { + if ( + path.includes(next) || + (solution[next] ?? 0) <= (solution[last] ?? 0) + ) { + continue; + } + visit([...path, next]); + } + }; + for (const cell of shuffled( + Array.from({ length: size * size }, (_, index) => index), + random, + )) { + visit([cell]); + } + if (found.length > 0) return found; + return allEdges(size).map(([a, b]) => ({ + type: "thermo", + cells: (solution[a] ?? 0) < (solution[b] ?? 0) ? [a, b] : [b, a], + })); +} + +function arrowCandidates( + size: number, + solution: readonly number[], +): VariantConstraint[] { + const found: VariantConstraint[] = []; + const seen = new Set(); + for (let bulb = 0; bulb < size * size; bulb += 1) { + for (const first of kingNeighbours(size, bulb)) { + for (const second of kingNeighbours(size, first)) { + if (second === bulb || second === first) continue; + if ( + (solution[first] ?? 0) + (solution[second] ?? 0) !== + solution[bulb] + ) { + continue; + } + const reverseLine = `${String(bulb)}:${String(second)}:${String(first)}`; + const key = `${String(bulb)}:${String(first)}:${String(second)}`; + if (seen.has(key) || seen.has(reverseLine)) continue; + seen.add(key); + found.push({ type: "arrow", bulb: [bulb], line: [first, second] }); + } + } + } + return found; +} + +function relationCandidates( + variant: "kropki" | "xv" | "inequality", + size: number, + solution: readonly number[], +): VariantConstraint[] { + const constraints: VariantConstraint[] = []; + for (const [a, b] of allEdges(size)) { + const av = solution[a] ?? 0; + const bv = solution[b] ?? 0; + if (variant === "inequality") { + constraints.push( + av < bv + ? { type: "inequality", lesser: a, greater: b } + : { type: "inequality", lesser: b, greater: a }, + ); + } else if (variant === "kropki") { + if (Math.abs(av - bv) === 1) { + constraints.push({ type: "kropki", a, b, kind: "white" }); + } + if (av === bv * 2 || bv === av * 2) { + constraints.push({ type: "kropki", a, b, kind: "black" }); + } + } else { + const total = av + bv; + if (total === 5 || total === 10) { + constraints.push({ type: "xv", a, b, total }); + } + } + } + return constraints; +} + +function renbanCandidates( + size: number, + solution: readonly number[], + random: RandomSource, +): VariantConstraint[] { + const found: VariantConstraint[] = []; + const seen = new Set(); + const visit = (path: number[]): void => { + if (path.length >= 2) { + const digits = path.map((cell) => solution[cell] ?? 0); + const valid = + new Set(digits).size === digits.length && + Math.max(...digits) - Math.min(...digits) === path.length - 1; + if (valid) { + const direct = path.join(":"); + const reverse = [...path].reverse().join(":"); + if (!seen.has(direct) && !seen.has(reverse)) { + seen.add(direct); + found.push({ type: "renban", cells: [...path] }); + } + } + } + if (path.length >= Math.min(5, size)) return; + const last = path.at(-1); + if (last === undefined) return; + for (const next of shuffled(orthogonalNeighbours(size, last), random)) { + if (!path.includes(next)) visit([...path, next]); + } + }; + for (const cell of shuffled( + Array.from({ length: size * size }, (_, index) => index), + random, + )) { + visit([cell]); + } + return found.sort( + (left, right) => + (right.type === "renban" ? right.cells.length : 0) - + (left.type === "renban" ? left.cells.length : 0), + ); +} + +function palindromeCandidates( + size: number, + solution: readonly number[], +): VariantConstraint[] { + const candidates: Array<{ + readonly constraint: VariantConstraint; + readonly distance: number; + }> = []; + for (let a = 0; a < solution.length; a += 1) { + for (let b = a + 1; b < solution.length; b += 1) { + if (solution[a] !== solution[b]) continue; + const distance = + Math.abs(Math.floor(a / size) - Math.floor(b / size)) + + Math.abs((a % size) - (b % size)); + candidates.push({ + constraint: { type: "palindrome", cells: [a, b] }, + distance, + }); + } + } + return candidates + .sort((left, right) => left.distance - right.distance) + .map(({ constraint }) => constraint); +} + +function localConstraints( + variant: Exclude< + GeneratorVariant, + "classic" | "diagonal" | "anti-knight" | "anti-king" | "non-consecutive" + >, + size: number, + solution: readonly number[], + count: number, + random: RandomSource, +): VariantConstraint[] { + if (variant === "killer") return killerCages(size, solution, random); + const candidates = (() => { + switch (variant) { + case "thermo": + return thermoCandidates(size, solution, random); + case "arrow": + return arrowCandidates(size, solution); + case "kropki": + case "xv": + case "inequality": + return relationCandidates(variant, size, solution); + case "renban": + return renbanCandidates(size, solution, random); + case "palindrome": + return palindromeCandidates(size, solution); + } + })(); + if (candidates.length === 0) { + throw new Error( + `The seeded solution did not yield a valid ${variant} marking. Try another seed.`, + ); + } + return selectConstraints( + candidates, + Math.min(count, candidates.length), + random, + ); +} + +function fullSolution( + variant: GeneratorVariant, + size: number, + boxRows: number | undefined, + boxColumns: number | undefined, + seed: string | number, + maxNodes: number, + timeoutMs: number, +): NormalizedPuzzle { + const globalVariant = + variant === "diagonal" || + variant === "anti-knight" || + variant === "anti-king" || + variant === "non-consecutive"; + if (!globalVariant) { + return generateClassic({ + size, + boxRows, + boxColumns, + seed, + targetClues: size * size, + maxChecks: 1, + }); + } + const constraints: VariantConstraint[] = + variant === "diagonal" + ? [ + { type: "diagonal", direction: "main" }, + { type: "diagonal", direction: "anti" }, + ] + : [{ type: variant }]; + const empty: PuzzleDefinition = { + version: 1, + size, + givens: new Array(size * size).fill(0), + regions: classicRegions(size, boxRows, boxColumns), + constraints, + }; + const solved = solveExact(empty, { + maxSolutions: 1, + maxNodes, + timeoutMs, + seed: `${String(seed)}:${variant}-solution`, + }); + const solution = solved.solutions[0]; + if (solution === undefined) { + throw new Error( + `Could not construct a ${variant} solution within the ${String(timeoutMs)} ms / ${String(maxNodes)} node generation limit.`, + ); + } + return normalizePuzzle({ ...empty, givens: solution, solution }); +} + +/** + * Creates a unique, seedable variant puzzle. Generation is suitable for a Web + * Worker: every exact search has explicit node/time caps and worker termination + * can cancel the complete operation. + */ +export function generateVariant( + options: GenerateVariantOptions = {}, +): GeneratedVariantPuzzle { + const variant = options.variant ?? "classic"; + const definition = getDefinition(variant); + if (definition === undefined) { + throw new RangeError(`Unsupported generator variant: ${String(variant)}.`); + } + const size = boundedInteger(options.size, 9, 4, 16, "size"); + if (!(definition.supportedSizes as readonly number[]).includes(size)) { + throw new RangeError( + `${definition.label} generation supports sizes ${definition.supportedSizes.join(", ")}.`, + ); + } + const seed = options.seed ?? "sudoku-tools"; + const targetDifficulty = options.targetDifficulty ?? "medium"; + const targetClues = boundedInteger( + options.targetClues, + clueTarget(size, targetDifficulty), + 0, + size * size, + "targetClues", + ); + const maxChecks = boundedInteger( + options.maxChecks, + Math.min(size * size, 72), + 1, + size * size * 10, + "maxChecks", + ); + const solveMaxNodes = boundedInteger( + options.solveMaxNodes, + 500_000, + 1, + 100_000_000, + "solveMaxNodes", + ); + const solveTimeoutMs = boundedInteger( + options.solveTimeoutMs, + 2_000, + 1, + 120_000, + "solveTimeoutMs", + ); + const requestedConstraintCount = boundedInteger( + options.constraintCount, + variant === "classic" || + variant === "diagonal" || + variant === "anti-knight" || + variant === "anti-king" || + variant === "non-consecutive" || + variant === "killer" + ? 1 + : DEFAULT_CONSTRAINT_COUNTS[variant], + 1, + size * size * 2, + "constraintCount", + ); + const random = seededRandom(`${String(seed)}:${variant}:constraints`); + const full = fullSolution( + variant, + size, + options.boxRows, + options.boxColumns, + seed, + solveMaxNodes, + solveTimeoutMs, + ); + const solution = full.solution; + if (solution === undefined) { + throw new Error("Internal generator error: the completed grid was lost."); + } + const constraints: VariantConstraint[] = + variant === "classic" + ? [] + : variant === "diagonal" || + variant === "anti-knight" || + variant === "anti-king" || + variant === "non-consecutive" + ? [...full.constraints] + : localConstraints( + variant, + size, + solution, + requestedConstraintCount, + random, + ); + const title = `${definition.label} · ${String(seed)}`.slice(0, 256); + const complete = normalizePuzzle({ + version: 1, + size, + givens: solution, + regions: full.regions, + constraints, + solution, + title, + author: "Sudoku Tools generator", + rules: RULES[variant], + }); + const puzzle = minimizePuzzle(complete, { + seed: `${String(seed)}:${variant}:clues`, + targetClues, + symmetry: options.symmetry ?? "rotational", + maxChecks, + solveMaxNodes, + solveTimeoutMs, + }); + const difficulty = evaluateDifficulty(puzzle, options.difficulty); + if (difficulty.uniqueness !== "unique") { + throw new Error( + "Generated puzzle did not pass the bounded uniqueness audit; no puzzle was returned.", + ); + } + return { + puzzle, + difficulty, + variant, + seed, + generatedConstraintCount: constraints.length, + }; +} diff --git a/src/state/gameplayHelpers.ts b/src/state/gameplayHelpers.ts new file mode 100644 index 0000000..0680b48 --- /dev/null +++ b/src/state/gameplayHelpers.ts @@ -0,0 +1,121 @@ +import { MAX_PUZZLE_SIZE, MIN_PUZZLE_SIZE } from "../domain/types"; + +export type DigitCompletionStatus = "undone" | "done" | "overdone"; + +/** The gameplay-facing count for one symbol in a completed Sudoku grid. */ +export interface DigitCompletion { + readonly digit: number; + readonly placed: number; + readonly target: number; + readonly remaining: number; + readonly excess: number; + readonly status: DigitCompletionStatus; +} + +function assertPuzzleSize(size: number): void { + if ( + !Number.isInteger(size) || + size < MIN_PUZZLE_SIZE || + size > MAX_PUZZLE_SIZE + ) { + throw new RangeError( + `Puzzle size must be an integer from ${MIN_PUZZLE_SIZE} to ${MAX_PUZZLE_SIZE}.`, + ); + } +} + +function isDigit(value: number | undefined, size: number): value is number { + return ( + Number.isInteger(value) && value !== undefined && value > 0 && value <= size + ); +} + +/** + * Counts placed digits for a completion bar. A valid solved size-N Sudoku has + * exactly N copies of every digit. Invalid or out-of-range values are ignored + * so a damaged in-progress document cannot make the helper itself fail. + */ +export function digitCompletions( + size: number, + values: readonly number[], +): readonly DigitCompletion[] { + assertPuzzleSize(size); + const counts = new Array(size + 1).fill(0); + const cellCount = size * size; + + for (let cell = 0; cell < Math.min(values.length, cellCount); cell += 1) { + const value = values[cell]; + if (isDigit(value, size)) counts[value] = (counts[value] ?? 0) + 1; + } + + return Array.from({ length: size }, (_, index) => { + const digit = index + 1; + const placed = counts[digit] ?? 0; + const delta = size - placed; + return { + digit, + placed, + target: size, + remaining: Math.max(0, delta), + excess: Math.max(0, -delta), + status: delta > 0 ? "undone" : delta === 0 ? "done" : "overdone", + }; + }); +} + +/** Returns the valid placed digit at a grid cell, or null for an empty/bad cell. */ +export function placedDigitAt( + size: number, + values: readonly number[], + cell: number, +): number | null { + assertPuzzleSize(size); + if (!Number.isInteger(cell) || cell < 0 || cell >= size * size) return null; + const value = values[cell]; + return isDigit(value, size) ? value : null; +} + +/** Finds every grid cell containing a digit, in stable row-major order. */ +export function matchingDigitCells( + size: number, + values: readonly number[], + digit: number, +): readonly number[] { + assertPuzzleSize(size); + if (!isDigit(digit, size)) return []; + const cells: number[] = []; + const cellCount = Math.min(values.length, size * size); + for (let cell = 0; cell < cellCount; cell += 1) { + if (values[cell] === digit) cells.push(cell); + } + return cells; +} + +/** + * Resolves the cells to highlight after activating digit matching on a filled + * cell (for example with Ctrl-click or Cmd-click). Empty cells yield no match. + */ +export function matchingDigitCellsAt( + size: number, + values: readonly number[], + cell: number, +): readonly number[] { + const digit = placedDigitAt(size, values, cell); + return digit === null ? [] : matchingDigitCells(size, values, digit); +} + +/** + * Toggles a digit highlight from a modified click. Calling this for an empty + * or invalid cell preserves the current highlight, leaving normal cell + * selection semantics to the UI. + */ +export function toggledDigitHighlight( + size: number, + values: readonly number[], + cell: number, + currentDigit: number | null, +): number | null { + const clickedDigit = placedDigitAt(size, values, cell); + if (clickedDigit === null) return currentDigit; + return clickedDigit === currentDigit ? null : clickedDigit; +} diff --git a/src/styles.css b/src/styles.css index 9f577c7..73c3f9a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -520,7 +520,7 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { } .workspace-tabs { - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); } .workspace-tabs button, @@ -587,6 +587,7 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { .side-panel, .play-panel, .setter-panel, +.generator-workspace, .solve-workspace, .helpers-workspace { min-width: 0; @@ -643,6 +644,100 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { filter: saturate(0.6); } +.digit-completion { + width: min(100%, 43rem); + display: grid; + gap: 0.45rem; + margin-inline: auto; + padding: 0.62rem; + border: 1px solid var(--toolbox-border); + border-radius: 0.78rem; + background: var(--toolbox-surface); +} + +.digit-completion__heading { + display: flex; + align-items: baseline; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.2rem 0.75rem; + color: var(--toolbox-text); + font-size: 0.75rem; + font-weight: 760; +} + +.digit-completion__heading small { + color: var(--toolbox-muted); + font-size: 0.66rem; + font-weight: 620; +} + +.digit-completion__bar { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(2.5rem, 1fr)); + gap: 0.32rem; +} + +.digit-completion__bar--wide { + grid-template-columns: repeat(auto-fit, minmax(2.25rem, 1fr)); +} + +.digit-completion__digit { + min-height: 2.4rem !important; + display: grid !important; + grid-template-columns: 1fr !important; + gap: 0.05rem !important; + padding: 0.28rem !important; + font-variant-numeric: tabular-nums; +} + +.digit-completion__digit strong { + font-size: 0.9rem; + line-height: 1; +} + +.digit-completion__digit span { + color: var(--toolbox-muted); + font-size: 0.58rem; + font-weight: 700; + line-height: 1; +} + +.digit-completion__digit.is-done { + border-color: color-mix( + in srgb, + var(--toolbox-muted) 28%, + var(--toolbox-border) + ); + background: var(--toolbox-surface-soft); + color: var(--toolbox-muted); + opacity: 0.66; +} + +.digit-completion__digit.is-overdone { + border-color: color-mix( + in srgb, + var(--toolbox-danger) 55%, + var(--toolbox-border) + ); + background: color-mix( + in srgb, + var(--toolbox-danger) 12%, + var(--toolbox-surface) + ); + color: var(--toolbox-danger); +} + +.digit-completion__digit.is-overdone span { + color: var(--toolbox-danger); +} + +.digit-completion__digit.is-highlighted { + border-color: var(--toolbox-accent); + box-shadow: 0 0 0 2px + color-mix(in srgb, var(--toolbox-accent) 22%, transparent); +} + .side-panel { padding: clamp(0.85rem, 1.7vw, 1.2rem); } @@ -801,6 +896,29 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { display: flex; } +.generator-form { + background: color-mix( + in srgb, + var(--toolbox-accent-soft) 32%, + var(--toolbox-surface) + ); +} + +.generator-description { + padding: 0.55rem 0.65rem; + border-left: 3px solid var(--toolbox-accent); + background: color-mix(in srgb, var(--toolbox-accent-soft) 52%, transparent); + color: var(--toolbox-muted); + font-size: 0.8rem; +} + +.difficulty-card code { + color: var(--toolbox-text); + font-family: + ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + overflow-wrap: anywhere; +} + .feedback-callout, .status-line, .error-callout, @@ -941,6 +1059,14 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { ); } +.sudoku-cell.is-digit-highlighted { + background: color-mix( + in srgb, + var(--toolbox-accent) 12%, + var(--cell-fill) + ) !important; +} + .sudoku-cell.is-selected { background: color-mix( in srgb, @@ -1169,21 +1295,37 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { fill: color-mix(in srgb, var(--toolbox-text) 88%, black); } -.constraint-label { +.constraint-xv circle { + fill: color-mix(in srgb, var(--toolbox-surface) 92%, var(--toolbox-accent)); + stroke: color-mix(in srgb, var(--toolbox-accent) 68%, var(--toolbox-text)); + stroke-width: clamp(1.2px, 0.2cqi, 2px); +} + +.constraint-xv text { dominant-baseline: central; fill: var(--toolbox-text); font-family: var(--toolbox-font); - font-size: 0.28px; + font-size: 0.19px; font-weight: 850; paint-order: stroke; stroke: var(--toolbox-surface); stroke-linejoin: round; - stroke-width: 0.07px; + stroke-width: 0.035px; text-anchor: middle; } -.constraint-label--inequality { - font-size: 0.38px; +.constraint-inequality path { + fill: none; + stroke: color-mix(in srgb, var(--toolbox-text) 88%, var(--toolbox-accent)); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: clamp(2px, 0.32cqi, 3px); +} + +.constraint-inequality-tip { + fill: var(--toolbox-accent); + stroke: var(--toolbox-surface); + stroke-width: clamp(0.8px, 0.12cqi, 1.2px); } /* Number and colour entry */ @@ -1883,6 +2025,10 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { flex-direction: column; } + .workspace-tabs { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .board-toolbar > :where(.toolbar-group, .action-row) { width: 100%; } @@ -1979,6 +2125,10 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { min-height: 2.7rem; } + .workspace-tabs { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .board-surface { padding: 0.25rem; } @@ -2078,6 +2228,7 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { .workspace-tabs, .workbench-status, .board-toolbar, + .digit-completion, .side-panel, .number-pad, .paused-cover, diff --git a/src/workers/protocol.ts b/src/workers/protocol.ts index 55b719c..2259b83 100644 --- a/src/workers/protocol.ts +++ b/src/workers/protocol.ts @@ -2,7 +2,11 @@ import type { PuzzleDefinition } from "../domain"; import type { ExactSolveOptions, ExactSolveResult, + DifficultyAssessment, + DifficultyOptions, GenerateClassicOptions, + GeneratedVariantPuzzle, + GenerateVariantOptions, KillerCombinationOptions, KillerCombinationResult, LogicalSolveOptions, @@ -23,6 +27,15 @@ export type SolverWorkerOperation = readonly options?: LogicalSolveOptions; } | { readonly kind: "generate"; readonly options?: GenerateClassicOptions } + | { + readonly kind: "generate-variant"; + readonly options?: GenerateVariantOptions; + } + | { + readonly kind: "difficulty"; + readonly puzzle: PuzzleDefinition; + readonly options?: DifficultyOptions; + } | { readonly kind: "minimize"; readonly puzzle: PuzzleDefinition; @@ -42,6 +55,8 @@ export type SolverWorkerValue = | ExactSolveResult | LogicalSolveResult | NormalizedPuzzle + | GeneratedVariantPuzzle + | DifficultyAssessment | KillerCombinationResult; export interface SolverWorkerError { diff --git a/src/workers/solver.worker.ts b/src/workers/solver.worker.ts index 60afc79..92fd7b8 100644 --- a/src/workers/solver.worker.ts +++ b/src/workers/solver.worker.ts @@ -2,7 +2,9 @@ import { PuzzleValidationError } from "../domain"; import { + evaluateDifficulty, generateClassic, + generateVariant, killerDigitCombinations, minimizePuzzle, solveExact, @@ -25,6 +27,10 @@ function run(request: SolverWorkerRequest): SolverWorkerValue { return solveLogically(operation.puzzle, operation.options); case "generate": return generateClassic(operation.options); + case "generate-variant": + return generateVariant(operation.options); + case "difficulty": + return evaluateDifficulty(operation.puzzle, operation.options); case "minimize": return minimizePuzzle(operation.puzzle, operation.options); case "killer-combinations": diff --git a/tests/browser/workbench.spec.ts b/tests/browser/workbench.spec.ts index 0b481e3..0449465 100644 --- a/tests/browser/workbench.spec.ts +++ b/tests/browser/workbench.spec.ts @@ -32,6 +32,16 @@ test("loads standalone and keeps the core play workflow local", async ({ grid.getByRole("gridcell", { name: "Row 1, column 3, empty" }), ).toBeVisible(); + const placedFour = grid.getByRole("gridcell", { + name: "Row 1, column 1, 4", + }); + await placedFour.click({ modifiers: ["Control"] }); + expect(await grid.locator(".is-digit-highlighted").count()).toBeGreaterThan( + 1, + ); + await placedFour.click({ modifiers: ["Control"] }); + await expect(grid.locator(".is-digit-highlighted")).toHaveCount(0); + await page.getByRole("button", { name: "Helpers" }).click(); await expect( page.getByRole("heading", { name: "Sudoku helpers" }), @@ -54,3 +64,76 @@ test("loads standalone and keeps the core play workflow local", async ({ expect(runtimeErrors).toEqual([]); }); + +test("replaces setter cages and generates a rated variant", async ({ + page, +}) => { + const runtimeErrors: string[] = []; + page.on("pageerror", (error) => runtimeErrors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") runtimeErrors.push(message.text()); + }); + + await page.goto("/deep/nested/sudoku/"); + await page.getByRole("button", { name: "New blank" }).click(); + const grid = page.getByRole("grid", { name: "9 by 9 Sudoku grid" }); + await grid.getByRole("gridcell", { name: "Row 1, column 1, empty" }).click(); + await grid + .getByRole("gridcell", { name: "Row 1, column 2, empty" }) + .click({ modifiers: ["Shift"] }); + + await page.getByRole("spinbutton", { name: "Cage sum" }).fill("3"); + await page.getByRole("button", { name: "Add / replace cage" }).click(); + await expect(page.getByText("3 cage · 2 cells")).toBeVisible(); + await page.getByRole("spinbutton", { name: "Cage sum" }).fill("4"); + await page.getByRole("button", { name: "Add / replace cage" }).click(); + await expect(page.getByText("3 cage · 2 cells")).toHaveCount(0); + await expect(page.getByText("4 cage · 2 cells")).toBeVisible(); + await page.getByRole("button", { name: "Remove selected cage" }).click(); + await expect(page.getByText("4 cage · 2 cells")).toHaveCount(0); + + await page.getByRole("button", { name: "Generate", exact: true }).click(); + await page.getByLabel("Variant").selectOption("thermo"); + await page + .getByRole("combobox", { name: "Grid", exact: true }) + .selectOption("4"); + await page.getByLabel("Requested profile").selectOption("beginner"); + await page.getByLabel("Seed").fill("browser-thermo"); + await page.getByRole("button", { name: "Generate Thermo" }).click(); + + await expect( + page.getByRole("heading", { name: "Thermo · browser-thermo" }), + ).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText("Difficulty assessment")).toBeVisible(); + await expect(page.locator(".difficulty-card .status-pill")).toHaveText( + "unique", + ); + await expect(page.locator(".constraint-thermo")).not.toHaveCount(0); + + expect(runtimeErrors).toEqual([]); +}); + +test("uses distinct numbered sum badges and directional inequalities", async ({ + page, +}) => { + await page.goto("/deep/nested/sudoku/"); + const examples = page.getByLabel("Open built-in puzzle"); + + await examples.selectOption("xv"); + await expect( + page.getByRole("heading", { name: "Five or ten" }), + ).toBeVisible(); + await expect(page.locator(".constraint-xv--5 text").first()).toHaveText("5"); + await expect(page.locator(".constraint-xv--10 text").first()).toHaveText( + "10", + ); + + await examples.selectOption("inequality"); + await expect( + page.getByRole("heading", { name: "Lesser and greater" }), + ).toBeVisible(); + await expect(page.locator(".constraint-inequality")).not.toHaveCount(0); + await expect( + page.locator(".constraint-inequality-tip").first(), + ).toBeVisible(); +}); diff --git a/tests/components/constraintEditing.test.ts b/tests/components/constraintEditing.test.ts new file mode 100644 index 0000000..4e36efb --- /dev/null +++ b/tests/components/constraintEditing.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import type { VariantConstraint } from "../../src/domain"; +import { + removeKillerCagesAtCells, + replaceOverlappingKillerCages, + selectionTouchesKillerCage, +} from "../../src/components/constraintEditing"; + +const constraints: VariantConstraint[] = [ + { type: "killer-cage", cells: [0, 1], sum: 3 }, + { type: "killer-cage", cells: [2, 3], sum: 7 }, + { type: "thermo", cells: [0, 4] }, +]; + +describe("setter cage editing", () => { + it("replaces every overlapping cage while preserving other rules", () => { + expect( + replaceOverlappingKillerCages(constraints, { + type: "killer-cage", + cells: [1, 2], + sum: 5, + }), + ).toEqual([ + { type: "thermo", cells: [0, 4] }, + { type: "killer-cage", cells: [1, 2], sum: 5 }, + ]); + }); + + it("removes cages by any selected member cell", () => { + expect(removeKillerCagesAtCells(constraints, [1])).toEqual([ + { type: "killer-cage", cells: [2, 3], sum: 7 }, + { type: "thermo", cells: [0, 4] }, + ]); + expect(selectionTouchesKillerCage(constraints, [1])).toBe(true); + expect(selectionTouchesKillerCage(constraints, [4])).toBe(false); + }); +}); diff --git a/tests/components/digitCompletionBar.test.tsx b/tests/components/digitCompletionBar.test.tsx new file mode 100644 index 0000000..802f782 --- /dev/null +++ b/tests/components/digitCompletionBar.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { DigitCompletionBar } from "../../src/components/DigitCompletionBar"; +import { digitCompletions } from "../../src/state/gameplayHelpers"; + +describe("digit completion bar", () => { + it("renders complete digits muted and excess digits as overdone", () => { + const values = new Array(81).fill(0); + values.fill(1, 0, 9); + values.fill(2, 9, 19); + + render( + , + ); + + expect( + screen.getByRole("button", { name: /Digit 1: complete/u }), + ).toHaveClass("is-done"); + expect( + screen.getByRole("button", { name: /Digit 2: overdone by 1/u }), + ).toHaveClass("is-overdone"); + expect( + screen.getByRole("button", { name: /Digit 3: 9 remaining/u }), + ).toHaveClass("is-undone"); + }); +}); diff --git a/tests/components/generatorWorkspace.test.tsx b/tests/components/generatorWorkspace.test.tsx new file mode 100644 index 0000000..beb0d43 --- /dev/null +++ b/tests/components/generatorWorkspace.test.tsx @@ -0,0 +1,62 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { GeneratorWorkspace } from "../../src/components/GeneratorWorkspace"; + +describe("Sudoku generator workspace", () => { + it("offers only reliable sizes and submits an explicit local recipe", async () => { + const user = userEvent.setup(); + const onGenerate = vi.fn(); + render( + , + ); + + await user.selectOptions(screen.getByLabelText("Variant"), "anti-king"); + const grid = screen.getByLabelText("Grid"); + expect( + within(grid) + .getAllByRole("option") + .map((option) => option.textContent), + ).toEqual(["6 × 6", "9 × 9"]); + expect( + screen.queryByLabelText("Requested markings"), + ).not.toBeInTheDocument(); + + await user.selectOptions(screen.getByLabelText("Variant"), "thermo"); + expect(screen.getByLabelText("Requested markings")).toBeInTheDocument(); + await user.selectOptions(screen.getByLabelText("Grid"), "4"); + await user.selectOptions( + screen.getByLabelText("Requested profile"), + "easy", + ); + await user.clear(screen.getByLabelText("Seed")); + await user.type(screen.getByLabelText("Seed"), "repeatable-demo"); + await user.click(screen.getByRole("button", { name: "Generate Thermo" })); + + expect(onGenerate).toHaveBeenCalledWith({ + variant: "thermo", + size: 4, + targetDifficulty: "easy", + symmetry: "rotational", + constraintCount: 8, + seed: "repeatable-demo", + }); + }); + + it("exposes independent rating for the current puzzle", async () => { + const user = userEvent.setup(); + const onRate = vi.fn(); + render( + , + ); + + await user.click( + screen.getByRole("button", { name: "Rate current puzzle" }), + ); + expect(onRate).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/components/sudokuBoard.test.tsx b/tests/components/sudokuBoard.test.tsx new file mode 100644 index 0000000..451f7a9 --- /dev/null +++ b/tests/components/sudokuBoard.test.tsx @@ -0,0 +1,44 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { normalizePuzzle } from "../../src/domain"; +import { SudokuBoard } from "../../src/components/SudokuBoard"; + +describe("Sudoku board constraint visuals", () => { + it("distinguishes XV sums from directional inequalities", () => { + const puzzle = normalizePuzzle({ + version: 1, + size: 4, + givens: new Array(16).fill(0), + constraints: [ + { type: "xv", a: 0, b: 1, total: 5 }, + { type: "xv", a: 4, b: 5, total: 10 }, + { type: "inequality", lesser: 8, greater: 9 }, + ], + }); + + const { container } = render( + , + ); + + expect(container.querySelector(".constraint-xv--5 text")).toHaveTextContent( + "5", + ); + expect( + container.querySelector(".constraint-xv--10 text"), + ).toHaveTextContent("10"); + expect( + container.querySelector(".constraint-inequality path"), + ).toHaveAttribute("d", "M0.12 -0.17L-0.12 0L0.12 0.17"); + expect( + container.querySelector(".constraint-inequality-tip"), + ).toBeInTheDocument(); + }); +}); diff --git a/tests/components/workbench.test.tsx b/tests/components/workbench.test.tsx index d50cf58..4356256 100644 --- a/tests/components/workbench.test.tsx +++ b/tests/components/workbench.test.tsx @@ -63,6 +63,102 @@ describe("Sudoku workbench", () => { expect(undo).toBeDisabled(); }); + it("shows digit progress and toggles matching-digit highlights separately from selection", async () => { + const user = userEvent.setup(); + render(); + + const completion = screen.getByRole("group", { + name: "Highlight matching digits", + }); + expect(within(completion).getAllByRole("button")).toHaveLength(9); + + const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" }); + const four = within(grid).getByRole("gridcell", { + name: "Row 1, column 1, 4", + }); + fireEvent.pointerDown(four, { buttons: 1, ctrlKey: true }); + + const highlighted = grid.querySelectorAll(".is-digit-highlighted"); + expect(highlighted.length).toBeGreaterThan(1); + expect(four).toHaveAttribute("aria-selected", "true"); + + fireEvent.pointerDown(four, { buttons: 1, ctrlKey: true }); + expect(grid.querySelectorAll(".is-digit-highlighted")).toHaveLength(0); + + await user.click(screen.getByLabelText("Show digit completion bar")); + expect( + screen.queryByRole("group", { name: "Highlight matching digits" }), + ).not.toBeInTheDocument(); + }); + + it("replaces and removes setter cages through the selected cells", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "New blank" })); + const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" }); + fireEvent.pointerDown( + within(grid).getByRole("gridcell", { + name: "Row 1, column 2, empty", + }), + { buttons: 1, shiftKey: true }, + ); + + const cageSum = screen.getByRole("spinbutton", { name: "Cage sum" }); + await user.clear(cageSum); + await user.type(cageSum, "3"); + await user.click( + screen.getByRole("button", { name: "Add / replace cage" }), + ); + expect(screen.getByText("3 cage · 2 cells")).toBeInTheDocument(); + + await user.clear(cageSum); + await user.type(cageSum, "4"); + await user.click( + screen.getByRole("button", { name: "Add / replace cage" }), + ); + expect(screen.queryByText("3 cage · 2 cells")).not.toBeInTheDocument(); + expect(screen.getByText("4 cage · 2 cells")).toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { name: "Remove selected cage" }), + ); + expect(screen.queryByText("4 cage · 2 cells")).not.toBeInTheDocument(); + }); + + it("opens the expanded built-in variant examples", async () => { + const user = userEvent.setup(); + const { container } = render(); + + await user.selectOptions( + screen.getByLabelText("Open built-in puzzle"), + "thermo", + ); + expect( + screen.getByRole("heading", { name: "Warm fronts" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("grid", { name: "6 by 6 Sudoku grid" }), + ).toBeInTheDocument(); + expect( + container.querySelectorAll(".constraint-thermo").length, + ).toBeGreaterThan(0); + + await user.selectOptions( + screen.getByLabelText("Open built-in puzzle"), + "xv", + ); + expect( + screen.getByRole("heading", { name: "Five or ten" }), + ).toBeInTheDocument(); + expect(container.querySelector(".constraint-xv--5 text")).toHaveTextContent( + "5", + ); + expect( + container.querySelector(".constraint-xv--10 text"), + ).toHaveTextContent("10"); + }); + it("switches between setting, solving, playing and helper workspaces", async () => { const user = userEvent.setup(); render(); @@ -75,6 +171,14 @@ describe("Sudoku workbench", () => { screen.getByRole("heading", { name: "Enter givens" }), ).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Generate" })); + expect( + screen.getByRole("heading", { name: "Sudoku generator" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Rate current puzzle" }), + ).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Solve" })); expect( screen.getByRole("heading", { name: "Solve and verify" }), diff --git a/tests/data/samples.test.ts b/tests/data/samples.test.ts index 39554ed..da61254 100644 --- a/tests/data/samples.test.ts +++ b/tests/data/samples.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vitest"; -import { SAMPLE_PUZZLES } from "../../src/data/samples"; -import { solveExact, solveLogically } from "../../src/solver"; +import { + CLASSIC_SAMPLE, + SAMPLE_CATALOG, + SAMPLE_PUZZLES, +} from "../../src/data/samples"; +import { + GENERATOR_VARIANTS, + solveExact, + solveLogically, +} from "../../src/solver"; describe("bundled original samples", () => { it.each(SAMPLE_PUZZLES)("ships $title as a unique puzzle", (puzzle) => { @@ -10,6 +18,39 @@ describe("bundled original samples", () => { }); it("solves the generated classic with the supported logical techniques", () => { - expect(solveLogically(SAMPLE_PUZZLES[0]).status).toBe("solved"); + expect(solveLogically(CLASSIC_SAMPLE).status).toBe("solved"); + }); + + it("provides a named example for every advertised generator variant", () => { + expect(new Set(SAMPLE_CATALOG.map(({ id }) => id)).size).toBe( + SAMPLE_CATALOG.length, + ); + for (const { id } of GENERATOR_VARIANTS) { + expect(SAMPLE_CATALOG.some((entry) => entry.id === id)).toBe(true); + } + }); + + it("also demonstrates every supported global chess/adjacency rule", () => { + const constraintTypes = new Set( + SAMPLE_PUZZLES.flatMap((puzzle) => + (puzzle.constraints ?? []).map(({ type }) => type), + ), + ); + expect(constraintTypes).toEqual( + new Set([ + "diagonal", + "anti-knight", + "anti-king", + "non-consecutive", + "killer-cage", + "thermo", + "arrow", + "kropki", + "xv", + "inequality", + "renban", + "palindrome", + ]), + ); }); }); diff --git a/tests/solver/variantGenerator.test.ts b/tests/solver/variantGenerator.test.ts new file mode 100644 index 0000000..0f4488c --- /dev/null +++ b/tests/solver/variantGenerator.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; +import { classicRegions, type PuzzleDefinition } from "../../src/domain"; +import { + evaluateDifficulty, + generateVariant, + GENERATOR_VARIANTS, + solveExact, + type GeneratorVariant, +} from "../../src/solver"; + +const solution4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const; + +const puzzle4: PuzzleDefinition = { + version: 1, + size: 4, + givens: [1, 0, 0, 4, 0, 4, 1, 0, 4, 0, 2, 0, 0, 1, 0, 3], + solution: solution4, + regions: classicRegions(4), + constraints: [], +}; + +describe("difficulty assessment", () => { + it("rates a unique puzzle from deterministic solver evidence", () => { + const first = evaluateDifficulty(puzzle4); + const second = evaluateDifficulty(puzzle4); + expect(first.uniqueness).toBe("unique"); + expect(first.score).not.toBeNull(); + expect(first).toEqual(second); + expect(first.logicalSteps).toBeGreaterThan(0); + }); + + it("does not rate puzzles with multiple solutions", () => { + const assessment = evaluateDifficulty({ + ...puzzle4, + givens: new Array(16).fill(0), + solution: undefined, + }); + expect(assessment.uniqueness).toBe("multiple"); + expect(assessment.level).toBe("unrated"); + expect(assessment.score).toBeNull(); + }); + + it("never claims uniqueness after a bounded search is cut short", () => { + const assessment = evaluateDifficulty(puzzle4, { exactMaxNodes: 1 }); + expect(assessment.uniqueness).toBe("unknown"); + expect(assessment.score).toBeNull(); + }); +}); + +describe("variant generator", () => { + it("advertises only explicit, material variant definitions", () => { + expect(GENERATOR_VARIANTS.map(({ id }) => id)).toEqual([ + "classic", + "diagonal", + "anti-knight", + "anti-king", + "non-consecutive", + "killer", + "thermo", + "arrow", + "kropki", + "xv", + "inequality", + "renban", + "palindrome", + ]); + expect( + GENERATOR_VARIANTS.every( + ({ description, supportedSizes }) => + description.length > 0 && supportedSizes.length > 0, + ), + ).toBe(true); + }); + + it.each([ + ["classic", 4], + ["diagonal", 4], + ["anti-knight", 4], + ["anti-king", 6], + ["non-consecutive", 6], + ["killer", 4], + ["thermo", 4], + ["kropki", 4], + ["inequality", 4], + ["renban", 4], + ["palindrome", 4], + ["arrow", 6], + ["xv", 6], + ] as const)("generates a unique %s puzzle", (variant, size) => { + const generated = generateVariant({ + variant, + size, + seed: `test-${variant}`, + targetDifficulty: "beginner", + maxChecks: size * size, + solveTimeoutMs: 2_000, + }); + const exact = solveExact(generated.puzzle, { + maxSolutions: 2, + timeoutMs: 5_000, + }); + expect(exact.count).toBe(1); + expect(exact.truncated).toBe(false); + expect(exact.solutions[0]).toEqual(generated.puzzle.solution); + expect(generated.difficulty.uniqueness).toBe("unique"); + if (variant === "classic") { + expect(generated.puzzle.constraints).toHaveLength(0); + } else if (variant === "diagonal") { + expect( + generated.puzzle.constraints.filter(({ type }) => type === "diagonal"), + ).toHaveLength(2); + } else if (variant === "killer") { + expect( + generated.puzzle.constraints.some(({ type }) => type === "killer-cage"), + ).toBe(true); + } else { + expect( + generated.puzzle.constraints.some(({ type }) => type === variant), + ).toBe(true); + } + }); + + it.each(["classic", "killer", "thermo", "arrow"] as const)( + "is deterministic for %s generation", + (variant) => { + const size = variant === "arrow" ? 6 : 4; + const options = { + variant, + size, + seed: "repeatable", + targetClues: Math.ceil((size * size) / 2), + maxChecks: size * size, + } as const; + expect(generateVariant(options)).toEqual(generateVariant(options)); + }, + ); + + it.each(["diagonal", "anti-knight", "anti-king", "killer", "arrow"] as const)( + "keeps practical 9x9 %s generation bounded", + (variant) => { + const generated = generateVariant({ + variant, + size: 9, + seed: `practical-${variant}`, + targetClues: 50, + maxChecks: 12, + solveMaxNodes: 500_000, + solveTimeoutMs: 2_000, + }); + expect(generated.puzzle.size).toBe(9); + expect(generated.difficulty.uniqueness).toBe("unique"); + }, + ); + + it.each([ + ["anti-knight", 9], + ["anti-king", 9], + ["non-consecutive", 6], + ] as const)( + "constructs supported global %s grids across deterministic seeds", + (variant, size) => { + for (let seed = 0; seed < 5; seed += 1) { + const generated = generateVariant({ + variant, + size, + seed, + targetClues: size * size, + maxChecks: 1, + solveMaxNodes: 500_000, + solveTimeoutMs: 2_000, + }); + expect(generated.difficulty.uniqueness).toBe("unique"); + } + }, + ); + + it("rejects sizes outside a variant's reliable advertised range", () => { + expect(() => generateVariant({ variant: "arrow", size: 4 })).toThrow( + /supports sizes 6, 9/, + ); + expect(() => + generateVariant({ variant: "non-consecutive", size: 9 }), + ).toThrow(/supports sizes 6/); + }); + + it("labels generated rules only for the requested variant", () => { + const generated = generateVariant({ + variant: "xv", + size: 6, + seed: "rules", + targetClues: 30, + maxChecks: 6, + }); + expect(generated.puzzle.rules).toMatch(/numbered pair badge/i); + expect(generated.puzzle.rules).toMatch(/Only marked pairs/i); + expect(generated.puzzle.rules).not.toMatch(/Kropki|negative/i); + }); + + it("keeps the public variant ID type exhaustive", () => { + const ids: GeneratorVariant[] = GENERATOR_VARIANTS.map(({ id }) => id); + expect(new Set(ids).size).toBe(GENERATOR_VARIANTS.length); + }); +}); diff --git a/tests/state/gameplayHelpers.test.ts b/tests/state/gameplayHelpers.test.ts new file mode 100644 index 0000000..8e2db06 --- /dev/null +++ b/tests/state/gameplayHelpers.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { + digitCompletions, + matchingDigitCells, + matchingDigitCellsAt, + placedDigitAt, + toggledDigitHighlight, +} from "../../src/state/gameplayHelpers"; + +describe("digit completion helpers", () => { + it.each([4, 6, 9, 12, 16])( + "reports undone, done and overdone digits on a %i by %i grid", + (size) => { + const values = new Array(size * size).fill(0); + values.fill(1, 0, size - 1); + values.fill(2, size, size * 2); + values.fill(3, size * 2, size * 3 + 1); + + const progress = digitCompletions(size, values); + + expect(progress).toHaveLength(size); + expect(progress[0]).toEqual({ + digit: 1, + placed: size - 1, + target: size, + remaining: 1, + excess: 0, + status: "undone", + }); + expect(progress[1]).toEqual({ + digit: 2, + placed: size, + target: size, + remaining: 0, + excess: 0, + status: "done", + }); + expect(progress[2]).toEqual({ + digit: 3, + placed: size + 1, + target: size, + remaining: 0, + excess: 1, + status: "overdone", + }); + }, + ); + + it("ignores empty, invalid and out-of-grid values without mutating input", () => { + const values = [1, 1, 0, -1, 5, Number.NaN, ...new Array(12).fill(0), 1]; + const before = [...values]; + + expect(digitCompletions(4, values)[0]).toMatchObject({ + placed: 2, + remaining: 2, + status: "undone", + }); + expect(values).toEqual(before); + }); + + it.each([0, 3, 17, 4.5, Number.NaN])( + "rejects unsupported puzzle size %s", + (size) => { + expect(() => digitCompletions(size, [])).toThrow(RangeError); + }, + ); +}); + +describe("digit match highlighting", () => { + const values = [4, 0, 2, 4, 0, 3, 4, 0, 2, 0, 3, 0, 4, 2, 0, 3]; + + it("resolves a placed digit and all of its matching cells", () => { + expect(placedDigitAt(4, values, 3)).toBe(4); + expect(matchingDigitCells(4, values, 4)).toEqual([0, 3, 6, 12]); + expect(matchingDigitCellsAt(4, values, 3)).toEqual([0, 3, 6, 12]); + }); + + it("treats empty, invalid and out-of-grid cells as no match", () => { + expect(placedDigitAt(4, values, 1)).toBeNull(); + expect(placedDigitAt(4, values, -1)).toBeNull(); + expect(placedDigitAt(4, values, 16)).toBeNull(); + expect(matchingDigitCellsAt(4, values, 1)).toEqual([]); + expect(matchingDigitCells(4, values, 0)).toEqual([]); + expect(matchingDigitCells(4, values, 5)).toEqual([]); + }); + + it("toggles repeated Ctrl/Cmd-style activation and preserves empty clicks", () => { + expect(toggledDigitHighlight(4, values, 0, null)).toBe(4); + expect(toggledDigitHighlight(4, values, 3, 4)).toBeNull(); + expect(toggledDigitHighlight(4, values, 2, 4)).toBe(2); + expect(toggledDigitHighlight(4, values, 1, 4)).toBe(4); + }); + + it("never returns cells beyond the declared grid", () => { + expect(matchingDigitCells(4, [...values, 4, 4], 4)).toEqual([0, 3, 6, 12]); + }); +});