483 lines
12 KiB
TypeScript
483 lines
12 KiB
TypeScript
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))];
|
|
}
|