feat: complete advanced Sudoku workbench
This commit is contained in:
+119
-4
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user