feat: complete advanced Sudoku workbench
This commit is contained in:
@@ -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))];
|
||||
}
|
||||
Reference in New Issue
Block a user