feat: launch local-first Sudoku workbench

This commit is contained in:
2026-08-30 14:14:11 +02:00
commit 659640b231
97 changed files with 19111 additions and 0 deletions
+174
View File
@@ -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),
),
};
}
+128
View File
@@ -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;
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./compile";
export * from "./geometry";
export * from "./rules";
export * from "./types";
export * from "./validation";
+342
View File
@@ -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
);
}
+141
View File
@@ -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;
}
}
+511
View File
@@ -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);
}