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
+35 -62
View File
@@ -1,12 +1,11 @@
import { constraintCells } from "./constraintRegistry";
import { cellColumn, cellRow, orthogonalNeighbours } from "./geometry";
import type {
CellId,
NormalizedPuzzle,
OutsideClueSide,
VariantConstraint,
} from "./types";
import type { CellId, NormalizedPuzzle } from "./types";
export type UnitKind = "row" | "column" | "region" | "diagonal";
export { outsideLineCells } from "./geometry";
export type UnitKind =
"row" | "column" | "region" | "diagonal" | "disjoint-group" | "extra-region";
export interface SudokuUnit {
readonly kind: UnitKind;
@@ -30,63 +29,18 @@ function addPeerPair(peers: Set<CellId>[], a: CellId, b: CellId): void {
peers[b]?.add(a);
}
/** Cells seen from an outside clue, ordered from the clue into the grid. */
export function outsideLineCells(
size: number,
side: OutsideClueSide,
index: number,
function disjointGroupCells(
puzzle: NormalizedPuzzle,
position: number,
): CellId[] {
return Array.from({ length: size }, (_, offset) => {
switch (side) {
case "top":
return offset * size + index;
case "right":
return index * size + size - offset - 1;
case "bottom":
return (size - offset - 1) * size + index;
case "left":
return index * size + offset;
}
const regions = Array.from({ length: puzzle.size }, () => [] as CellId[]);
puzzle.regions.forEach((region, cell) => regions[region]?.push(cell));
return regions.flatMap((cells) => {
const cell = cells[position];
return cell === undefined ? [] : [cell];
});
}
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];
case "x-sum":
case "skyscraper":
return outsideLineCells(size, constraint.side, constraint.index);
case "quadruple":
return constraint.cells;
case "maximum":
return [constraint.cell, ...orthogonalNeighbours(size, constraint.cell)];
}
}
export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle {
const { size } = puzzle;
const count = size * size;
@@ -115,9 +69,28 @@ export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle {
units.push({
kind: "diagonal",
index: constraint.direction === "main" ? 0 : 1,
cells: cellsForConstraint(size, constraint),
cells: constraintCells(size, constraint),
});
}
if (puzzle.constraints.some(({ type }) => type === "disjoint-groups")) {
for (let position = 0; position < size; position += 1) {
units.push({
kind: "disjoint-group",
index: position,
cells: disjointGroupCells(puzzle, position),
});
}
}
let extraRegionIndex = 0;
for (const constraint of puzzle.constraints) {
if (constraint.type !== "extra-region") continue;
units.push({
kind: "extra-region",
index: extraRegionIndex,
cells: constraint.cells,
});
extraRegionIndex += 1;
}
const peers = Array.from({ length: count }, () => new Set<CellId>());
const unitsByCell = Array.from({ length: count }, () => [] as number[]);
@@ -130,7 +103,7 @@ export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle {
const constraintsByCell = Array.from({ length: count }, () => [] as number[]);
puzzle.constraints.forEach((constraint, constraintIndex) => {
for (const cell of new Set(cellsForConstraint(size, constraint))) {
for (const cell of constraintCells(size, constraint)) {
constraintsByCell[cell]?.push(constraintIndex);
}
if (
+482
View File
@@ -0,0 +1,482 @@
import {
correspondingBoxPositionCells,
littleKillerCells,
orthogonalNeighbours,
outsideLineCells,
} from "./geometry";
import type { CellId, VariantConstraint } from "./types";
export type ConstraintType = VariantConstraint["type"];
export type ConstraintCategory =
"global" | "region" | "line" | "adjacency" | "outside" | "cell";
export type ConstraintFieldKind =
| "discriminator"
| "boolean"
| "integer"
| "enum"
| "cell"
| "cells"
| "integers"
| "outside-side";
export interface ConstraintFieldMetadata {
readonly key: string;
readonly kind: ConstraintFieldKind;
readonly required: boolean;
}
export interface ConstraintRegistryEntry<
Type extends ConstraintType = ConstraintType,
> {
readonly type: Type;
readonly label: string;
readonly category: ConstraintCategory;
readonly negatable: boolean;
readonly fields: readonly ConstraintFieldMetadata[];
readonly cells: (
size: number,
constraint: Extract<VariantConstraint, { readonly type: Type }>,
) => readonly CellId[];
}
type ConstraintRegistry = {
readonly [Type in ConstraintType]: ConstraintRegistryEntry<Type>;
};
const typeField: ConstraintFieldMetadata = {
key: "type",
kind: "discriminator",
required: true,
};
const negatedField: ConstraintFieldMetadata = {
key: "negated",
kind: "boolean",
required: false,
};
const allCells = (size: number): CellId[] =>
Array.from({ length: size * size }, (_, cell) => cell);
const cellField = (key = "cell"): ConstraintFieldMetadata => ({
key,
kind: "cell",
required: true,
});
const cellsField = (key = "cells"): ConstraintFieldMetadata => ({
key,
kind: "cells",
required: true,
});
const integerField = (key: string): ConstraintFieldMetadata => ({
key,
kind: "integer",
required: true,
});
const enumField = (key: string): ConstraintFieldMetadata => ({
key,
kind: "enum",
required: true,
});
const outsideFields = (
valueField: string,
): readonly ConstraintFieldMetadata[] => [
typeField,
{ key: "side", kind: "outside-side", required: true },
integerField("index"),
integerField(valueField),
negatedField,
];
function entry<Type extends ConstraintType>(
value: ConstraintRegistryEntry<Type>,
): ConstraintRegistryEntry<Type> {
return value;
}
export const CONSTRAINT_REGISTRY = {
diagonal: entry({
type: "diagonal",
label: "Diagonal",
category: "global",
negatable: false,
fields: [typeField, enumField("direction")],
cells: (size, constraint) =>
Array.from({ length: size }, (_, index) =>
constraint.direction === "main"
? index * size + index
: index * size + size - index - 1,
),
}),
"anti-knight": entry({
type: "anti-knight",
label: "Anti-knight",
category: "global",
negatable: false,
fields: [typeField],
cells: (size) => allCells(size),
}),
"anti-king": entry({
type: "anti-king",
label: "Anti-king",
category: "global",
negatable: false,
fields: [typeField],
cells: (size) => allCells(size),
}),
"non-consecutive": entry({
type: "non-consecutive",
label: "Non-consecutive",
category: "global",
negatable: false,
fields: [typeField],
cells: (size) => allCells(size),
}),
"disjoint-groups": entry({
type: "disjoint-groups",
label: "Disjoint groups",
category: "global",
negatable: false,
fields: [typeField],
cells: (size) => allCells(size),
}),
"killer-cage": entry({
type: "killer-cage",
label: "Killer cage",
category: "region",
negatable: true,
fields: [
typeField,
cellsField(),
integerField("sum"),
{ key: "noRepeat", kind: "boolean", required: false },
negatedField,
],
cells: (_size, constraint) => constraint.cells,
}),
thermo: entry({
type: "thermo",
label: "Thermometer",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
arrow: entry({
type: "arrow",
label: "Arrow",
category: "line",
negatable: true,
fields: [typeField, cellsField("bulb"), cellsField("line"), negatedField],
cells: (_size, constraint) => [...constraint.bulb, ...constraint.line],
}),
kropki: entry({
type: "kropki",
label: "Kropki dot",
category: "adjacency",
negatable: true,
fields: [
typeField,
cellField("a"),
cellField("b"),
enumField("kind"),
negatedField,
],
cells: (_size, constraint) => [constraint.a, constraint.b],
}),
xv: entry({
type: "xv",
label: "XV pair",
category: "adjacency",
negatable: true,
fields: [
typeField,
cellField("a"),
cellField("b"),
integerField("total"),
negatedField,
],
cells: (_size, constraint) => [constraint.a, constraint.b],
}),
inequality: entry({
type: "inequality",
label: "Inequality",
category: "adjacency",
negatable: true,
fields: [
typeField,
cellField("lesser"),
cellField("greater"),
negatedField,
],
cells: (_size, constraint) => [constraint.lesser, constraint.greater],
}),
renban: entry({
type: "renban",
label: "Renban line",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
palindrome: entry({
type: "palindrome",
label: "Palindrome line",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
"x-sum": entry({
type: "x-sum",
label: "X-sum",
category: "outside",
negatable: true,
fields: outsideFields("sum"),
cells: (size, constraint) =>
outsideLineCells(size, constraint.side, constraint.index),
}),
skyscraper: entry({
type: "skyscraper",
label: "Skyscraper",
category: "outside",
negatable: true,
fields: outsideFields("count"),
cells: (size, constraint) =>
outsideLineCells(size, constraint.side, constraint.index),
}),
quadruple: entry({
type: "quadruple",
label: "Quadruple",
category: "cell",
negatable: true,
fields: [
typeField,
cellsField(),
{ key: "digits", kind: "integers", required: true },
negatedField,
],
cells: (_size, constraint) => constraint.cells,
}),
maximum: entry({
type: "maximum",
label: "Maximum cell",
category: "cell",
negatable: true,
fields: [typeField, cellField(), negatedField],
cells: (size, constraint) => [
constraint.cell,
...orthogonalNeighbours(size, constraint.cell),
],
}),
minimum: entry({
type: "minimum",
label: "Minimum cell",
category: "cell",
negatable: true,
fields: [typeField, cellField(), negatedField],
cells: (size, constraint) => [
constraint.cell,
...orthogonalNeighbours(size, constraint.cell),
],
}),
odd: entry({
type: "odd",
label: "Odd cell",
category: "cell",
negatable: true,
fields: [typeField, cellField(), negatedField],
cells: (_size, constraint) => [constraint.cell],
}),
even: entry({
type: "even",
label: "Even cell",
category: "cell",
negatable: true,
fields: [typeField, cellField(), negatedField],
cells: (_size, constraint) => [constraint.cell],
}),
"little-killer": entry({
type: "little-killer",
label: "Little killer",
category: "outside",
negatable: true,
fields: [
typeField,
{ key: "side", kind: "outside-side", required: true },
integerField("index"),
enumField("direction"),
integerField("sum"),
negatedField,
],
cells: (size, constraint) =>
littleKillerCells(
size,
constraint.side,
constraint.index,
constraint.direction,
),
}),
sandwich: entry({
type: "sandwich",
label: "Sandwich sum",
category: "outside",
negatable: true,
fields: outsideFields("sum"),
cells: (size, constraint) =>
outsideLineCells(size, constraint.side, constraint.index),
}),
"between-line": entry({
type: "between-line",
label: "Between line",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
"german-whisper": entry({
type: "german-whisper",
label: "German whisper",
category: "line",
negatable: true,
fields: [
typeField,
cellsField(),
{ key: "minimumDifference", kind: "integer", required: false },
negatedField,
],
cells: (_size, constraint) => constraint.cells,
}),
"region-sum-line": entry({
type: "region-sum-line",
label: "Region-sum line",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
clone: entry({
type: "clone",
label: "Clone regions",
category: "region",
negatable: true,
fields: [typeField, cellsField(), cellsField("cloneCells"), negatedField],
cells: (_size, constraint) => [
...constraint.cells,
...constraint.cloneCells,
],
}),
"extra-region": entry({
type: "extra-region",
label: "Extra region",
category: "region",
negatable: false,
fields: [typeField, cellsField()],
cells: (_size, constraint) => constraint.cells,
}),
"modular-line": entry({
type: "modular-line",
label: "Modular line",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
"entropic-line": entry({
type: "entropic-line",
label: "Entropic line",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
"zipper-line": entry({
type: "zipper-line",
label: "Zipper line",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
"double-arrow": entry({
type: "double-arrow",
label: "Double arrow",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
indexer: entry({
type: "indexer",
label: "Indexer",
category: "cell",
negatable: true,
fields: [typeField, enumField("kind"), cellField(), negatedField],
cells: (size, constraint) => {
const row = Math.floor(constraint.cell / size);
const column = constraint.cell % size;
if (constraint.kind === "row") {
return Array.from(
{ length: size },
(_, targetRow) => targetRow * size + column,
);
}
if (constraint.kind === "column") {
return Array.from(
{ length: size },
(_, targetColumn) => row * size + targetColumn,
);
}
return correspondingBoxPositionCells(size, constraint.cell);
},
}),
fog: entry({
type: "fog",
label: "Fog of war",
category: "global",
negatable: false,
fields: [
typeField,
cellsField("lights"),
{ key: "revealRadius", kind: "integer", required: false },
],
cells: (_size, constraint) => constraint.lights,
}),
} satisfies ConstraintRegistry;
export const CONSTRAINT_TYPES = Object.freeze(
Object.keys(CONSTRAINT_REGISTRY) as ConstraintType[],
);
export function isConstraintType(value: string): value is ConstraintType {
return Object.hasOwn(CONSTRAINT_REGISTRY, value);
}
export function constraintMetadata<Type extends ConstraintType>(
type: Type,
): ConstraintRegistryEntry<Type> {
return CONSTRAINT_REGISTRY[type] as unknown as ConstraintRegistryEntry<Type>;
}
export function constraintLabel(type: string): string {
if (isConstraintType(type)) return CONSTRAINT_REGISTRY[type].label;
return type
.split("-")
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join(" ");
}
export function constraintAllowedFields(
type: ConstraintType,
): ReadonlySet<string> {
return new Set(CONSTRAINT_REGISTRY[type].fields.map(({ key }) => key));
}
export function constraintCells(
size: number,
constraint: VariantConstraint,
): readonly CellId[] {
const resolver = CONSTRAINT_REGISTRY[constraint.type].cells as (
size: number,
constraint: never,
) => readonly CellId[];
return [...new Set(resolver(size, constraint as never))];
}
+143 -7
View File
@@ -3,6 +3,8 @@ import {
MAX_PUZZLE_SIZE,
MIN_PUZZLE_SIZE,
type CellId,
type LittleKillerDirection,
type OutsideClueSide,
type PuzzleDefinition,
} from "./types";
@@ -46,15 +48,17 @@ export function assertCell(size: number, cell: CellId): void {
}
}
/**
* 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(
export interface ClassicBoxDimensions {
readonly rows: number;
readonly columns: number;
readonly boxesPerRow: number;
}
export function classicBoxDimensions(
size: number,
boxRows?: number,
boxColumns?: number,
): number[] {
): ClassicBoxDimensions {
assertSize(size);
let rows = boxRows;
let columns = boxColumns;
@@ -84,8 +88,24 @@ export function classicRegions(
"Box rows and columns must be positive factors whose product is size.",
);
}
return { rows, columns, boxesPerRow: size / columns };
}
/**
* 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[] {
const { rows, columns, boxesPerRow } = classicBoxDimensions(
size,
boxRows,
boxColumns,
);
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] =
@@ -95,6 +115,52 @@ export function classicRegions(
return regions;
}
export function classicBoxIndex(size: number, cell: CellId): number {
assertCell(size, cell);
const { rows, columns, boxesPerRow } = classicBoxDimensions(size);
return (
Math.floor(cellRow(size, cell) / rows) * boxesPerRow +
Math.floor(cellColumn(size, cell) / columns)
);
}
export function classicBoxPosition(size: number, cell: CellId): number {
assertCell(size, cell);
const { rows, columns } = classicBoxDimensions(size);
return (
(cellRow(size, cell) % rows) * columns + (cellColumn(size, cell) % columns)
);
}
export function classicBoxCell(
size: number,
boxIndex: number,
position: number,
): CellId {
const { rows, columns, boxesPerRow } = classicBoxDimensions(size);
if (!Number.isInteger(boxIndex) || boxIndex < 0 || boxIndex >= size) {
throw new RangeError("Box index is outside the grid.");
}
if (!Number.isInteger(position) || position < 0 || position >= size) {
throw new RangeError("Box position is outside the grid.");
}
const boxRow = Math.floor(boxIndex / boxesPerRow);
const boxColumn = boxIndex % boxesPerRow;
const row = boxRow * rows + Math.floor(position / columns);
const column = boxColumn * columns + (position % columns);
return cellId(size, row, column);
}
export function correspondingBoxPositionCells(
size: number,
cell: CellId,
): CellId[] {
const position = classicBoxPosition(size, cell);
return Array.from({ length: size }, (_, box) =>
classicBoxCell(size, box, position),
);
}
export function createEmptyPuzzle(
size = 9,
options: {
@@ -127,6 +193,76 @@ export function orthogonalNeighbours(size: number, cell: CellId): CellId[] {
return result;
}
/** Cells seen from an outside clue, ordered from the clue into the grid. */
export function outsideLineCells(
size: number,
side: OutsideClueSide,
index: number,
): CellId[] {
assertSize(size);
if (!Number.isInteger(index) || index < 0 || index >= size) return [];
return Array.from({ length: size }, (_, offset) => {
switch (side) {
case "top":
return offset * size + index;
case "right":
return index * size + size - offset - 1;
case "bottom":
return (size - offset - 1) * size + index;
case "left":
return index * size + offset;
}
});
}
export function littleKillerDirectionEntersGrid(
side: OutsideClueSide,
direction: LittleKillerDirection,
): boolean {
switch (side) {
case "top":
return direction === "down-left" || direction === "down-right";
case "right":
return direction === "down-left" || direction === "up-left";
case "bottom":
return direction === "up-left" || direction === "up-right";
case "left":
return direction === "down-right" || direction === "up-right";
}
}
/** Diagonal cells crossed by a little-killer clue, from its outside origin. */
export function littleKillerCells(
size: number,
side: OutsideClueSide,
index: number,
direction: LittleKillerDirection,
): CellId[] {
assertSize(size);
if (!Number.isInteger(index) || index < 0 || index >= size) return [];
const [rowStep, columnStep] = (() => {
switch (direction) {
case "down-right":
return [1, 1] as const;
case "down-left":
return [1, -1] as const;
case "up-right":
return [-1, 1] as const;
case "up-left":
return [-1, -1] as const;
}
})();
let row = side === "top" ? 0 : side === "bottom" ? size - 1 : index;
let column = side === "left" ? 0 : side === "right" ? size - 1 : index;
const cells: CellId[] = [];
while (row >= 0 && row < size && column >= 0 && column < size) {
cells.push(row * size + column);
row += rowStep;
column += columnStep;
}
return cells;
}
/** True when four cells meet at one internal grid intersection. */
export function cellsFormQuadruple(
size: number,
+1
View File
@@ -1,4 +1,5 @@
export * from "./compile";
export * from "./constraintRegistry";
export * from "./geometry";
export * from "./rules";
export * from "./types";
+536 -82
View File
@@ -1,9 +1,14 @@
import { compilePuzzle, type CompiledPuzzle } from "./compile";
import { constraintCells } from "./constraintRegistry";
import {
compilePuzzle,
classicBoxCell,
classicBoxIndex,
classicBoxPosition,
classicRegions,
littleKillerCells,
orthogonalNeighbours,
outsideLineCells,
type CompiledPuzzle,
} from "./compile";
import { orthogonalNeighbours } from "./geometry";
} from "./geometry";
import type {
CellId,
NormalizedPuzzle,
@@ -255,33 +260,382 @@ function quadrupleCanMeet(
return missing <= blanks;
}
function maximumCanMeet(
function extremumCanMeet(
cell: CellId,
values: readonly number[],
size: number,
kind: "maximum" | "minimum",
): boolean {
const maximum = values[cell] ?? 0;
const extremum = values[cell] ?? 0;
const neighbours = orthogonalNeighbours(size, cell).map(
(neighbour) => values[neighbour] ?? 0,
);
if (maximum === 0) {
return neighbours.every((value) => value === 0 || value < size);
if (extremum === 0) {
return neighbours.every((value) =>
value === 0 ? true : kind === "maximum" ? value < size : value > 1,
);
}
return neighbours.every((value) =>
value === 0 ? maximum > 1 : value < maximum,
return neighbours.every((value) => {
if (value === 0) return kind === "maximum" ? extremum > 1 : extremum < size;
return kind === "maximum" ? value < extremum : value > extremum;
});
}
function littleKillerCanMeet(
values: readonly number[],
cells: readonly CellId[],
size: number,
target: number,
): boolean {
const [minimum, maximum] = sumsCanMeet(values, cells, size);
return target >= minimum && target <= maximum;
}
function sandwichPossibleSums(
values: readonly number[],
cells: readonly CellId[],
size: number,
): ReadonlySet<number> {
const line = valuesAt(values, cells);
const fixed = line.filter((value) => value !== 0);
if (
fixed.some((value) => value < 1 || value > size) ||
new Set(fixed).size !== fixed.length
) {
return new Set();
}
const fixedOne = line.indexOf(1);
const fixedMaximum = line.indexOf(size);
const possible = new Set<number>();
for (let one = 0; one < size; one += 1) {
if (fixedOne >= 0 && one !== fixedOne) continue;
if (line[one] !== 0 && line[one] !== 1) continue;
for (let maximum = 0; maximum < size; maximum += 1) {
if (maximum === one) continue;
if (fixedMaximum >= 0 && maximum !== fixedMaximum) continue;
if (line[maximum] !== 0 && line[maximum] !== size) continue;
const start = Math.min(one, maximum) + 1;
const end = Math.max(one, maximum);
let assignedSum = 0;
let blanks = 0;
for (let position = start; position < end; position += 1) {
const value = line[position] ?? 0;
if (value === 0) blanks += 1;
else assignedSum += value;
}
const reserved = new Set(fixed);
reserved.add(1);
reserved.add(size);
const available = Array.from(
{ length: Math.max(0, size - 2) },
(_, index) => index + 2,
).filter((digit) => !reserved.has(digit));
const maximumExtra = available.reduce((sum, digit) => sum + digit, 0);
for (let extra = 0; extra <= maximumExtra; extra += 1) {
if (canChooseDistinctSum(available, blanks, extra)) {
possible.add(assignedSum + extra);
}
}
}
}
return possible;
}
function betweenLineCanMeet(
cells: readonly CellId[],
values: readonly number[],
size: number,
negated: boolean,
): boolean {
const firstFixed = values[cells[0] ?? -1] ?? 0;
const lastFixed = values[cells.at(-1) ?? -1] ?? 0;
const choices = (fixed: number): readonly number[] =>
fixed === 0
? Array.from({ length: size }, (_, index) => index + 1)
: [fixed];
for (const first of choices(firstFixed)) {
for (const last of choices(lastFixed)) {
const low = Math.min(first, last);
const high = Math.max(first, last);
if (high - low < 2) {
if (negated) return true;
continue;
}
let positivePossible = true;
let negativePossible = false;
for (const cell of cells.slice(1, -1)) {
const value = values[cell] ?? 0;
if (value === 0) {
negativePossible = true;
continue;
}
if (value <= low || value >= high) {
positivePossible = false;
negativePossible = true;
}
}
if (negated ? negativePossible : positivePossible) return true;
}
}
return false;
}
function germanWhisperCanMeet(
cells: readonly CellId[],
values: readonly number[],
size: number,
minimumDifference: number,
requireViolation: boolean,
): boolean {
const choices = (cell: CellId): readonly number[] => {
const fixed = values[cell] ?? 0;
return fixed === 0
? Array.from({ length: size }, (_, index) => index + 1)
: [fixed];
};
let states = new Set(choices(cells[0] ?? -1).map((digit) => `${digit}:0`));
for (let position = 1; position < cells.length; position += 1) {
const next = new Set<string>();
for (const state of states) {
const [previousText, violationText] = state.split(":");
const previous = Number(previousText);
const violated = violationText === "1";
for (const digit of choices(cells[position] ?? -1)) {
const pairViolates = Math.abs(previous - digit) < minimumDifference;
if (!requireViolation && pairViolates) continue;
next.add(`${digit}:${violated || pairViolates ? "1" : "0"}`);
}
}
states = next;
if (states.size === 0) return false;
}
return requireViolation
? [...states].some((state) => state.endsWith(":1"))
: states.size > 0;
}
function regionLineSegments(
cells: readonly CellId[],
regions: readonly number[],
): readonly (readonly CellId[])[] {
const segments: CellId[][] = [];
for (const cell of cells) {
const previous = segments.at(-1);
const previousCell = previous?.at(-1);
if (
previous === undefined ||
previousCell === undefined ||
regions[previousCell] !== regions[cell]
) {
segments.push([cell]);
} else {
previous.push(cell);
}
}
return segments;
}
function regionSumRanges(
cells: readonly CellId[],
values: readonly number[],
regions: readonly number[],
size: number,
): readonly (readonly [number, number])[] {
return regionLineSegments(cells, regions).map((segment) =>
sumsCanMeet(values, segment, size),
);
}
function regionSumLineCanMeet(
cells: readonly CellId[],
values: readonly number[],
regions: readonly number[],
size: number,
negated: boolean,
): boolean {
const ranges = regionSumRanges(cells, values, regions, size);
if (ranges.length < 2) return !negated;
if (negated) {
const first = ranges[0]!;
return (
first[0] !== first[1] ||
ranges
.slice(1)
.some((range) => range[0] !== range[1] || range[0] !== first[0])
);
}
const minimum = Math.max(...ranges.map((range) => range[0]));
const maximum = Math.min(...ranges.map((range) => range[1]));
return minimum <= maximum;
}
function cloneCanMeet(
source: readonly CellId[],
clone: readonly CellId[],
values: readonly number[],
negated: boolean,
): boolean {
let hasUnknown = false;
for (let index = 0; index < source.length; index += 1) {
const first = values[source[index] ?? -1] ?? 0;
const second = values[clone[index] ?? -1] ?? 0;
if (first === 0 || second === 0) hasUnknown = true;
else if (first !== second) return negated;
}
return negated ? hasUnknown : true;
}
const THREE_CLASS_PERMUTATIONS = [
[0, 1, 2],
[0, 2, 1],
[1, 0, 2],
[1, 2, 0],
[2, 0, 1],
[2, 1, 0],
] as const;
function threeClassLineCanMeet(
cells: readonly CellId[],
values: readonly number[],
classify: (value: number) => 0 | 1 | 2,
negated: boolean,
): boolean {
if (negated && cells.some((cell) => (values[cell] ?? 0) === 0)) {
// Every class has at least one digit on validated grids. A blank can
// therefore be chosen to violate one window; peer rules can only narrow
// this, so retaining the state is a sound over-approximation.
return true;
}
const positive = THREE_CLASS_PERMUTATIONS.some((classes) =>
cells.every((cell, position) => {
const value = values[cell] ?? 0;
return value === 0 || classify(value) === classes[position % 3];
}),
);
return negated ? !positive : positive;
}
function zipperLineCanMeet(
cells: readonly CellId[],
values: readonly number[],
size: number,
negated: boolean,
): boolean {
const middle = Math.floor(cells.length / 2);
const fixedCentre = values[cells[middle] ?? -1] ?? 0;
const centres =
fixedCentre === 0
? Array.from({ length: size }, (_, index) => index + 1)
: [fixedCentre];
for (const centre of centres) {
let positivePossible = true;
let negativePossible = false;
for (let offset = 1; offset <= middle; offset += 1) {
const left = values[cells[middle - offset] ?? -1] ?? 0;
const right = values[cells[middle + offset] ?? -1] ?? 0;
if (left !== 0 && right !== 0) {
if (left + right !== centre) {
positivePossible = false;
negativePossible = true;
}
continue;
}
negativePossible = true;
if (left === 0 && right === 0) {
if (centre < 2 || centre > size * 2) positivePossible = false;
} else {
const fixed = left === 0 ? right : left;
const missing = centre - fixed;
if (missing < 1 || missing > size) positivePossible = false;
}
}
if (negated ? negativePossible : positivePossible) return true;
}
return false;
}
function doubleArrowCanMeet(
cells: readonly CellId[],
values: readonly number[],
size: number,
negated: boolean,
): boolean {
const endpoints = [cells[0]!, cells.at(-1)!];
const interior = cells.slice(1, -1);
const endpointRange = sumsCanMeet(values, endpoints, size);
const interiorRange = sumsCanMeet(values, interior, size);
if (negated) {
const complete = cells.every((cell) => (values[cell] ?? 0) !== 0);
if (!complete) {
// Each blank occurs on only one side of the equation and has at least
// four possible raw digits. Keeping it cannot reject a valid completion.
return true;
}
return endpointRange[0] !== interiorRange[0];
}
return (
endpointRange[0] <= interiorRange[1] && interiorRange[0] <= endpointRange[1]
);
}
function indexerTarget(
kind: "row" | "column" | "box",
cell: CellId,
value: number,
size: number,
): readonly [target: CellId, required: number] {
const row = Math.floor(cell / size);
const column = cell % size;
if (kind === "row") return [(value - 1) * size + column, row + 1];
if (kind === "column") return [row * size + value - 1, column + 1];
return [
classicBoxCell(size, value - 1, classicBoxPosition(size, cell)),
classicBoxIndex(size, cell) + 1,
];
}
function indexerCanMeet(
constraint: Extract<VariantConstraint, { readonly type: "indexer" }>,
values: readonly number[],
size: number,
negated: boolean,
): boolean {
const fixed = values[constraint.cell] ?? 0;
const candidates =
fixed === 0
? Array.from({ length: size }, (_, index) => index + 1)
: [fixed];
for (const value of candidates) {
const [target, required] = indexerTarget(
constraint.kind,
constraint.cell,
value,
size,
);
const targetValue =
target === constraint.cell ? value : (values[target] ?? 0);
if (!negated && (targetValue === 0 || targetValue === required))
return true;
if (negated && (targetValue === 0 || targetValue !== required)) return true;
}
return false;
}
function positiveConstraintIsFeasible(
constraint: VariantConstraint,
values: readonly number[],
size: number,
regions: readonly number[],
): boolean {
switch (constraint.type) {
case "diagonal":
case "anti-knight":
case "anti-king":
case "disjoint-groups":
case "extra-region":
return true; // Equality conflicts are represented in compiled peers/units.
case "fog":
return true; // Fog is presentation state and never changes solutions.
case "non-consecutive": {
for (let cell = 0; cell < size * size; cell += 1) {
const value = values[cell] ?? 0;
@@ -400,7 +754,82 @@ function positiveConstraintIsFeasible(
case "quadruple":
return quadrupleCanMeet(constraint, values);
case "maximum":
return maximumCanMeet(constraint.cell, values, size);
return extremumCanMeet(constraint.cell, values, size, "maximum");
case "minimum":
return extremumCanMeet(constraint.cell, values, size, "minimum");
case "odd": {
const value = values[constraint.cell] ?? 0;
return value === 0 || value % 2 === 1;
}
case "even": {
const value = values[constraint.cell] ?? 0;
return value === 0 || value % 2 === 0;
}
case "little-killer":
return littleKillerCanMeet(
values,
littleKillerCells(
size,
constraint.side,
constraint.index,
constraint.direction,
),
size,
constraint.sum,
);
case "sandwich":
return sandwichPossibleSums(
values,
outsideLineCells(size, constraint.side, constraint.index),
size,
).has(constraint.sum);
case "between-line":
return betweenLineCanMeet(constraint.cells, values, size, false);
case "german-whisper":
return germanWhisperCanMeet(
constraint.cells,
values,
size,
constraint.minimumDifference ?? Math.ceil(size / 2),
false,
);
case "region-sum-line":
return regionSumLineCanMeet(
constraint.cells,
values,
regions,
size,
false,
);
case "clone":
return cloneCanMeet(
constraint.cells,
constraint.cloneCells,
values,
false,
);
case "modular-line":
return threeClassLineCanMeet(
constraint.cells,
values,
(value) => (value % 3) as 0 | 1 | 2,
false,
);
case "entropic-line": {
const bandSize = size / 3;
return threeClassLineCanMeet(
constraint.cells,
values,
(value) => Math.floor((value - 1) / bandSize) as 0 | 1 | 2,
false,
);
}
case "zipper-line":
return zipperLineCanMeet(constraint.cells, values, size, false);
case "double-arrow":
return doubleArrowCanMeet(constraint.cells, values, size, false);
case "indexer":
return indexerCanMeet(constraint, values, size, false);
}
}
@@ -408,31 +837,8 @@ function completedCells(
constraint: VariantConstraint,
size: number,
): readonly CellId[] | undefined {
switch (constraint.type) {
case "diagonal":
case "anti-knight":
case "anti-king":
case "non-consecutive":
return undefined;
case "killer-cage":
case "thermo":
case "renban":
case "palindrome":
case "quadruple":
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];
case "x-sum":
case "skyscraper":
return outsideLineCells(size, constraint.side, constraint.index);
case "maximum":
return [constraint.cell, ...orthogonalNeighbours(size, constraint.cell)];
}
if (!("negated" in constraint)) return undefined;
return constraintCells(size, constraint);
}
/**
@@ -446,6 +852,7 @@ function negatedConstraintIsFeasible(
constraint: VariantConstraint,
values: readonly number[],
size: number,
regions: readonly number[],
): boolean {
if (constraint.type === "x-sum") {
const line = valuesAt(
@@ -476,15 +883,89 @@ function negatedConstraintIsFeasible(
}
if (constraint.type === "maximum") {
const maximum = values[constraint.cell] ?? 0;
if (maximum === 0) return true;
const extremum = values[constraint.cell] ?? 0;
if (extremum === 0) return true;
const neighbours = orthogonalNeighbours(size, constraint.cell).map(
(cell) => values[cell] ?? 0,
);
if (neighbours.some((value) => value !== 0 && value >= maximum)) {
if (neighbours.some((value) => value !== 0 && value >= extremum)) {
return true;
}
return !(maximum === size || neighbours.every((value) => value !== 0));
return !(extremum === size || neighbours.every((value) => value !== 0));
}
if (constraint.type === "minimum") {
const extremum = values[constraint.cell] ?? 0;
if (extremum === 0) return true;
const neighbours = orthogonalNeighbours(size, constraint.cell).map(
(cell) => values[cell] ?? 0,
);
if (neighbours.some((value) => value !== 0 && value <= extremum)) {
return true;
}
return !(extremum === 1 || neighbours.every((value) => value !== 0));
}
if (constraint.type === "sandwich") {
const sums = sandwichPossibleSums(
values,
outsideLineCells(size, constraint.side, constraint.index),
size,
);
return [...sums].some((sum) => sum !== constraint.sum);
}
if (constraint.type === "between-line") {
return betweenLineCanMeet(constraint.cells, values, size, true);
}
if (constraint.type === "german-whisper") {
return germanWhisperCanMeet(
constraint.cells,
values,
size,
constraint.minimumDifference ?? Math.ceil(size / 2),
true,
);
}
if (constraint.type === "region-sum-line") {
return regionSumLineCanMeet(constraint.cells, values, regions, size, true);
}
if (constraint.type === "clone") {
return cloneCanMeet(constraint.cells, constraint.cloneCells, values, true);
}
if (constraint.type === "modular-line") {
return threeClassLineCanMeet(
constraint.cells,
values,
(value) => (value % 3) as 0 | 1 | 2,
true,
);
}
if (constraint.type === "entropic-line") {
const bandSize = size / 3;
return threeClassLineCanMeet(
constraint.cells,
values,
(value) => Math.floor((value - 1) / bandSize) as 0 | 1 | 2,
true,
);
}
if (constraint.type === "zipper-line") {
return zipperLineCanMeet(constraint.cells, values, size, true);
}
if (constraint.type === "double-arrow") {
return doubleArrowCanMeet(constraint.cells, values, size, true);
}
if (constraint.type === "indexer") {
return indexerCanMeet(constraint, values, size, true);
}
if (constraint.type === "palindrome") {
@@ -511,17 +992,19 @@ function negatedConstraintIsFeasible(
) {
return true;
}
return !positiveConstraintIsFeasible(constraint, values, size);
return !positiveConstraintIsFeasible(constraint, values, size, regions);
}
export function constraintIsFeasible(
constraint: VariantConstraint,
values: readonly number[],
size: number,
regions: readonly number[] = classicRegions(size),
): boolean {
const negated = "negated" in constraint && constraint.negated === true;
if (!negated) return positiveConstraintIsFeasible(constraint, values, size);
return negatedConstraintIsFeasible(constraint, values, size);
if (!negated)
return positiveConstraintIsFeasible(constraint, values, size, regions);
return negatedConstraintIsFeasible(constraint, values, size, regions);
}
function asCompiled(
@@ -550,7 +1033,7 @@ export function canPlaceValue(
const constraint = compiled.puzzle.constraints[index];
if (
constraint !== undefined &&
!constraintIsFeasible(constraint, next, size)
!constraintIsFeasible(constraint, next, size, compiled.puzzle.regions)
)
return false;
}
@@ -646,44 +1129,15 @@ export function findConflicts(
}
});
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":
case "quadruple":
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];
case "x-sum":
case "skyscraper":
return outsideLineCells(
compiled.puzzle.size,
constraint.side,
constraint.index,
);
case "maximum":
return [
constraint.cell,
...orthogonalNeighbours(compiled.puzzle.size, constraint.cell),
];
}
})();
if (
!constraintIsFeasible(
constraint,
board,
compiled.puzzle.size,
compiled.puzzle.regions,
)
) {
const cells = constraintCells(compiled.puzzle.size, constraint);
conflicts.push({
kind: "constraint",
cells,
+145 -1
View File
@@ -23,6 +23,11 @@ export interface NonConsecutiveConstraint {
readonly type: "non-consecutive";
}
/** Corresponding positions in every standard box form an additional house. */
export interface DisjointGroupsConstraint {
readonly type: "disjoint-groups";
}
export interface KillerCageConstraint {
readonly type: "killer-cage";
readonly cells: readonly CellId[];
@@ -122,11 +127,134 @@ export interface MaximumConstraint {
readonly negated?: boolean;
}
/** The marked cell is less than each orthogonally adjacent cell. */
export interface MinimumConstraint {
readonly type: "minimum";
readonly cell: CellId;
readonly negated?: boolean;
}
/** The marked cell contains an odd digit. */
export interface OddConstraint {
readonly type: "odd";
readonly cell: CellId;
readonly negated?: boolean;
}
/** The marked cell contains an even digit. */
export interface EvenConstraint {
readonly type: "even";
readonly cell: CellId;
readonly negated?: boolean;
}
export type LittleKillerDirection =
"down-right" | "down-left" | "up-right" | "up-left";
/** An outside sum along a diagonal entering the grid from one edge. */
export interface LittleKillerConstraint {
readonly type: "little-killer";
readonly side: OutsideClueSide;
readonly index: number;
readonly direction: LittleKillerDirection;
readonly sum: number;
readonly negated?: boolean;
}
/** Sum of the digits strictly between 1 and N on an outside line. */
export interface SandwichConstraint {
readonly type: "sandwich";
readonly side: OutsideClueSide;
readonly index: number;
readonly sum: number;
readonly negated?: boolean;
}
/** Interior line digits lie strictly between the two endpoint values. */
export interface BetweenLineConstraint {
readonly type: "between-line";
readonly cells: readonly CellId[];
readonly negated?: boolean;
}
/** Adjacent line digits differ by at least the configured amount. */
export interface GermanWhisperConstraint {
readonly type: "german-whisper";
readonly cells: readonly CellId[];
/** Defaults to ceil(size / 2). */
readonly minimumDifference?: number;
readonly negated?: boolean;
}
/** Every contiguous segment in a region has the same sum. */
export interface RegionSumLineConstraint {
readonly type: "region-sum-line";
readonly cells: readonly CellId[];
readonly negated?: boolean;
}
/** Ordered cells in the two regions contain pairwise equal digits. */
export interface CloneConstraint {
readonly type: "clone";
readonly cells: readonly CellId[];
readonly cloneCells: readonly CellId[];
readonly negated?: boolean;
}
/** An additional all-different house containing exactly N cells. */
export interface ExtraRegionConstraint {
readonly type: "extra-region";
readonly cells: readonly CellId[];
}
/** Every three consecutive cells contain all three digit residues modulo 3. */
export interface ModularLineConstraint {
readonly type: "modular-line";
readonly cells: readonly CellId[];
readonly negated?: boolean;
}
/** Every three consecutive cells contain one digit from each equal value band. */
export interface EntropicLineConstraint {
readonly type: "entropic-line";
readonly cells: readonly CellId[];
readonly negated?: boolean;
}
/** Equidistant pairs sum to the digit in the line's centre cell. */
export interface ZipperLineConstraint {
readonly type: "zipper-line";
readonly cells: readonly CellId[];
readonly negated?: boolean;
}
/** The two endpoint digits sum to all interior digits combined. */
export interface DoubleArrowConstraint {
readonly type: "double-arrow";
readonly cells: readonly CellId[];
readonly negated?: boolean;
}
export interface IndexerConstraint {
readonly type: "indexer";
readonly kind: "row" | "column" | "box";
readonly cell: CellId;
readonly negated?: boolean;
}
/** Canonical reveal seed data; fog never changes Sudoku solution semantics. */
export interface FogConstraint {
readonly type: "fog";
readonly lights: readonly CellId[];
readonly revealRadius?: 0 | 1;
}
export type VariantConstraint =
| DiagonalConstraint
| AntiKnightConstraint
| AntiKingConstraint
| NonConsecutiveConstraint
| DisjointGroupsConstraint
| KillerCageConstraint
| ThermoConstraint
| ArrowConstraint
@@ -138,7 +266,23 @@ export type VariantConstraint =
| XSumConstraint
| SkyscraperConstraint
| QuadrupleConstraint
| MaximumConstraint;
| MaximumConstraint
| MinimumConstraint
| OddConstraint
| EvenConstraint
| LittleKillerConstraint
| SandwichConstraint
| BetweenLineConstraint
| GermanWhisperConstraint
| RegionSumLineConstraint
| CloneConstraint
| ExtraRegionConstraint
| ModularLineConstraint
| EntropicLineConstraint
| ZipperLineConstraint
| DoubleArrowConstraint
| IndexerConstraint
| FogConstraint;
export interface PuzzleDefinition {
readonly version: 1;
+432 -25
View File
@@ -1,11 +1,22 @@
import { cellsFormQuadruple, classicRegions } from "./geometry";
import {
cellsFormQuadruple,
classicRegions,
littleKillerCells,
littleKillerDirectionEntersGrid,
} from "./geometry";
import { compilePuzzle } from "./compile";
import {
constraintAllowedFields,
isConstraintType,
} from "./constraintRegistry";
import { findConflicts } from "./rules";
import {
MAX_PUZZLE_SIZE,
MIN_PUZZLE_SIZE,
PuzzleValidationError,
type LittleKillerDirection,
type NormalizedPuzzle,
type OutsideClueSide,
type PuzzleDefinition,
type ValidationIssue,
type ValidationResult,
@@ -30,28 +41,8 @@ const ROOT_KEYS = new Set([
"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", "negated"]),
thermo: new Set(["type", "cells", "negated"]),
arrow: new Set(["type", "bulb", "line", "negated"]),
kropki: new Set(["type", "a", "b", "kind", "negated"]),
xv: new Set(["type", "a", "b", "total", "negated"]),
inequality: new Set(["type", "lesser", "greater", "negated"]),
renban: new Set(["type", "cells", "negated"]),
palindrome: new Set(["type", "cells", "negated"]),
"x-sum": new Set(["type", "side", "index", "sum", "negated"]),
skyscraper: new Set(["type", "side", "index", "count", "negated"]),
quadruple: new Set(["type", "cells", "digits", "negated"]),
maximum: new Set(["type", "cell", "negated"]),
};
const reachableXSumCache = new Map<number, ReadonlySet<number>>();
const reachableSandwichCache = new Map<number, ReadonlySet<number>>();
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -182,6 +173,30 @@ function reachableXSumTotals(size: number): ReadonlySet<number> {
return reachable;
}
function reachableSandwichTotals(size: number): ReadonlySet<number> {
const cached = reachableSandwichCache.get(size);
if (cached !== undefined) return cached;
const reachable = new Set<number>([0]);
for (let digit = 2; digit < size; digit += 1) {
for (const sum of [...reachable]) reachable.add(sum + digit);
}
reachableSandwichCache.set(size, reachable);
return reachable;
}
function sameRegionPartition(
first: readonly number[],
second: readonly number[],
): boolean {
if (first.length !== second.length) return false;
for (let a = 0; a < first.length; a += 1) {
for (let b = a + 1; b < first.length; b += 1) {
if ((first[a] === first[b]) !== (second[a] === second[b])) return false;
}
}
return true;
}
function validateConstraint(
value: unknown,
index: number,
@@ -193,12 +208,12 @@ function validateConstraint(
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) {
if (!isConstraintType(value.type)) {
add(issues, `${path}.type`, "is not a supported constraint type");
return;
}
const type = value.type;
const allowed = constraintAllowedFields(type);
for (const key of Object.keys(value)) {
if (!allowed.has(key))
add(issues, `${path}.${key}`, "is not a recognized field");
@@ -220,6 +235,7 @@ function validateConstraint(
case "anti-knight":
case "anti-king":
case "non-consecutive":
case "disjoint-groups":
break;
case "killer-cage": {
const cellsValid = validateCells(
@@ -410,8 +426,258 @@ function validateConstraint(
break;
}
case "maximum":
case "minimum":
case "odd":
case "even":
validateCell(value.cell, `${path}.cell`, cellCount, issues);
break;
case "little-killer": {
validateOutsideClue(value, path, size, issues);
const directionValid =
value.direction === "down-right" ||
value.direction === "down-left" ||
value.direction === "up-right" ||
value.direction === "up-left";
if (!directionValid) {
add(
issues,
`${path}.direction`,
'must be "down-right", "down-left", "up-right" or "up-left"',
);
} else if (
(value.side === "top" ||
value.side === "right" ||
value.side === "bottom" ||
value.side === "left") &&
!littleKillerDirectionEntersGrid(
value.side,
value.direction as LittleKillerDirection,
)
) {
add(issues, `${path}.direction`, "must point into the grid");
}
const indexValid =
Number.isInteger(value.index) &&
(value.index as number) >= 0 &&
(value.index as number) < size;
const sideValid =
value.side === "top" ||
value.side === "right" ||
value.side === "bottom" ||
value.side === "left";
const cells =
directionValid && indexValid && sideValid
? littleKillerCells(
size,
value.side as OutsideClueSide,
value.index as number,
value.direction as LittleKillerDirection,
)
: [];
if (cells.length === 1) {
add(
issues,
path,
"little-killer diagonal must cross at least two cells",
);
}
if (!Number.isInteger(value.sum)) {
add(issues, `${path}.sum`, "must be an integer");
} else if (cells.length >= 2) {
const minimum = value.negated === true ? 1 : cells.length;
const maximum =
value.negated === true
? maximumFalseClueValue(size)
: cells.length * size;
if (
(value.sum as number) < minimum ||
(value.sum as number) > maximum
) {
add(
issues,
`${path}.sum`,
value.negated === true
? `must be a bounded positive integer (1 to ${maximum})`
: `must be reachable (${minimum} to ${maximum})`,
);
}
}
break;
}
case "sandwich": {
validateOutsideClue(value, path, size, issues);
const valid = validateIntegerRange(
value.sum,
`${path}.sum`,
0,
value.negated === true
? maximumFalseClueValue(size)
: (size * (size + 1)) / 2 - size - 1,
issues,
);
if (
valid &&
value.negated !== true &&
!reachableSandwichTotals(size).has(value.sum as number)
) {
add(
issues,
`${path}.sum`,
"cannot be formed by digits between 1 and the maximum digit",
);
}
break;
}
case "between-line":
validateCells(
value.cells,
`${path}.cells`,
cellCount,
3,
cellCount,
issues,
);
break;
case "german-whisper":
validateCells(
value.cells,
`${path}.cells`,
cellCount,
2,
cellCount,
issues,
);
if (value.minimumDifference !== undefined) {
validateIntegerRange(
value.minimumDifference,
`${path}.minimumDifference`,
1,
size - 1,
issues,
);
}
break;
case "region-sum-line":
validateCells(
value.cells,
`${path}.cells`,
cellCount,
2,
cellCount,
issues,
);
break;
case "clone": {
const sourceValid = validateCells(
value.cells,
`${path}.cells`,
cellCount,
1,
cellCount,
issues,
);
const cloneValid = validateCells(
value.cloneCells,
`${path}.cloneCells`,
cellCount,
1,
cellCount,
issues,
);
if (sourceValid && cloneValid) {
const source = value.cells as readonly number[];
const clone = value.cloneCells as readonly number[];
if (source.length !== clone.length) {
add(issues, path, "clone cell lists must have equal length");
}
}
break;
}
case "extra-region":
validateCells(
value.cells,
`${path}.cells`,
cellCount,
size,
size,
issues,
);
break;
case "modular-line":
validateCells(
value.cells,
`${path}.cells`,
cellCount,
3,
cellCount,
issues,
);
break;
case "entropic-line":
validateCells(
value.cells,
`${path}.cells`,
cellCount,
3,
cellCount,
issues,
);
if (size % 3 !== 0) {
add(issues, path, "entropic lines require a grid size divisible by 3");
}
break;
case "zipper-line":
if (
validateCells(
value.cells,
`${path}.cells`,
cellCount,
3,
cellCount,
issues,
) &&
(value.cells as readonly number[]).length % 2 === 0
) {
add(issues, `${path}.cells`, "must contain an odd number of cells");
}
break;
case "double-arrow":
validateCells(
value.cells,
`${path}.cells`,
cellCount,
3,
cellCount,
issues,
);
break;
case "indexer":
validateCell(value.cell, `${path}.cell`, cellCount, issues);
if (
value.kind !== "row" &&
value.kind !== "column" &&
value.kind !== "box"
) {
add(issues, `${path}.kind`, 'must be "row", "column" or "box"');
}
break;
case "fog":
validateCells(
value.lights,
`${path}.lights`,
cellCount,
1,
cellCount,
issues,
);
if (
value.revealRadius !== undefined &&
value.revealRadius !== 0 &&
value.revealRadius !== 1
) {
add(issues, `${path}.revealRadius`, "must be 0 or 1");
}
break;
}
}
@@ -448,6 +714,7 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
case "anti-knight":
case "anti-king":
case "non-consecutive":
case "disjoint-groups":
return { type: constraint.type };
case "killer-cage":
return {
@@ -464,6 +731,12 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
case "thermo":
case "renban":
case "palindrome":
case "between-line":
case "region-sum-line":
case "modular-line":
case "entropic-line":
case "zipper-line":
case "double-arrow":
return {
type: constraint.type,
cells: [...constraint.cells],
@@ -539,6 +812,9 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
: { negated: constraint.negated }),
};
case "maximum":
case "minimum":
case "odd":
case "even":
return {
type: constraint.type,
cell: constraint.cell,
@@ -546,6 +822,66 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
? {}
: { negated: constraint.negated }),
};
case "little-killer":
return {
type: constraint.type,
side: constraint.side,
index: constraint.index,
direction: constraint.direction,
sum: constraint.sum,
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
};
case "sandwich":
return {
type: constraint.type,
side: constraint.side,
index: constraint.index,
sum: constraint.sum,
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
};
case "german-whisper":
return {
type: constraint.type,
cells: [...constraint.cells],
...(constraint.minimumDifference === undefined
? {}
: { minimumDifference: constraint.minimumDifference }),
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
};
case "clone":
return {
type: constraint.type,
cells: [...constraint.cells],
cloneCells: [...constraint.cloneCells],
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
};
case "extra-region":
return { type: constraint.type, cells: [...constraint.cells] };
case "indexer":
return {
type: constraint.type,
kind: constraint.kind,
cell: constraint.cell,
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
};
case "fog":
return {
type: constraint.type,
lights: [...constraint.lights],
...(constraint.revealRadius === undefined
? {}
: { revealRadius: constraint.revealRadius }),
};
}
}
@@ -654,6 +990,77 @@ export function validatePuzzle(puzzle: unknown): ValidationResult {
.forEach((constraint, index) => {
validateConstraint(constraint, index, n, issues);
});
if (regionsValid) {
const actualRegions =
puzzle.regions === undefined
? classicRegions(n)
: (puzzle.regions as readonly number[]);
if (!sameRegionPartition(actualRegions, classicRegions(n))) {
puzzle.constraints.forEach((constraint, index) => {
if (!isRecord(constraint)) return;
if (constraint.type === "disjoint-groups") {
add(
issues,
`constraints[${index}]`,
"disjoint groups require the standard rectangular box layout",
);
}
if (constraint.type === "indexer" && constraint.kind === "box") {
add(
issues,
`constraints[${index}]`,
"box indexers require the standard rectangular box layout",
);
}
});
}
puzzle.constraints.forEach((constraint, index) => {
if (
!isRecord(constraint) ||
constraint.type !== "region-sum-line" ||
!Array.isArray(constraint.cells) ||
constraint.cells.length < 2 ||
constraint.cells.some(
(cell) =>
!Number.isInteger(cell) ||
(cell as number) < 0 ||
(cell as number) >= n * n,
)
) {
return;
}
const cells = constraint.cells as readonly number[];
let segmentCount = 1;
for (let position = 1; position < cells.length; position += 1) {
if (
actualRegions[cells[position]!] !==
actualRegions[cells[position - 1]!]
) {
segmentCount += 1;
}
}
if (segmentCount < 2) {
add(
issues,
`constraints[${index}].cells`,
"region-sum line must cross at least one region boundary",
);
}
});
}
puzzle.constraints.forEach((constraint, index) => {
if (
isRecord(constraint) &&
constraint.type === "fog" &&
puzzle.solution === undefined
) {
add(
issues,
`constraints[${index}]`,
"fog requires a complete trusted puzzle solution",
);
}
});
}
validateText(puzzle.id, "id", MAX_SHORT_TEXT, issues);
validateText(puzzle.title, "title", MAX_SHORT_TEXT, issues);