feat: expand sudoku analysis and interoperability

This commit is contained in:
2026-08-30 23:21:56 +02:00
parent 4a9869baa0
commit 8ca9300ab3
73 changed files with 12482 additions and 384 deletions
+38 -2
View File
@@ -1,5 +1,10 @@
import { cellColumn, cellRow, orthogonalNeighbours } from "./geometry";
import type { CellId, NormalizedPuzzle, VariantConstraint } from "./types";
import type {
CellId,
NormalizedPuzzle,
OutsideClueSide,
VariantConstraint,
} from "./types";
export type UnitKind = "row" | "column" | "region" | "diagonal";
@@ -25,6 +30,26 @@ 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,
): 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;
}
});
}
function cellsForConstraint(
size: number,
constraint: VariantConstraint,
@@ -52,6 +77,13 @@ function cellsForConstraint(
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)];
}
}
@@ -101,7 +133,11 @@ export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle {
for (const cell of new Set(cellsForConstraint(size, constraint))) {
constraintsByCell[cell]?.push(constraintIndex);
}
if (constraint.type === "killer-cage" && constraint.noRepeat !== false) {
if (
constraint.type === "killer-cage" &&
constraint.noRepeat !== false &&
constraint.negated !== true
) {
for (const a of constraint.cells) {
for (const b of constraint.cells) addPeerPair(peers, a, b);
}
+33
View File
@@ -126,3 +126,36 @@ export function orthogonalNeighbours(size: number, cell: CellId): CellId[] {
if (column + 1 < size) result.push(cell + 1);
return result;
}
/** True when four cells meet at one internal grid intersection. */
export function cellsFormQuadruple(
size: number,
cells: readonly CellId[],
): boolean {
if (cells.length !== 4 || new Set(cells).size !== 4) return false;
try {
const rows = [...new Set(cells.map((cell) => cellRow(size, cell)))].sort(
(a, b) => a - b,
);
const columns = [
...new Set(cells.map((cell) => cellColumn(size, cell))),
].sort((a, b) => a - b);
if (
rows.length !== 2 ||
columns.length !== 2 ||
rows[1] !== rows[0]! + 1 ||
columns[1] !== columns[0]! + 1
) {
return false;
}
const expected = new Set([
cellId(size, rows[0]!, columns[0]!),
cellId(size, rows[0]!, columns[1]!),
cellId(size, rows[1]!, columns[0]!),
cellId(size, rows[1]!, columns[1]!),
]);
return cells.every((cell) => expected.has(cell));
} catch {
return false;
}
}
+374 -5
View File
@@ -1,4 +1,9 @@
import { compilePuzzle, type CompiledPuzzle } from "./compile";
import {
compilePuzzle,
outsideLineCells,
type CompiledPuzzle,
} from "./compile";
import { orthogonalNeighbours } from "./geometry";
import type {
CellId,
NormalizedPuzzle,
@@ -21,8 +26,9 @@ function sumRange(
size: number,
noRepeat: boolean,
): readonly [number, number] {
const current = assigned.reduce((sum, value) => sum + value, 0);
if (blanks === 0) return [current, current];
if (!noRepeat) {
const current = assigned.reduce((sum, value) => sum + value, 0);
return [current + blanks, current + blanks * size];
}
const used = new Set(assigned);
@@ -32,7 +38,6 @@ function sumRange(
).filter((value) => !used.has(value));
if (available.length < blanks)
return [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
const current = assigned.reduce((sum, value) => sum + value, 0);
const low = available
.slice(0, blanks)
.reduce((sum, value) => sum + value, current);
@@ -57,7 +62,217 @@ function sumsCanMeet(
return [sum + blanks, sum + blanks * size];
}
export function constraintIsFeasible(
function canChooseDistinctSum(
available: readonly number[],
count: number,
target: number,
): boolean {
if (count < 0 || count > available.length || target < 0) return false;
if (count === 0) return target === 0;
const ordered = [...available].sort((a, b) => a - b);
const minimum = ordered
.slice(0, count)
.reduce((total, digit) => total + digit, 0);
const maximum = ordered
.slice(-count)
.reduce((total, digit) => total + digit, 0);
if (target < minimum || target > maximum) return false;
const sums = new Array<bigint>(count + 1).fill(0n);
sums[0] = 1n;
let processed = 0;
for (const digit of ordered) {
processed += 1;
for (
let selected = Math.min(count, processed);
selected >= 1;
selected -= 1
) {
sums[selected] =
(sums[selected] ?? 0n) | ((sums[selected - 1] ?? 0n) << BigInt(digit));
}
}
return ((sums[count] ?? 0n) & (1n << BigInt(target))) !== 0n;
}
function xSumCanMeet(
values: readonly number[],
cells: readonly CellId[],
size: number,
target: number,
): boolean {
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 false;
}
const first = line[0] ?? 0;
const fixedDigits = new Set(fixed);
const possibleFirsts =
first === 0
? Array.from({ length: size }, (_, index) => index + 1).filter(
(digit) => !fixedDigits.has(digit),
)
: [first];
return possibleFirsts.some((firstDigit) => {
if (firstDigit < 1 || firstDigit > size) return false;
const used = new Set(fixedDigits);
used.add(firstDigit);
let assignedSum = firstDigit;
let blanks = 0;
for (let position = 1; position < firstDigit; position += 1) {
const value = line[position] ?? 0;
if (value === 0) blanks += 1;
else assignedSum += value;
}
const available = Array.from(
{ length: size },
(_, index) => index + 1,
).filter((digit) => !used.has(digit));
return canChooseDistinctSum(available, blanks, target - assignedSum);
});
}
type SearchResult = "yes" | "no" | "unknown";
const MAX_SKYSCRAPER_STATES = 50_000;
function skyscraperCanMeet(
values: readonly number[],
cells: readonly CellId[],
size: number,
target: number,
): boolean {
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 false;
}
if (fixed.length === 0) return target >= 1 && target <= size;
let prefixMaximum = 0;
let prefixVisible = 0;
for (const value of line) {
if (value === 0) break;
if (value > prefixMaximum) {
prefixMaximum = value;
prefixVisible += 1;
}
}
const minimumVisible = prefixVisible + (prefixMaximum < size ? 1 : 0);
const maximumVisible = prefixVisible + (size - prefixMaximum);
if (target < minimumVisible || target > maximumVisible) return false;
let availableMask = 0;
for (let digit = 1; digit <= size; digit += 1) {
if (!fixed.includes(digit)) availableMask |= 1 << (digit - 1);
}
let explored = 0;
const memo = new Map<string, SearchResult>();
const visit = (
position: number,
remainingMask: number,
tallest: number,
visible: number,
): SearchResult => {
if (visible > target) return "no";
if (position === size) return visible === target ? "yes" : "no";
const positionsLeft = size - position;
if (
visible + positionsLeft < target ||
visible + (size - tallest) < target
) {
return "no";
}
explored += 1;
if (explored > MAX_SKYSCRAPER_STATES) return "unknown";
const key = `${position}:${remainingMask}:${tallest}:${visible}`;
const cached = memo.get(key);
if (cached !== undefined) return cached;
const fixedValue = line[position] ?? 0;
if (fixedValue !== 0) {
const result = visit(
position + 1,
remainingMask,
Math.max(tallest, fixedValue),
visible + (fixedValue > tallest ? 1 : 0),
);
memo.set(key, result);
return result;
}
let sawUnknown = false;
for (let mask = remainingMask; mask !== 0; mask &= mask - 1) {
const bit = mask & -mask;
const digit = 32 - Math.clz32(bit);
const result = visit(
position + 1,
remainingMask & ~bit,
Math.max(tallest, digit),
visible + (digit > tallest ? 1 : 0),
);
if (result === "yes") {
memo.set(key, result);
return result;
}
if (result === "unknown") sawUnknown = true;
}
const result = sawUnknown ? "unknown" : "no";
memo.set(key, result);
return result;
};
return visit(0, availableMask, 0, 0) !== "no";
}
function quadrupleCanMeet(
constraint: Extract<VariantConstraint, { readonly type: "quadruple" }>,
values: readonly number[],
): boolean {
const assigned = new Map<number, number>();
let blanks = 0;
for (const cell of constraint.cells) {
const value = values[cell] ?? 0;
if (value === 0) blanks += 1;
else assigned.set(value, (assigned.get(value) ?? 0) + 1);
}
const required = new Map<number, number>();
for (const digit of constraint.digits) {
required.set(digit, (required.get(digit) ?? 0) + 1);
}
let missing = 0;
for (const [digit, count] of required) {
missing += Math.max(0, count - (assigned.get(digit) ?? 0));
}
return missing <= blanks;
}
function maximumCanMeet(
cell: CellId,
values: readonly number[],
size: number,
): boolean {
const maximum = values[cell] ?? 0;
const neighbours = orthogonalNeighbours(size, cell).map(
(neighbour) => values[neighbour] ?? 0,
);
if (maximum === 0) {
return neighbours.every((value) => value === 0 || value < size);
}
return neighbours.every((value) =>
value === 0 ? maximum > 1 : value < maximum,
);
}
function positiveConstraintIsFeasible(
constraint: VariantConstraint,
values: readonly number[],
size: number,
@@ -168,9 +383,147 @@ export function constraintIsFeasible(
}
return true;
}
case "x-sum":
return xSumCanMeet(
values,
outsideLineCells(size, constraint.side, constraint.index),
size,
constraint.sum,
);
case "skyscraper":
return skyscraperCanMeet(
values,
outsideLineCells(size, constraint.side, constraint.index),
size,
constraint.count,
);
case "quadruple":
return quadrupleCanMeet(constraint, values);
case "maximum":
return maximumCanMeet(constraint.cell, values, size);
}
}
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)];
}
}
/**
* Returns whether a false clue can still be satisfied. Unlike an ordinary
* feasibility check, a false clue only constrains the grid once the truth of
* its positive statement is fixed. Several clue families become fixed before
* every geometrically related cell is filled, which is important for bounded
* exact search on liar/Wrogn puzzles.
*/
function negatedConstraintIsFeasible(
constraint: VariantConstraint,
values: readonly number[],
size: number,
): boolean {
if (constraint.type === "x-sum") {
const line = valuesAt(
values,
outsideLineCells(size, constraint.side, constraint.index),
);
const first = line[0] ?? 0;
if (first === 0) return true;
const prefix = line.slice(0, first);
if (prefix.some((value) => value === 0)) return true;
return prefix.reduce((sum, value) => sum + value, 0) !== constraint.sum;
}
if (constraint.type === "quadruple") {
const assigned = new Map<number, number>();
for (const cell of constraint.cells) {
const value = values[cell] ?? 0;
if (value !== 0) assigned.set(value, (assigned.get(value) ?? 0) + 1);
}
const required = new Map<number, number>();
for (const digit of constraint.digits) {
required.set(digit, (required.get(digit) ?? 0) + 1);
}
const positiveAlreadyTrue = [...required].every(
([digit, count]) => (assigned.get(digit) ?? 0) >= count,
);
return !positiveAlreadyTrue;
}
if (constraint.type === "maximum") {
const maximum = values[constraint.cell] ?? 0;
if (maximum === 0) return true;
const neighbours = orthogonalNeighbours(size, constraint.cell).map(
(cell) => values[cell] ?? 0,
);
if (neighbours.some((value) => value !== 0 && value >= maximum)) {
return true;
}
return !(maximum === size || neighbours.every((value) => value !== 0));
}
if (constraint.type === "palindrome") {
let allPairsKnown = true;
for (
let index = 0;
index < Math.floor(constraint.cells.length / 2);
index += 1
) {
const a = values[constraint.cells[index] ?? -1] ?? 0;
const b =
values[constraint.cells[constraint.cells.length - index - 1] ?? -1] ??
0;
if (a !== 0 && b !== 0 && a !== b) return true;
if (a === 0 || b === 0) allPairsKnown = false;
}
if (allPairsKnown) return false;
}
const relevant = completedCells(constraint, size);
if (
relevant === undefined ||
relevant.some((cell) => (values[cell] ?? 0) === 0)
) {
return true;
}
return !positiveConstraintIsFeasible(constraint, values, size);
}
export function constraintIsFeasible(
constraint: VariantConstraint,
values: readonly number[],
size: number,
): boolean {
const negated = "negated" in constraint && constraint.negated === true;
if (!negated) return positiveConstraintIsFeasible(constraint, values, size);
return negatedConstraintIsFeasible(constraint, values, size);
}
function asCompiled(
puzzle: PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle,
): CompiledPuzzle {
@@ -308,6 +661,7 @@ export function findConflicts(
case "thermo":
case "renban":
case "palindrome":
case "quadruple":
return constraint.cells;
case "arrow":
return [...constraint.bulb, ...constraint.line];
@@ -316,12 +670,27 @@ export function findConflicts(
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),
];
}
})();
conflicts.push({
kind: "constraint",
cells,
message: `${constraint.type} constraint cannot be satisfied.`,
message:
"negated" in constraint && constraint.negated === true
? `${constraint.type} clue is true but must be false.`
: `${constraint.type} constraint cannot be satisfied.`,
constraintIndex,
});
}
+52 -1
View File
@@ -29,12 +29,15 @@ export interface KillerCageConstraint {
readonly sum: number;
/** Killer cages normally do not repeat digits. Defaults to true. */
readonly noRepeat?: boolean;
/** If true, the completed clue must be false instead of true. */
readonly negated?: boolean;
}
export interface ThermoConstraint {
readonly type: "thermo";
/** Ordered from bulb to tip. Values must increase strictly. */
readonly cells: readonly CellId[];
readonly negated?: boolean;
}
export interface ArrowConstraint {
@@ -42,6 +45,7 @@ export interface ArrowConstraint {
/** Bulb cells. Their values sum to the values on the line. */
readonly bulb: readonly CellId[];
readonly line: readonly CellId[];
readonly negated?: boolean;
}
export interface KropkiConstraint {
@@ -50,6 +54,7 @@ export interface KropkiConstraint {
readonly b: CellId;
/** White means consecutive; black means a 1:2 ratio. */
readonly kind: "white" | "black";
readonly negated?: boolean;
}
export interface XvConstraint {
@@ -57,22 +62,64 @@ export interface XvConstraint {
readonly a: CellId;
readonly b: CellId;
readonly total: 5 | 10;
readonly negated?: boolean;
}
export interface InequalityConstraint {
readonly type: "inequality";
readonly lesser: CellId;
readonly greater: CellId;
readonly negated?: boolean;
}
export interface RenbanConstraint {
readonly type: "renban";
readonly cells: readonly CellId[];
readonly negated?: boolean;
}
export interface PalindromeConstraint {
readonly type: "palindrome";
readonly cells: readonly CellId[];
readonly negated?: boolean;
}
export type OutsideClueSide = "top" | "right" | "bottom" | "left";
/**
* The first digit seen from `side` selects how many cells, including itself,
* must add to `sum`. `index` is the zero-based row or column on that side.
*/
export interface XSumConstraint {
readonly type: "x-sum";
readonly side: OutsideClueSide;
readonly index: number;
readonly sum: number;
readonly negated?: boolean;
}
/** Number of increasing-height records seen across a row or column. */
export interface SkyscraperConstraint {
readonly type: "skyscraper";
readonly side: OutsideClueSide;
readonly index: number;
readonly count: number;
readonly negated?: boolean;
}
/** Every listed digit, including repetitions, must occur in the clue cells. */
export interface QuadrupleConstraint {
readonly type: "quadruple";
readonly cells: readonly CellId[];
readonly digits: readonly CellValue[];
readonly negated?: boolean;
}
/** The marked cell is greater than each orthogonally adjacent cell. */
export interface MaximumConstraint {
readonly type: "maximum";
readonly cell: CellId;
readonly negated?: boolean;
}
export type VariantConstraint =
@@ -87,7 +134,11 @@ export type VariantConstraint =
| XvConstraint
| InequalityConstraint
| RenbanConstraint
| PalindromeConstraint;
| PalindromeConstraint
| XSumConstraint
| SkyscraperConstraint
| QuadrupleConstraint
| MaximumConstraint;
export interface PuzzleDefinition {
readonly version: 1;
+240 -12
View File
@@ -1,4 +1,4 @@
import { classicRegions } from "./geometry";
import { cellsFormQuadruple, classicRegions } from "./geometry";
import { compilePuzzle } from "./compile";
import { findConflicts } from "./rules";
import {
@@ -15,6 +15,8 @@ import {
const MAX_CONSTRAINTS = 4_096;
const MAX_SHORT_TEXT = 256;
const MAX_RULES_TEXT = 16_384;
/** Keeps deliberately impossible false-clue labels finite without requiring reachability. */
const maximumFalseClueValue = (size: number): number => size ** 4;
const ROOT_KEYS = new Set([
"version",
"size",
@@ -35,16 +37,22 @@ const CONSTRAINT_KEYS: Readonly<
"anti-knight": new Set(["type"]),
"anti-king": new Set(["type"]),
"non-consecutive": new Set(["type"]),
"killer-cage": new Set(["type", "cells", "sum", "noRepeat"]),
thermo: new Set(["type", "cells"]),
arrow: new Set(["type", "bulb", "line"]),
kropki: new Set(["type", "a", "b", "kind"]),
xv: new Set(["type", "a", "b", "total"]),
inequality: new Set(["type", "lesser", "greater"]),
renban: new Set(["type", "cells"]),
palindrome: new Set(["type", "cells"]),
"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>>();
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -108,6 +116,72 @@ function validateCells(
return true;
}
function validateIntegerRange(
value: unknown,
path: string,
minimum: number,
maximum: number,
issues: ValidationIssue[],
): value is number {
if (
!Number.isInteger(value) ||
(value as number) < minimum ||
(value as number) > maximum
) {
add(issues, path, `must be an integer from ${minimum} to ${maximum}`);
return false;
}
return true;
}
function validateOutsideClue(
value: Record<string, unknown>,
path: string,
size: number,
issues: ValidationIssue[],
): void {
if (
value.side !== "top" &&
value.side !== "right" &&
value.side !== "bottom" &&
value.side !== "left"
) {
add(issues, `${path}.side`, 'must be "top", "right", "bottom" or "left"');
}
validateIntegerRange(value.index, `${path}.index`, 0, size - 1, issues);
}
function reachableXSumTotals(size: number): ReadonlySet<number> {
const cached = reachableXSumCache.get(size);
if (cached !== undefined) return cached;
const total = (size * (size + 1)) / 2;
const reachable = new Set<number>();
for (let first = 1; first <= size; first += 1) {
const choose = first - 1;
const sums = Array.from(
{ length: choose + 1 },
() => new Uint8Array(total + 1),
);
sums[0]![0] = 1;
for (let digit = 1; digit <= size; digit += 1) {
if (digit === first) continue;
for (let count = choose; count >= 1; count -= 1) {
const current = sums[count]!;
const previous = sums[count - 1]!;
for (let sum = total; sum >= digit; sum -= 1) {
if (previous[sum - digit] !== 0) current[sum] = 1;
}
}
}
for (let sum = 0; sum <= total - first; sum += 1) {
if (sums[choose]![sum] !== 0) reachable.add(first + sum);
}
}
reachableXSumCache.set(size, reachable);
return reachable;
}
function validateConstraint(
value: unknown,
index: number,
@@ -129,6 +203,13 @@ function validateConstraint(
if (!allowed.has(key))
add(issues, `${path}.${key}`, "is not a recognized field");
}
if (
allowed.has("negated") &&
value.negated !== undefined &&
typeof value.negated !== "boolean"
) {
add(issues, `${path}.negated`, "must be a boolean");
}
const cellCount = size * size;
switch (type) {
case "diagonal":
@@ -166,8 +247,17 @@ function validateConstraint(
? (length * (2 * size - length + 1)) / 2
: length * size;
if (
(value.sum as number) < minimum ||
(value.sum as number) > maximum
value.negated === true &&
((value.sum as number) < 1 || (value.sum as number) > size ** 3)
) {
add(
issues,
`${path}.sum`,
`must be a bounded positive integer (1 to ${size ** 3})`,
);
} else if (
value.negated !== true &&
((value.sum as number) < minimum || (value.sum as number) > maximum)
) {
add(
issues,
@@ -242,6 +332,86 @@ function validateConstraint(
issues,
);
break;
case "x-sum": {
validateOutsideClue(value, path, size, issues);
const sumValid = validateIntegerRange(
value.sum,
`${path}.sum`,
1,
value.negated === true
? maximumFalseClueValue(size)
: (size * (size + 1)) / 2,
issues,
);
if (
sumValid &&
value.negated !== true &&
!reachableXSumTotals(size).has(value.sum as number)
) {
add(issues, `${path}.sum`, "cannot be formed by a valid X-sum line");
}
break;
}
case "skyscraper":
validateOutsideClue(value, path, size, issues);
validateIntegerRange(
value.count,
`${path}.count`,
1,
value.negated === true ? maximumFalseClueValue(size) : size,
issues,
);
break;
case "quadruple": {
const cellsValid = validateCells(
value.cells,
`${path}.cells`,
cellCount,
1,
4,
issues,
);
if (!Array.isArray(value.digits)) {
add(issues, `${path}.digits`, "must be an array");
break;
}
if (value.digits.length < 1 || value.digits.length > 4) {
add(issues, `${path}.digits`, "must contain 1 to 4 digits");
}
value.digits.forEach((digit, digitIndex) => {
validateIntegerRange(
digit,
`${path}.digits[${digitIndex}]`,
1,
size,
issues,
);
});
if (
cellsValid &&
value.digits.length > (value.cells as readonly number[]).length
) {
add(
issues,
`${path}.digits`,
"must not contain more digits than clue cells",
);
}
if (
cellsValid &&
!cellsFormQuadruple(size, value.cells as readonly number[])
) {
add(
issues,
`${path}.cells`,
"must be the four cells surrounding one grid intersection",
);
}
break;
}
case "maximum":
validateCell(value.cell, `${path}.cell`, cellCount, issues);
break;
}
}
@@ -287,16 +457,28 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
...(constraint.noRepeat === undefined
? {}
: { noRepeat: constraint.noRepeat }),
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
};
case "thermo":
case "renban":
case "palindrome":
return { type: constraint.type, cells: [...constraint.cells] };
return {
type: constraint.type,
cells: [...constraint.cells],
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
};
case "arrow":
return {
type: constraint.type,
bulb: [...constraint.bulb],
line: [...constraint.line],
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
};
case "kropki":
return {
@@ -304,6 +486,9 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
a: constraint.a,
b: constraint.b,
kind: constraint.kind,
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
};
case "xv":
return {
@@ -311,12 +496,55 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
a: constraint.a,
b: constraint.b,
total: constraint.total,
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
};
case "inequality":
return {
type: constraint.type,
lesser: constraint.lesser,
greater: constraint.greater,
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
};
case "x-sum":
return {
type: constraint.type,
side: constraint.side,
index: constraint.index,
sum: constraint.sum,
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
};
case "skyscraper":
return {
type: constraint.type,
side: constraint.side,
index: constraint.index,
count: constraint.count,
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
};
case "quadruple":
return {
type: constraint.type,
cells: [...constraint.cells],
digits: [...constraint.digits],
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
};
case "maximum":
return {
type: constraint.type,
cell: constraint.cell,
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
};
}
}