feat: complete advanced Sudoku workbench
This commit is contained in:
+35
-62
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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,4 +1,5 @@
|
||||
export * from "./compile";
|
||||
export * from "./constraintRegistry";
|
||||
export * from "./geometry";
|
||||
export * from "./rules";
|
||||
export * from "./types";
|
||||
|
||||
+536
-82
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user