feat: launch local-first Sudoku workbench
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import { cellColumn, cellRow, orthogonalNeighbours } from "./geometry";
|
||||
import type { CellId, NormalizedPuzzle, VariantConstraint } from "./types";
|
||||
|
||||
export type UnitKind = "row" | "column" | "region" | "diagonal";
|
||||
|
||||
export interface SudokuUnit {
|
||||
readonly kind: UnitKind;
|
||||
readonly index: number;
|
||||
readonly cells: readonly CellId[];
|
||||
}
|
||||
|
||||
export interface CompiledPuzzle {
|
||||
readonly puzzle: NormalizedPuzzle;
|
||||
readonly units: readonly SudokuUnit[];
|
||||
readonly unitsByCell: readonly (readonly number[])[];
|
||||
/** Cells which may not contain an equal value. */
|
||||
readonly peers: readonly ReadonlySet<CellId>[];
|
||||
readonly constraintsByCell: readonly (readonly number[])[];
|
||||
readonly orthogonalByCell: readonly (readonly CellId[])[];
|
||||
}
|
||||
|
||||
function addPeerPair(peers: Set<CellId>[], a: CellId, b: CellId): void {
|
||||
if (a === b) return;
|
||||
peers[a]?.add(b);
|
||||
peers[b]?.add(a);
|
||||
}
|
||||
|
||||
function cellsForConstraint(
|
||||
size: number,
|
||||
constraint: VariantConstraint,
|
||||
): readonly CellId[] {
|
||||
switch (constraint.type) {
|
||||
case "diagonal":
|
||||
return Array.from({ length: size }, (_, index) =>
|
||||
constraint.direction === "main"
|
||||
? index * size + index
|
||||
: index * size + size - index - 1,
|
||||
);
|
||||
case "anti-knight":
|
||||
case "anti-king":
|
||||
case "non-consecutive":
|
||||
return Array.from({ length: size * size }, (_, cell) => cell);
|
||||
case "killer-cage":
|
||||
case "thermo":
|
||||
case "renban":
|
||||
case "palindrome":
|
||||
return constraint.cells;
|
||||
case "arrow":
|
||||
return [...constraint.bulb, ...constraint.line];
|
||||
case "kropki":
|
||||
case "xv":
|
||||
return [constraint.a, constraint.b];
|
||||
case "inequality":
|
||||
return [constraint.lesser, constraint.greater];
|
||||
}
|
||||
}
|
||||
|
||||
export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle {
|
||||
const { size } = puzzle;
|
||||
const count = size * size;
|
||||
const units: SudokuUnit[] = [];
|
||||
for (let index = 0; index < size; index += 1) {
|
||||
units.push({
|
||||
kind: "row",
|
||||
index,
|
||||
cells: Array.from({ length: size }, (_, column) => index * size + column),
|
||||
});
|
||||
units.push({
|
||||
kind: "column",
|
||||
index,
|
||||
cells: Array.from({ length: size }, (_, row) => row * size + index),
|
||||
});
|
||||
units.push({
|
||||
kind: "region",
|
||||
index,
|
||||
cells: Array.from({ length: count }, (_, cell) => cell).filter(
|
||||
(cell) => puzzle.regions[cell] === index,
|
||||
),
|
||||
});
|
||||
}
|
||||
for (const constraint of puzzle.constraints) {
|
||||
if (constraint.type !== "diagonal") continue;
|
||||
units.push({
|
||||
kind: "diagonal",
|
||||
index: constraint.direction === "main" ? 0 : 1,
|
||||
cells: cellsForConstraint(size, constraint),
|
||||
});
|
||||
}
|
||||
|
||||
const peers = Array.from({ length: count }, () => new Set<CellId>());
|
||||
const unitsByCell = Array.from({ length: count }, () => [] as number[]);
|
||||
units.forEach((unit, unitIndex) => {
|
||||
for (const cell of unit.cells) {
|
||||
unitsByCell[cell]?.push(unitIndex);
|
||||
for (const other of unit.cells) addPeerPair(peers, cell, other);
|
||||
}
|
||||
});
|
||||
|
||||
const constraintsByCell = Array.from({ length: count }, () => [] as number[]);
|
||||
puzzle.constraints.forEach((constraint, constraintIndex) => {
|
||||
for (const cell of new Set(cellsForConstraint(size, constraint))) {
|
||||
constraintsByCell[cell]?.push(constraintIndex);
|
||||
}
|
||||
if (constraint.type === "killer-cage" && constraint.noRepeat !== false) {
|
||||
for (const a of constraint.cells) {
|
||||
for (const b of constraint.cells) addPeerPair(peers, a, b);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const hasAntiKnight = puzzle.constraints.some(
|
||||
({ type }) => type === "anti-knight",
|
||||
);
|
||||
const hasAntiKing = puzzle.constraints.some(
|
||||
({ type }) => type === "anti-king",
|
||||
);
|
||||
if (hasAntiKnight || hasAntiKing) {
|
||||
for (let cell = 0; cell < count; cell += 1) {
|
||||
const row = cellRow(size, cell);
|
||||
const column = cellColumn(size, cell);
|
||||
if (hasAntiKnight) {
|
||||
for (const [dr, dc] of [
|
||||
[-2, -1],
|
||||
[-2, 1],
|
||||
[-1, -2],
|
||||
[-1, 2],
|
||||
[1, -2],
|
||||
[1, 2],
|
||||
[2, -1],
|
||||
[2, 1],
|
||||
] as const) {
|
||||
const otherRow = row + dr;
|
||||
const otherColumn = column + dc;
|
||||
if (
|
||||
otherRow >= 0 &&
|
||||
otherRow < size &&
|
||||
otherColumn >= 0 &&
|
||||
otherColumn < size
|
||||
) {
|
||||
addPeerPair(peers, cell, otherRow * size + otherColumn);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasAntiKing) {
|
||||
for (let dr = -1; dr <= 1; dr += 1) {
|
||||
for (let dc = -1; dc <= 1; dc += 1) {
|
||||
if (dr === 0 && dc === 0) continue;
|
||||
const otherRow = row + dr;
|
||||
const otherColumn = column + dc;
|
||||
if (
|
||||
otherRow >= 0 &&
|
||||
otherRow < size &&
|
||||
otherColumn >= 0 &&
|
||||
otherColumn < size
|
||||
) {
|
||||
addPeerPair(peers, cell, otherRow * size + otherColumn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
puzzle,
|
||||
units,
|
||||
unitsByCell,
|
||||
peers,
|
||||
constraintsByCell,
|
||||
orthogonalByCell: Array.from({ length: count }, (_, cell) =>
|
||||
orthogonalNeighbours(size, cell),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
EMPTY_VALUE,
|
||||
MAX_PUZZLE_SIZE,
|
||||
MIN_PUZZLE_SIZE,
|
||||
type CellId,
|
||||
type PuzzleDefinition,
|
||||
} from "./types";
|
||||
|
||||
function assertSize(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}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function cellId(size: number, row: number, column: number): CellId {
|
||||
assertSize(size);
|
||||
if (!Number.isInteger(row) || row < 0 || row >= size) {
|
||||
throw new RangeError("Row is outside the grid.");
|
||||
}
|
||||
if (!Number.isInteger(column) || column < 0 || column >= size) {
|
||||
throw new RangeError("Column is outside the grid.");
|
||||
}
|
||||
return row * size + column;
|
||||
}
|
||||
|
||||
export function cellRow(size: number, cell: CellId): number {
|
||||
assertCell(size, cell);
|
||||
return Math.floor(cell / size);
|
||||
}
|
||||
|
||||
export function cellColumn(size: number, cell: CellId): number {
|
||||
assertCell(size, cell);
|
||||
return cell % size;
|
||||
}
|
||||
|
||||
export function assertCell(size: number, cell: CellId): void {
|
||||
assertSize(size);
|
||||
if (!Number.isInteger(cell) || cell < 0 || cell >= size * size) {
|
||||
throw new RangeError(`Cell ${String(cell)} is outside the grid.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds conventional rectangular regions. For non-composite sizes this falls
|
||||
* back to 1 x size regions, which is still a valid Latin-square topology.
|
||||
*/
|
||||
export function classicRegions(
|
||||
size: number,
|
||||
boxRows?: number,
|
||||
boxColumns?: number,
|
||||
): number[] {
|
||||
assertSize(size);
|
||||
let rows = boxRows;
|
||||
let columns = boxColumns;
|
||||
if (rows === undefined && columns === undefined) {
|
||||
rows = Math.floor(Math.sqrt(size));
|
||||
while (rows > 1 && size % rows !== 0) rows -= 1;
|
||||
columns = size / rows;
|
||||
} else if (
|
||||
rows === undefined &&
|
||||
columns !== undefined &&
|
||||
size % columns === 0
|
||||
) {
|
||||
rows = size / columns;
|
||||
} else if (columns === undefined && rows !== undefined && size % rows === 0) {
|
||||
columns = size / rows;
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(rows) ||
|
||||
!Number.isInteger(columns) ||
|
||||
rows === undefined ||
|
||||
columns === undefined ||
|
||||
rows <= 0 ||
|
||||
columns <= 0 ||
|
||||
rows * columns !== size
|
||||
) {
|
||||
throw new RangeError(
|
||||
"Box rows and columns must be positive factors whose product is size.",
|
||||
);
|
||||
}
|
||||
const regions = new Array<number>(size * size);
|
||||
const boxesPerRow = size / columns;
|
||||
for (let row = 0; row < size; row += 1) {
|
||||
for (let column = 0; column < size; column += 1) {
|
||||
regions[row * size + column] =
|
||||
Math.floor(row / rows) * boxesPerRow + Math.floor(column / columns);
|
||||
}
|
||||
}
|
||||
return regions;
|
||||
}
|
||||
|
||||
export function createEmptyPuzzle(
|
||||
size = 9,
|
||||
options: {
|
||||
readonly boxRows?: number;
|
||||
readonly boxColumns?: number;
|
||||
readonly title?: string;
|
||||
readonly author?: string;
|
||||
} = {},
|
||||
): PuzzleDefinition {
|
||||
const puzzle: PuzzleDefinition = {
|
||||
version: 1,
|
||||
size,
|
||||
givens: new Array<number>(size * size).fill(EMPTY_VALUE),
|
||||
regions: classicRegions(size, options.boxRows, options.boxColumns),
|
||||
constraints: [],
|
||||
...(options.title === undefined ? {} : { title: options.title }),
|
||||
...(options.author === undefined ? {} : { author: options.author }),
|
||||
};
|
||||
return puzzle;
|
||||
}
|
||||
|
||||
export function orthogonalNeighbours(size: number, cell: CellId): CellId[] {
|
||||
const row = cellRow(size, cell);
|
||||
const column = cellColumn(size, cell);
|
||||
const result: CellId[] = [];
|
||||
if (row > 0) result.push(cell - size);
|
||||
if (row + 1 < size) result.push(cell + size);
|
||||
if (column > 0) result.push(cell - 1);
|
||||
if (column + 1 < size) result.push(cell + 1);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./compile";
|
||||
export * from "./geometry";
|
||||
export * from "./rules";
|
||||
export * from "./types";
|
||||
export * from "./validation";
|
||||
@@ -0,0 +1,342 @@
|
||||
import { compilePuzzle, type CompiledPuzzle } from "./compile";
|
||||
import type {
|
||||
CellId,
|
||||
NormalizedPuzzle,
|
||||
PuzzleConflict,
|
||||
PuzzleDefinition,
|
||||
VariantConstraint,
|
||||
} from "./types";
|
||||
import { normalizePuzzle } from "./validation";
|
||||
|
||||
function valuesAt(
|
||||
values: readonly number[],
|
||||
cells: readonly CellId[],
|
||||
): number[] {
|
||||
return cells.map((cell) => values[cell] ?? 0);
|
||||
}
|
||||
|
||||
function sumRange(
|
||||
assigned: readonly number[],
|
||||
blanks: number,
|
||||
size: number,
|
||||
noRepeat: boolean,
|
||||
): readonly [number, number] {
|
||||
if (!noRepeat) {
|
||||
const current = assigned.reduce((sum, value) => sum + value, 0);
|
||||
return [current + blanks, current + blanks * size];
|
||||
}
|
||||
const used = new Set(assigned);
|
||||
const available = Array.from(
|
||||
{ length: size },
|
||||
(_, index) => index + 1,
|
||||
).filter((value) => !used.has(value));
|
||||
if (available.length < blanks)
|
||||
return [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
|
||||
const current = assigned.reduce((sum, value) => sum + value, 0);
|
||||
const low = available
|
||||
.slice(0, blanks)
|
||||
.reduce((sum, value) => sum + value, current);
|
||||
const high = available
|
||||
.slice(-blanks)
|
||||
.reduce((sum, value) => sum + value, current);
|
||||
return [low, high];
|
||||
}
|
||||
|
||||
function sumsCanMeet(
|
||||
values: readonly number[],
|
||||
cells: readonly CellId[],
|
||||
size: number,
|
||||
): readonly [number, number] {
|
||||
let sum = 0;
|
||||
let blanks = 0;
|
||||
for (const cell of cells) {
|
||||
const value = values[cell] ?? 0;
|
||||
if (value === 0) blanks += 1;
|
||||
else sum += value;
|
||||
}
|
||||
return [sum + blanks, sum + blanks * size];
|
||||
}
|
||||
|
||||
export function constraintIsFeasible(
|
||||
constraint: VariantConstraint,
|
||||
values: readonly number[],
|
||||
size: number,
|
||||
): boolean {
|
||||
switch (constraint.type) {
|
||||
case "diagonal":
|
||||
case "anti-knight":
|
||||
case "anti-king":
|
||||
return true; // Equality conflicts are represented in compiled peers/units.
|
||||
case "non-consecutive": {
|
||||
for (let cell = 0; cell < size * size; cell += 1) {
|
||||
const value = values[cell] ?? 0;
|
||||
if (value === 0) continue;
|
||||
const row = Math.floor(cell / size);
|
||||
const column = cell % size;
|
||||
if (column + 1 < size) {
|
||||
const right = values[cell + 1] ?? 0;
|
||||
if (right !== 0 && Math.abs(value - right) === 1) return false;
|
||||
}
|
||||
if (row + 1 < size) {
|
||||
const below = values[cell + size] ?? 0;
|
||||
if (below !== 0 && Math.abs(value - below) === 1) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "killer-cage": {
|
||||
const cageValues = valuesAt(values, constraint.cells);
|
||||
const assigned = cageValues.filter((value) => value !== 0);
|
||||
if (
|
||||
constraint.noRepeat !== false &&
|
||||
new Set(assigned).size !== assigned.length
|
||||
)
|
||||
return false;
|
||||
const [low, high] = sumRange(
|
||||
assigned,
|
||||
cageValues.length - assigned.length,
|
||||
size,
|
||||
constraint.noRepeat !== false,
|
||||
);
|
||||
return constraint.sum >= low && constraint.sum <= high;
|
||||
}
|
||||
case "thermo": {
|
||||
const length = constraint.cells.length;
|
||||
const fixed: Array<readonly [number, number]> = [];
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const value = values[constraint.cells[index] ?? -1] ?? 0;
|
||||
if (value === 0) continue;
|
||||
if (value < index + 1 || value > size - length + index + 1)
|
||||
return false;
|
||||
fixed.push([index, value]);
|
||||
}
|
||||
for (let left = 0; left < fixed.length; left += 1) {
|
||||
for (let right = left + 1; right < fixed.length; right += 1) {
|
||||
const a = fixed[left];
|
||||
const b = fixed[right];
|
||||
if (a === undefined || b === undefined || b[1] - a[1] < b[0] - a[0])
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "arrow": {
|
||||
const [bulbLow, bulbHigh] = sumsCanMeet(values, constraint.bulb, size);
|
||||
const [lineLow, lineHigh] = sumsCanMeet(values, constraint.line, size);
|
||||
return bulbLow <= lineHigh && lineLow <= bulbHigh;
|
||||
}
|
||||
case "kropki": {
|
||||
const a = values[constraint.a] ?? 0;
|
||||
const b = values[constraint.b] ?? 0;
|
||||
if (a === 0 || b === 0) return true;
|
||||
return constraint.kind === "white"
|
||||
? Math.abs(a - b) === 1
|
||||
: a === b * 2 || b === a * 2;
|
||||
}
|
||||
case "xv": {
|
||||
const a = values[constraint.a] ?? 0;
|
||||
const b = values[constraint.b] ?? 0;
|
||||
if (a === 0 || b === 0) return true;
|
||||
return a + b === constraint.total;
|
||||
}
|
||||
case "inequality": {
|
||||
const lesser = values[constraint.lesser] ?? 0;
|
||||
const greater = values[constraint.greater] ?? 0;
|
||||
return lesser === 0 || greater === 0 || lesser < greater;
|
||||
}
|
||||
case "renban": {
|
||||
const assigned = valuesAt(values, constraint.cells).filter(
|
||||
(value) => value !== 0,
|
||||
);
|
||||
if (new Set(assigned).size !== assigned.length) return false;
|
||||
if (assigned.length <= 1) return true;
|
||||
const low = Math.min(...assigned);
|
||||
const high = Math.max(...assigned);
|
||||
return high - low < constraint.cells.length;
|
||||
}
|
||||
case "palindrome": {
|
||||
for (
|
||||
let index = 0;
|
||||
index < Math.floor(constraint.cells.length / 2);
|
||||
index += 1
|
||||
) {
|
||||
const a = values[constraint.cells[index] ?? -1] ?? 0;
|
||||
const b =
|
||||
values[constraint.cells[constraint.cells.length - index - 1] ?? -1] ??
|
||||
0;
|
||||
if (a !== 0 && b !== 0 && a !== b) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function asCompiled(
|
||||
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
|
||||
): CompiledPuzzle {
|
||||
if ("puzzle" in puzzle && "peers" in puzzle) return puzzle;
|
||||
return compilePuzzle(normalizePuzzle(puzzle));
|
||||
}
|
||||
|
||||
export function canPlaceValue(
|
||||
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
|
||||
values: readonly number[],
|
||||
cell: CellId,
|
||||
value: number,
|
||||
): boolean {
|
||||
const compiled = asCompiled(puzzle);
|
||||
const size = compiled.puzzle.size;
|
||||
if (!Number.isInteger(cell) || cell < 0 || cell >= size * size) return false;
|
||||
if (!Number.isInteger(value) || value < 1 || value > size) return false;
|
||||
for (const peer of compiled.peers[cell] ?? []) {
|
||||
if (values[peer] === value) return false;
|
||||
}
|
||||
const next = values.slice();
|
||||
next[cell] = value;
|
||||
for (const index of compiled.constraintsByCell[cell] ?? []) {
|
||||
const constraint = compiled.puzzle.constraints[index];
|
||||
if (
|
||||
constraint !== undefined &&
|
||||
!constraintIsFeasible(constraint, next, size)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function candidatesForCell(
|
||||
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
|
||||
values: readonly number[],
|
||||
cell: CellId,
|
||||
): number[] {
|
||||
const compiled = asCompiled(puzzle);
|
||||
if ((values[cell] ?? 0) !== 0) return [];
|
||||
const result: number[] = [];
|
||||
for (let value = 1; value <= compiled.puzzle.size; value += 1) {
|
||||
if (canPlaceValue(compiled, values, cell, value)) result.push(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function allCandidates(
|
||||
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
|
||||
values: readonly number[],
|
||||
): readonly (readonly number[])[] {
|
||||
const compiled = asCompiled(puzzle);
|
||||
return values.map((value, cell) =>
|
||||
value === 0 ? candidatesForCell(compiled, values, cell) : [],
|
||||
);
|
||||
}
|
||||
|
||||
export function findConflicts(
|
||||
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
|
||||
values?: readonly number[],
|
||||
): PuzzleConflict[] {
|
||||
const compiled = asCompiled(puzzle);
|
||||
const board = values ?? compiled.puzzle.givens;
|
||||
const conflicts: PuzzleConflict[] = [];
|
||||
if (board.length !== compiled.puzzle.size * compiled.puzzle.size) {
|
||||
return [
|
||||
{
|
||||
kind: "invalid-value",
|
||||
cells: [],
|
||||
message: "Board length does not match the grid.",
|
||||
},
|
||||
];
|
||||
}
|
||||
board.forEach((value, cell) => {
|
||||
if (!Number.isInteger(value) || value < 0 || value > compiled.puzzle.size) {
|
||||
conflicts.push({
|
||||
kind: "invalid-value",
|
||||
cells: [cell],
|
||||
message: "Value is outside the grid range.",
|
||||
});
|
||||
}
|
||||
});
|
||||
compiled.units.forEach((unit) => {
|
||||
const byValue = new Map<number, CellId[]>();
|
||||
for (const cell of unit.cells) {
|
||||
const value = board[cell] ?? 0;
|
||||
if (value === 0) continue;
|
||||
const cells = byValue.get(value) ?? [];
|
||||
cells.push(cell);
|
||||
byValue.set(value, cells);
|
||||
}
|
||||
for (const [value, cells] of byValue) {
|
||||
if (cells.length > 1) {
|
||||
conflicts.push({
|
||||
kind: "duplicate",
|
||||
cells,
|
||||
message: `Value ${value} is repeated in ${unit.kind} ${unit.index + 1}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
// Peer relationships not already represented by a unit (anti chess and cage uniqueness).
|
||||
const unitPairs = new Set<string>();
|
||||
for (const unit of compiled.units) {
|
||||
for (const a of unit.cells)
|
||||
for (const b of unit.cells)
|
||||
unitPairs.add(a < b ? `${a}:${b}` : `${b}:${a}`);
|
||||
}
|
||||
compiled.peers.forEach((peers, a) => {
|
||||
for (const b of peers) {
|
||||
if (a >= b || unitPairs.has(`${a}:${b}`)) continue;
|
||||
const value = board[a] ?? 0;
|
||||
if (value !== 0 && value === board[b]) {
|
||||
conflicts.push({
|
||||
kind: "duplicate",
|
||||
cells: [a, b],
|
||||
message: "Equal values conflict.",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
compiled.puzzle.constraints.forEach((constraint, constraintIndex) => {
|
||||
if (!constraintIsFeasible(constraint, board, compiled.puzzle.size)) {
|
||||
const cells = (() => {
|
||||
switch (constraint.type) {
|
||||
case "diagonal":
|
||||
case "anti-knight":
|
||||
case "anti-king":
|
||||
case "non-consecutive":
|
||||
return Array.from(
|
||||
{ length: compiled.puzzle.size * compiled.puzzle.size },
|
||||
(_, cell) => cell,
|
||||
);
|
||||
case "killer-cage":
|
||||
case "thermo":
|
||||
case "renban":
|
||||
case "palindrome":
|
||||
return constraint.cells;
|
||||
case "arrow":
|
||||
return [...constraint.bulb, ...constraint.line];
|
||||
case "kropki":
|
||||
case "xv":
|
||||
return [constraint.a, constraint.b];
|
||||
case "inequality":
|
||||
return [constraint.lesser, constraint.greater];
|
||||
}
|
||||
})();
|
||||
conflicts.push({
|
||||
kind: "constraint",
|
||||
cells,
|
||||
message: `${constraint.type} constraint cannot be satisfied.`,
|
||||
constraintIndex,
|
||||
});
|
||||
}
|
||||
});
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
export function isSolved(
|
||||
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
|
||||
values: readonly number[],
|
||||
): boolean {
|
||||
const compiled = asCompiled(puzzle);
|
||||
return (
|
||||
values.length === compiled.puzzle.size * compiled.puzzle.size &&
|
||||
values.every((value) => value !== 0) &&
|
||||
findConflicts(compiled, values).length === 0
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
export const MIN_PUZZLE_SIZE = 4;
|
||||
export const MAX_PUZZLE_SIZE = 16;
|
||||
export const EMPTY_VALUE = 0;
|
||||
|
||||
/** A zero-based cell index: `row * size + column`. */
|
||||
export type CellId = number;
|
||||
export type CellValue = number;
|
||||
|
||||
export interface DiagonalConstraint {
|
||||
readonly type: "diagonal";
|
||||
readonly direction: "main" | "anti";
|
||||
}
|
||||
|
||||
export interface AntiKnightConstraint {
|
||||
readonly type: "anti-knight";
|
||||
}
|
||||
|
||||
export interface AntiKingConstraint {
|
||||
readonly type: "anti-king";
|
||||
}
|
||||
|
||||
export interface NonConsecutiveConstraint {
|
||||
readonly type: "non-consecutive";
|
||||
}
|
||||
|
||||
export interface KillerCageConstraint {
|
||||
readonly type: "killer-cage";
|
||||
readonly cells: readonly CellId[];
|
||||
readonly sum: number;
|
||||
/** Killer cages normally do not repeat digits. Defaults to true. */
|
||||
readonly noRepeat?: boolean;
|
||||
}
|
||||
|
||||
export interface ThermoConstraint {
|
||||
readonly type: "thermo";
|
||||
/** Ordered from bulb to tip. Values must increase strictly. */
|
||||
readonly cells: readonly CellId[];
|
||||
}
|
||||
|
||||
export interface ArrowConstraint {
|
||||
readonly type: "arrow";
|
||||
/** Bulb cells. Their values sum to the values on the line. */
|
||||
readonly bulb: readonly CellId[];
|
||||
readonly line: readonly CellId[];
|
||||
}
|
||||
|
||||
export interface KropkiConstraint {
|
||||
readonly type: "kropki";
|
||||
readonly a: CellId;
|
||||
readonly b: CellId;
|
||||
/** White means consecutive; black means a 1:2 ratio. */
|
||||
readonly kind: "white" | "black";
|
||||
}
|
||||
|
||||
export interface XvConstraint {
|
||||
readonly type: "xv";
|
||||
readonly a: CellId;
|
||||
readonly b: CellId;
|
||||
readonly total: 5 | 10;
|
||||
}
|
||||
|
||||
export interface InequalityConstraint {
|
||||
readonly type: "inequality";
|
||||
readonly lesser: CellId;
|
||||
readonly greater: CellId;
|
||||
}
|
||||
|
||||
export interface RenbanConstraint {
|
||||
readonly type: "renban";
|
||||
readonly cells: readonly CellId[];
|
||||
}
|
||||
|
||||
export interface PalindromeConstraint {
|
||||
readonly type: "palindrome";
|
||||
readonly cells: readonly CellId[];
|
||||
}
|
||||
|
||||
export type VariantConstraint =
|
||||
| DiagonalConstraint
|
||||
| AntiKnightConstraint
|
||||
| AntiKingConstraint
|
||||
| NonConsecutiveConstraint
|
||||
| KillerCageConstraint
|
||||
| ThermoConstraint
|
||||
| ArrowConstraint
|
||||
| KropkiConstraint
|
||||
| XvConstraint
|
||||
| InequalityConstraint
|
||||
| RenbanConstraint
|
||||
| PalindromeConstraint;
|
||||
|
||||
export interface PuzzleDefinition {
|
||||
readonly version: 1;
|
||||
readonly size: number;
|
||||
/** Row-major values. Zero denotes an empty cell. */
|
||||
readonly givens: readonly CellValue[];
|
||||
/** Row-major region IDs in the range 0..size-1. Omit for rectangular boxes. */
|
||||
readonly regions?: readonly number[];
|
||||
readonly constraints?: readonly VariantConstraint[];
|
||||
readonly id?: string;
|
||||
readonly title?: string;
|
||||
readonly author?: string;
|
||||
readonly rules?: string;
|
||||
/** Optional trusted solution, stored row-major without zeroes. */
|
||||
readonly solution?: readonly CellValue[];
|
||||
}
|
||||
|
||||
export interface NormalizedPuzzle extends PuzzleDefinition {
|
||||
readonly regions: readonly number[];
|
||||
readonly constraints: readonly VariantConstraint[];
|
||||
}
|
||||
|
||||
export type ConflictKind =
|
||||
"duplicate" | "constraint" | "impossible" | "invalid-value";
|
||||
|
||||
export interface PuzzleConflict {
|
||||
readonly kind: ConflictKind;
|
||||
readonly cells: readonly CellId[];
|
||||
readonly message: string;
|
||||
readonly constraintIndex?: number;
|
||||
}
|
||||
|
||||
export interface ValidationIssue {
|
||||
readonly path: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
readonly valid: boolean;
|
||||
readonly issues: readonly ValidationIssue[];
|
||||
}
|
||||
|
||||
export class PuzzleValidationError extends Error {
|
||||
readonly issues: readonly ValidationIssue[];
|
||||
|
||||
constructor(issues: readonly ValidationIssue[]) {
|
||||
super(issues.map((issue) => `${issue.path}: ${issue.message}`).join("; "));
|
||||
this.name = "PuzzleValidationError";
|
||||
this.issues = issues;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
import { classicRegions } from "./geometry";
|
||||
import { compilePuzzle } from "./compile";
|
||||
import { findConflicts } from "./rules";
|
||||
import {
|
||||
MAX_PUZZLE_SIZE,
|
||||
MIN_PUZZLE_SIZE,
|
||||
PuzzleValidationError,
|
||||
type NormalizedPuzzle,
|
||||
type PuzzleDefinition,
|
||||
type ValidationIssue,
|
||||
type ValidationResult,
|
||||
type VariantConstraint,
|
||||
} from "./types";
|
||||
|
||||
const MAX_CONSTRAINTS = 4_096;
|
||||
const MAX_SHORT_TEXT = 256;
|
||||
const MAX_RULES_TEXT = 16_384;
|
||||
const ROOT_KEYS = new Set([
|
||||
"version",
|
||||
"size",
|
||||
"givens",
|
||||
"regions",
|
||||
"constraints",
|
||||
"id",
|
||||
"title",
|
||||
"author",
|
||||
"rules",
|
||||
"solution",
|
||||
]);
|
||||
|
||||
const CONSTRAINT_KEYS: Readonly<
|
||||
Record<VariantConstraint["type"], ReadonlySet<string>>
|
||||
> = {
|
||||
diagonal: new Set(["type", "direction"]),
|
||||
"anti-knight": new Set(["type"]),
|
||||
"anti-king": new Set(["type"]),
|
||||
"non-consecutive": new Set(["type"]),
|
||||
"killer-cage": new Set(["type", "cells", "sum", "noRepeat"]),
|
||||
thermo: new Set(["type", "cells"]),
|
||||
arrow: new Set(["type", "bulb", "line"]),
|
||||
kropki: new Set(["type", "a", "b", "kind"]),
|
||||
xv: new Set(["type", "a", "b", "total"]),
|
||||
inequality: new Set(["type", "lesser", "greater"]),
|
||||
renban: new Set(["type", "cells"]),
|
||||
palindrome: new Set(["type", "cells"]),
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function add(issues: ValidationIssue[], path: string, message: string): void {
|
||||
issues.push({ path, message });
|
||||
}
|
||||
|
||||
function validateText(
|
||||
value: unknown,
|
||||
path: string,
|
||||
maximum: number,
|
||||
issues: ValidationIssue[],
|
||||
): void {
|
||||
if (value === undefined) return;
|
||||
if (typeof value !== "string") add(issues, path, "must be a string");
|
||||
else if (value.length > maximum)
|
||||
add(issues, path, `must contain at most ${maximum} characters`);
|
||||
}
|
||||
|
||||
function validateCell(
|
||||
value: unknown,
|
||||
path: string,
|
||||
cellCount: number,
|
||||
issues: ValidationIssue[],
|
||||
): value is number {
|
||||
if (
|
||||
!Number.isInteger(value) ||
|
||||
(value as number) < 0 ||
|
||||
(value as number) >= cellCount
|
||||
) {
|
||||
add(issues, path, `must be an integer from 0 to ${cellCount - 1}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateCells(
|
||||
value: unknown,
|
||||
path: string,
|
||||
cellCount: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
issues: ValidationIssue[],
|
||||
): value is readonly number[] {
|
||||
if (!Array.isArray(value)) {
|
||||
add(issues, path, "must be an array");
|
||||
return false;
|
||||
}
|
||||
if (value.length < minimum || value.length > maximum) {
|
||||
add(issues, path, `must contain ${minimum} to ${maximum} cells`);
|
||||
}
|
||||
const seen = new Set<number>();
|
||||
value.forEach((cell, index) => {
|
||||
if (validateCell(cell, `${path}[${index}]`, cellCount, issues)) {
|
||||
if (seen.has(cell))
|
||||
add(issues, `${path}[${index}]`, "must not repeat a cell");
|
||||
seen.add(cell);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateConstraint(
|
||||
value: unknown,
|
||||
index: number,
|
||||
size: number,
|
||||
issues: ValidationIssue[],
|
||||
): void {
|
||||
const path = `constraints[${index}]`;
|
||||
if (!isRecord(value) || typeof value.type !== "string") {
|
||||
add(issues, path, "must be a constraint object with a type");
|
||||
return;
|
||||
}
|
||||
const type = value.type as VariantConstraint["type"];
|
||||
const allowed = CONSTRAINT_KEYS[type];
|
||||
if (allowed === undefined) {
|
||||
add(issues, `${path}.type`, "is not a supported constraint type");
|
||||
return;
|
||||
}
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.has(key))
|
||||
add(issues, `${path}.${key}`, "is not a recognized field");
|
||||
}
|
||||
const cellCount = size * size;
|
||||
switch (type) {
|
||||
case "diagonal":
|
||||
if (value.direction !== "main" && value.direction !== "anti") {
|
||||
add(issues, `${path}.direction`, 'must be "main" or "anti"');
|
||||
}
|
||||
break;
|
||||
case "anti-knight":
|
||||
case "anti-king":
|
||||
case "non-consecutive":
|
||||
break;
|
||||
case "killer-cage": {
|
||||
const cellsValid = validateCells(
|
||||
value.cells,
|
||||
`${path}.cells`,
|
||||
cellCount,
|
||||
1,
|
||||
size,
|
||||
issues,
|
||||
);
|
||||
if (
|
||||
typeof value.noRepeat !== "undefined" &&
|
||||
typeof value.noRepeat !== "boolean"
|
||||
) {
|
||||
add(issues, `${path}.noRepeat`, "must be a boolean");
|
||||
}
|
||||
if (!Number.isInteger(value.sum)) {
|
||||
add(issues, `${path}.sum`, "must be an integer");
|
||||
} else if (cellsValid) {
|
||||
const cells = value.cells as readonly number[];
|
||||
const length = cells.length;
|
||||
const noRepeat = value.noRepeat !== false;
|
||||
const minimum = noRepeat ? (length * (length + 1)) / 2 : length;
|
||||
const maximum = noRepeat
|
||||
? (length * (2 * size - length + 1)) / 2
|
||||
: length * size;
|
||||
if (
|
||||
(value.sum as number) < minimum ||
|
||||
(value.sum as number) > maximum
|
||||
) {
|
||||
add(
|
||||
issues,
|
||||
`${path}.sum`,
|
||||
`must be reachable (${minimum} to ${maximum})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "thermo":
|
||||
validateCells(value.cells, `${path}.cells`, cellCount, 2, size, issues);
|
||||
break;
|
||||
case "arrow": {
|
||||
const bulbValid = validateCells(
|
||||
value.bulb,
|
||||
`${path}.bulb`,
|
||||
cellCount,
|
||||
1,
|
||||
size,
|
||||
issues,
|
||||
);
|
||||
const lineValid = validateCells(
|
||||
value.line,
|
||||
`${path}.line`,
|
||||
cellCount,
|
||||
1,
|
||||
cellCount,
|
||||
issues,
|
||||
);
|
||||
if (bulbValid && lineValid) {
|
||||
const bulb = new Set(value.bulb as readonly number[]);
|
||||
if ((value.line as readonly number[]).some((cell) => bulb.has(cell))) {
|
||||
add(issues, path, "bulb and line cells must be disjoint");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "kropki":
|
||||
validateCell(value.a, `${path}.a`, cellCount, issues);
|
||||
validateCell(value.b, `${path}.b`, cellCount, issues);
|
||||
if (value.a === value.b)
|
||||
add(issues, path, "endpoints must be different cells");
|
||||
if (value.kind !== "white" && value.kind !== "black") {
|
||||
add(issues, `${path}.kind`, 'must be "white" or "black"');
|
||||
}
|
||||
break;
|
||||
case "xv":
|
||||
validateCell(value.a, `${path}.a`, cellCount, issues);
|
||||
validateCell(value.b, `${path}.b`, cellCount, issues);
|
||||
if (value.a === value.b)
|
||||
add(issues, path, "endpoints must be different cells");
|
||||
if (value.total !== 5 && value.total !== 10)
|
||||
add(issues, `${path}.total`, "must be 5 or 10");
|
||||
break;
|
||||
case "inequality":
|
||||
validateCell(value.lesser, `${path}.lesser`, cellCount, issues);
|
||||
validateCell(value.greater, `${path}.greater`, cellCount, issues);
|
||||
if (value.lesser === value.greater)
|
||||
add(issues, path, "endpoints must be different cells");
|
||||
break;
|
||||
case "renban":
|
||||
validateCells(value.cells, `${path}.cells`, cellCount, 2, size, issues);
|
||||
break;
|
||||
case "palindrome":
|
||||
validateCells(
|
||||
value.cells,
|
||||
`${path}.cells`,
|
||||
cellCount,
|
||||
2,
|
||||
cellCount,
|
||||
issues,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function validateValueArray(
|
||||
value: unknown,
|
||||
path: string,
|
||||
size: number,
|
||||
allowEmpty: boolean,
|
||||
issues: ValidationIssue[],
|
||||
): value is readonly number[] {
|
||||
if (!Array.isArray(value)) {
|
||||
add(issues, path, "must be an array");
|
||||
return false;
|
||||
}
|
||||
if (value.length !== size * size)
|
||||
add(issues, path, `must contain exactly ${size * size} values`);
|
||||
value.forEach((entry, index) => {
|
||||
const minimum = allowEmpty ? 0 : 1;
|
||||
if (!Number.isInteger(entry) || entry < minimum || entry > size) {
|
||||
add(
|
||||
issues,
|
||||
`${path}[${index}]`,
|
||||
`must be an integer from ${minimum} to ${size}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
|
||||
switch (constraint.type) {
|
||||
case "diagonal":
|
||||
return { type: constraint.type, direction: constraint.direction };
|
||||
case "anti-knight":
|
||||
case "anti-king":
|
||||
case "non-consecutive":
|
||||
return { type: constraint.type };
|
||||
case "killer-cage":
|
||||
return {
|
||||
type: constraint.type,
|
||||
cells: [...constraint.cells],
|
||||
sum: constraint.sum,
|
||||
...(constraint.noRepeat === undefined
|
||||
? {}
|
||||
: { noRepeat: constraint.noRepeat }),
|
||||
};
|
||||
case "thermo":
|
||||
case "renban":
|
||||
case "palindrome":
|
||||
return { type: constraint.type, cells: [...constraint.cells] };
|
||||
case "arrow":
|
||||
return {
|
||||
type: constraint.type,
|
||||
bulb: [...constraint.bulb],
|
||||
line: [...constraint.line],
|
||||
};
|
||||
case "kropki":
|
||||
return {
|
||||
type: constraint.type,
|
||||
a: constraint.a,
|
||||
b: constraint.b,
|
||||
kind: constraint.kind,
|
||||
};
|
||||
case "xv":
|
||||
return {
|
||||
type: constraint.type,
|
||||
a: constraint.a,
|
||||
b: constraint.b,
|
||||
total: constraint.total,
|
||||
};
|
||||
case "inequality":
|
||||
return {
|
||||
type: constraint.type,
|
||||
lesser: constraint.lesser,
|
||||
greater: constraint.greater,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedUnchecked(puzzle: PuzzleDefinition): NormalizedPuzzle {
|
||||
return {
|
||||
version: 1,
|
||||
size: puzzle.size,
|
||||
givens: [...puzzle.givens],
|
||||
regions:
|
||||
puzzle.regions === undefined
|
||||
? classicRegions(puzzle.size)
|
||||
: [...puzzle.regions],
|
||||
constraints: (puzzle.constraints ?? []).map(cloneConstraint),
|
||||
...(puzzle.id === undefined ? {} : { id: puzzle.id }),
|
||||
...(puzzle.title === undefined ? {} : { title: puzzle.title }),
|
||||
...(puzzle.author === undefined ? {} : { author: puzzle.author }),
|
||||
...(puzzle.rules === undefined ? {} : { rules: puzzle.rules }),
|
||||
...(puzzle.solution === undefined
|
||||
? {}
|
||||
: { solution: [...puzzle.solution] }),
|
||||
};
|
||||
}
|
||||
|
||||
export function validatePuzzle(puzzle: unknown): ValidationResult {
|
||||
const issues: ValidationIssue[] = [];
|
||||
if (!isRecord(puzzle))
|
||||
return {
|
||||
valid: false,
|
||||
issues: [{ path: "$", message: "must be an object" }],
|
||||
};
|
||||
for (const key of Object.keys(puzzle)) {
|
||||
if (!ROOT_KEYS.has(key)) add(issues, key, "is not a recognized field");
|
||||
}
|
||||
if (puzzle.version !== 1) add(issues, "version", "must be 1");
|
||||
const size = puzzle.size;
|
||||
if (
|
||||
!Number.isInteger(size) ||
|
||||
(size as number) < MIN_PUZZLE_SIZE ||
|
||||
(size as number) > MAX_PUZZLE_SIZE
|
||||
) {
|
||||
add(
|
||||
issues,
|
||||
"size",
|
||||
`must be an integer from ${MIN_PUZZLE_SIZE} to ${MAX_PUZZLE_SIZE}`,
|
||||
);
|
||||
return { valid: false, issues };
|
||||
}
|
||||
const n = size as number;
|
||||
const givensValid = validateValueArray(
|
||||
puzzle.givens,
|
||||
"givens",
|
||||
n,
|
||||
true,
|
||||
issues,
|
||||
);
|
||||
let regionsValid = true;
|
||||
if (puzzle.regions !== undefined) {
|
||||
if (!Array.isArray(puzzle.regions)) {
|
||||
add(issues, "regions", "must be an array");
|
||||
regionsValid = false;
|
||||
} else {
|
||||
if (puzzle.regions.length !== n * n) {
|
||||
add(issues, "regions", `must contain exactly ${n * n} values`);
|
||||
regionsValid = false;
|
||||
}
|
||||
puzzle.regions.forEach((region, index) => {
|
||||
if (!Number.isInteger(region) || region < 0 || region >= n) {
|
||||
add(
|
||||
issues,
|
||||
`regions[${index}]`,
|
||||
`must be an integer from 0 to ${n - 1}`,
|
||||
);
|
||||
regionsValid = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (Array.isArray(puzzle.regions)) {
|
||||
const counts = new Array<number>(n).fill(0);
|
||||
puzzle.regions.forEach((region) => {
|
||||
if (Number.isInteger(region) && region >= 0 && region < n) {
|
||||
counts[region] = (counts[region] ?? 0) + 1;
|
||||
}
|
||||
});
|
||||
counts.forEach((count, region) => {
|
||||
if (count !== n)
|
||||
add(
|
||||
issues,
|
||||
"regions",
|
||||
`region ${region} must contain exactly ${n} cells`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (puzzle.constraints !== undefined && !Array.isArray(puzzle.constraints)) {
|
||||
add(issues, "constraints", "must be an array");
|
||||
} else if (Array.isArray(puzzle.constraints)) {
|
||||
if (puzzle.constraints.length > MAX_CONSTRAINTS) {
|
||||
add(
|
||||
issues,
|
||||
"constraints",
|
||||
`must contain at most ${MAX_CONSTRAINTS} constraints`,
|
||||
);
|
||||
}
|
||||
puzzle.constraints
|
||||
.slice(0, MAX_CONSTRAINTS + 1)
|
||||
.forEach((constraint, index) => {
|
||||
validateConstraint(constraint, index, n, issues);
|
||||
});
|
||||
}
|
||||
validateText(puzzle.id, "id", MAX_SHORT_TEXT, issues);
|
||||
validateText(puzzle.title, "title", MAX_SHORT_TEXT, issues);
|
||||
validateText(puzzle.author, "author", MAX_SHORT_TEXT, issues);
|
||||
validateText(puzzle.rules, "rules", MAX_RULES_TEXT, issues);
|
||||
const solutionValid =
|
||||
puzzle.solution === undefined ||
|
||||
validateValueArray(puzzle.solution, "solution", n, false, issues);
|
||||
|
||||
if (
|
||||
issues.length === 0 &&
|
||||
givensValid &&
|
||||
regionsValid &&
|
||||
solutionValid &&
|
||||
(puzzle.constraints === undefined || Array.isArray(puzzle.constraints))
|
||||
) {
|
||||
const normalized = normalizedUnchecked(
|
||||
puzzle as unknown as PuzzleDefinition,
|
||||
);
|
||||
const compiled = compilePuzzle(normalized);
|
||||
for (const conflict of findConflicts(compiled, normalized.givens)) {
|
||||
add(issues, "givens", conflict.message);
|
||||
}
|
||||
if (normalized.solution !== undefined) {
|
||||
for (let cell = 0; cell < normalized.givens.length; cell += 1) {
|
||||
const given = normalized.givens[cell] ?? 0;
|
||||
if (given !== 0 && normalized.solution[cell] !== given) {
|
||||
add(issues, `solution[${cell}]`, "must agree with the given value");
|
||||
}
|
||||
}
|
||||
for (const conflict of findConflicts(compiled, normalized.solution)) {
|
||||
add(issues, "solution", conflict.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { valid: issues.length === 0, issues };
|
||||
}
|
||||
|
||||
export function assertValidPuzzle(
|
||||
puzzle: unknown,
|
||||
): asserts puzzle is PuzzleDefinition {
|
||||
const result = validatePuzzle(puzzle);
|
||||
if (!result.valid) throw new PuzzleValidationError(result.issues);
|
||||
}
|
||||
|
||||
export function normalizePuzzle(puzzle: PuzzleDefinition): NormalizedPuzzle {
|
||||
assertValidPuzzle(puzzle);
|
||||
return normalizedUnchecked(puzzle);
|
||||
}
|
||||
|
||||
export function validateBoard(
|
||||
puzzle: PuzzleDefinition | NormalizedPuzzle,
|
||||
values: unknown,
|
||||
): ValidationResult {
|
||||
const normalized = normalizePuzzle(puzzle);
|
||||
const issues: ValidationIssue[] = [];
|
||||
const valuesValid = validateValueArray(
|
||||
values,
|
||||
"values",
|
||||
normalized.size,
|
||||
true,
|
||||
issues,
|
||||
);
|
||||
if (valuesValid) {
|
||||
values.forEach((value, cell) => {
|
||||
const given = normalized.givens[cell] ?? 0;
|
||||
if (given !== 0 && value !== given)
|
||||
add(issues, `values[${cell}]`, "must preserve the given value");
|
||||
});
|
||||
for (const conflict of findConflicts(compilePuzzle(normalized), values)) {
|
||||
add(issues, "values", conflict.message);
|
||||
}
|
||||
}
|
||||
return { valid: issues.length === 0, issues };
|
||||
}
|
||||
|
||||
export function assertValidBoard(
|
||||
puzzle: PuzzleDefinition | NormalizedPuzzle,
|
||||
values: unknown,
|
||||
): asserts values is readonly number[] {
|
||||
const result = validateBoard(puzzle, values);
|
||||
if (!result.valid) throw new PuzzleValidationError(result.issues);
|
||||
}
|
||||
Reference in New Issue
Block a user