feat: complete advanced Sudoku workbench

This commit is contained in:
2026-08-31 08:20:30 +02:00
parent 8ca9300ab3
commit 0a1bdc1a8c
99 changed files with 20793 additions and 923 deletions
+119 -4
View File
@@ -12,6 +12,7 @@ import {
type SudokuUnit,
type ValidationIssue,
} from "../domain";
import { findAdvancedLogicalStep } from "./advancedLogical";
export type LogicalTechnique =
| "naked-single"
@@ -26,6 +27,17 @@ export type LogicalTechnique =
| "claiming"
| "x-wing"
| "swordfish"
| "jellyfish"
| "finned-x-wing"
| "finned-swordfish"
| "skyscraper"
| "two-string-kite"
| "simple-colouring"
| "w-wing"
| "x-chain"
| "xy-chain"
| "aic"
| "unique-rectangle"
| "xy-wing"
| "xyz-wing"
| "killer-cage";
@@ -52,6 +64,19 @@ export type LogicalSolveStatus = "solved" | "stuck" | "invalid" | "step-limit";
export interface LogicalSolveOptions {
readonly values?: readonly number[];
/**
* Optional per-cell candidate restrictions to resume a logical solve after
* applying an elimination-only step. Each entry contains ordinary Sudoku
* digits (1 through the puzzle size), rather than an implementation-specific
* bit mask. Restrictions are intersected with the candidates that remain
* legal on the supplied board; entries for filled cells are ignored.
*/
readonly candidates?: readonly (readonly number[])[];
/**
* Enables uniqueness-dependent deductions only after the caller has proved
* exactly one solution with an exhaustive solver result. Never inferred.
*/
readonly uniquenessProven?: boolean;
readonly maxSteps?: number;
}
@@ -148,9 +173,73 @@ function validateStart(
if (issues.length > 0) throw new PuzzleValidationError(issues);
}
function compileCandidateRestrictions(
normalized: NormalizedPuzzle,
input: unknown,
): number[] | undefined {
if (input === undefined) return undefined;
const issues: ValidationIssue[] = [];
const cellCount = normalized.size * normalized.size;
if (!Array.isArray(input)) {
throw new PuzzleValidationError([
{ path: "candidates", message: "must be an array of candidate arrays" },
]);
}
if (input.length !== cellCount) {
issues.push({
path: "candidates",
message: `must contain exactly ${cellCount} candidate arrays`,
});
}
const masks = new Array<number>(cellCount).fill(0);
for (let cell = 0; cell < Math.min(input.length, cellCount); cell += 1) {
const cellCandidates: unknown = input[cell];
if (!Array.isArray(cellCandidates)) {
issues.push({
path: `candidates[${cell}]`,
message: "must be an array of candidate digits",
});
continue;
}
let mask = 0;
for (let index = 0; index < cellCandidates.length; index += 1) {
const value: unknown = cellCandidates[index];
if (
typeof value !== "number" ||
!Number.isInteger(value) ||
value < 1 ||
value > normalized.size
) {
issues.push({
path: `candidates[${cell}][${index}]`,
message: `must be an integer from 1 to ${normalized.size}`,
});
continue;
}
const bit = digitBit(value);
if ((mask & bit) !== 0) {
issues.push({
path: `candidates[${cell}][${index}]`,
message: `contains duplicate candidate ${value}`,
});
continue;
}
mask |= bit;
}
masks[cell] = mask;
}
if (issues.length > 0) throw new PuzzleValidationError(issues);
return masks;
}
function initializeState(
normalized: NormalizedPuzzle,
values: readonly number[],
candidateRestrictions?: readonly number[],
): LogicalState {
const compiled = compilePuzzle(normalized);
return {
@@ -158,10 +247,13 @@ function initializeState(
values: [...values],
masks: values.map((value, cell) => {
if (value !== 0) return 0;
return candidatesForCell(compiled, values, cell).reduce(
const legalMask = candidatesForCell(compiled, values, cell).reduce(
(mask, candidate) => mask | digitBit(candidate),
0,
);
return candidateRestrictions === undefined
? legalMask
: legalMask & (candidateRestrictions[cell] ?? 0);
}),
};
}
@@ -624,7 +716,10 @@ function findKillerReduction(state: LogicalState): LogicalStep | undefined {
return undefined;
}
function findStep(state: LogicalState): LogicalStep | undefined {
function findStep(
state: LogicalState,
uniquenessProven: boolean,
): LogicalStep | undefined {
return (
findNakedSingle(state) ??
findHiddenSingle(state) ??
@@ -634,6 +729,16 @@ function findStep(state: LogicalState): LogicalStep | undefined {
findFish(state) ??
findXyWing(state) ??
findXyzWing(state) ??
findAdvancedLogicalStep({
size: state.compiled.puzzle.size,
values: state.values,
masks: state.masks,
regions: state.compiled.puzzle.regions,
peers: state.compiled.peers,
units: state.compiled.units,
uniquenessProven,
uniquenessPatternsSafe: state.compiled.puzzle.constraints.length === 0,
}) ??
findKillerReduction(state)
);
}
@@ -677,7 +782,17 @@ export function solveLogically(
if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > 10_000) {
throw new RangeError("maxSteps must be an integer from 1 to 10000");
}
const state = initializeState(normalized, values);
if (
options.uniquenessProven !== undefined &&
typeof options.uniquenessProven !== "boolean"
) {
throw new TypeError("uniquenessProven must be a boolean when supplied");
}
const candidateRestrictions = compileCandidateRestrictions(
normalized,
options.candidates,
);
const state = initializeState(normalized, values, candidateRestrictions);
const steps: LogicalStep[] = [];
if (findConflicts(state.compiled, state.values).length > 0) {
return {
@@ -708,7 +823,7 @@ export function solveLogically(
steps,
};
}
const step = findStep(state);
const step = findStep(state, options.uniquenessProven === true);
if (step === undefined) {
return {
status: "stuck",