feat: expand sudoku analysis and interoperability
This commit is contained in:
+38
-2
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user