380 lines
11 KiB
TypeScript
380 lines
11 KiB
TypeScript
import {
|
|
compilePuzzle,
|
|
type CompiledPuzzle,
|
|
type NormalizedPuzzle,
|
|
type SudokuUnit,
|
|
type UnitKind,
|
|
} from "../domain";
|
|
|
|
export interface CandidateNode {
|
|
readonly cell: number;
|
|
readonly value: number;
|
|
}
|
|
|
|
export type CandidateLinkKind = "strong" | "weak";
|
|
|
|
export interface CandidateLinkContext {
|
|
readonly kind: "cell" | "house";
|
|
readonly label: string;
|
|
readonly unitKind?: UnitKind;
|
|
readonly unitIndex?: number;
|
|
}
|
|
|
|
/**
|
|
* A graph edge between two candidates. Strong edges mean that at least one
|
|
* endpoint must be true; weak edges mean that both endpoints cannot be true.
|
|
* A strong Sudoku link is also weak, but is rendered once as the more useful
|
|
* strong relationship.
|
|
*/
|
|
export interface CandidateLink {
|
|
readonly id: string;
|
|
readonly kind: CandidateLinkKind;
|
|
readonly a: CandidateNode;
|
|
readonly b: CandidateNode;
|
|
readonly contexts: readonly CandidateLinkContext[];
|
|
}
|
|
|
|
export interface CandidatePosition {
|
|
readonly value: number;
|
|
readonly cells: readonly number[];
|
|
readonly linkKind: CandidateLinkKind | "none";
|
|
}
|
|
|
|
export interface HouseCandidateInspection {
|
|
readonly unitIndex: number;
|
|
readonly kind: UnitKind;
|
|
readonly index: number;
|
|
readonly label: string;
|
|
readonly cells: readonly number[];
|
|
readonly missingValues: readonly number[];
|
|
readonly positions: readonly CandidatePosition[];
|
|
}
|
|
|
|
export interface CellCandidateInspection {
|
|
readonly cell: number;
|
|
readonly values: readonly number[];
|
|
readonly houseLabels: readonly string[];
|
|
}
|
|
|
|
export interface CandidateOverlay {
|
|
readonly activeValues: readonly number[];
|
|
readonly candidateCells: readonly number[];
|
|
readonly links: readonly CandidateLink[];
|
|
}
|
|
|
|
export interface CandidateLinkOptions {
|
|
/** Only derive house links from these compiled unit indices. */
|
|
readonly unitIndices?: readonly number[];
|
|
/** Only retain links whose two endpoints use one of these values. */
|
|
readonly values?: readonly number[];
|
|
/** Cells from which same-cell links are derived. Defaults to every cell. */
|
|
readonly cellIndices?: readonly number[];
|
|
readonly includeCellLinks?: boolean;
|
|
}
|
|
|
|
function nodeKey(node: CandidateNode): string {
|
|
return `${String(node.cell)}:${String(node.value)}`;
|
|
}
|
|
|
|
function orderedNodes(
|
|
first: CandidateNode,
|
|
second: CandidateNode,
|
|
): readonly [CandidateNode, CandidateNode] {
|
|
return nodeKey(first) < nodeKey(second) ? [first, second] : [second, first];
|
|
}
|
|
|
|
function linkKey(a: CandidateNode, b: CandidateNode): string {
|
|
const [first, second] = orderedNodes(a, b);
|
|
return `${nodeKey(first)}-${nodeKey(second)}`;
|
|
}
|
|
|
|
function addLink(
|
|
links: Map<
|
|
string,
|
|
{
|
|
kind: CandidateLinkKind;
|
|
a: CandidateNode;
|
|
b: CandidateNode;
|
|
contexts: CandidateLinkContext[];
|
|
}
|
|
>,
|
|
kind: CandidateLinkKind,
|
|
first: CandidateNode,
|
|
second: CandidateNode,
|
|
context: CandidateLinkContext,
|
|
): void {
|
|
const [a, b] = orderedNodes(first, second);
|
|
const key = linkKey(a, b);
|
|
const existing = links.get(key);
|
|
if (existing === undefined) {
|
|
links.set(key, { kind, a, b, contexts: [context] });
|
|
return;
|
|
}
|
|
if (kind === "strong") existing.kind = "strong";
|
|
if (!existing.contexts.some(({ label }) => label === context.label)) {
|
|
existing.contexts.push(context);
|
|
}
|
|
}
|
|
|
|
function candidateValues(mask: number, size: number): number[] {
|
|
const result: number[] = [];
|
|
for (let value = 1; value <= size; value += 1) {
|
|
if ((mask & (1 << (value - 1))) !== 0) result.push(value);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function candidateValuesFromMask(mask: number, size: number): number[] {
|
|
if (!Number.isInteger(size) || size < 1 || size > 30) return [];
|
|
return candidateValues(Number.isInteger(mask) ? mask : 0, size);
|
|
}
|
|
|
|
export function houseLabel(unit: Pick<SudokuUnit, "kind" | "index">): string {
|
|
switch (unit.kind) {
|
|
case "row":
|
|
return `Row ${String(unit.index + 1)}`;
|
|
case "column":
|
|
return `Column ${String(unit.index + 1)}`;
|
|
case "region":
|
|
return `Region ${String(unit.index + 1)}`;
|
|
case "diagonal":
|
|
return unit.index === 0 ? "Main diagonal" : "Anti-diagonal";
|
|
}
|
|
}
|
|
|
|
/** All houses touching at least one of the supplied cells, in compile order. */
|
|
export function candidateUnitIndicesForCells(
|
|
puzzle: NormalizedPuzzle,
|
|
cells: readonly number[],
|
|
): number[] {
|
|
const compiled = compilePuzzle(puzzle);
|
|
const indices = new Set<number>();
|
|
for (const cell of cells) {
|
|
for (const unitIndex of compiled.unitsByCell[cell] ?? []) {
|
|
indices.add(unitIndex);
|
|
}
|
|
}
|
|
return [...indices].sort((a, b) => a - b);
|
|
}
|
|
|
|
export function inspectCandidateCells(
|
|
puzzle: NormalizedPuzzle,
|
|
candidateMasks: readonly number[],
|
|
cells: readonly number[],
|
|
): CellCandidateInspection[] {
|
|
const compiled = compilePuzzle(puzzle);
|
|
return [...new Set(cells)]
|
|
.filter(
|
|
(cell) => Number.isInteger(cell) && cell >= 0 && cell < puzzle.size ** 2,
|
|
)
|
|
.sort((a, b) => a - b)
|
|
.map((cell) => ({
|
|
cell,
|
|
values: candidateValues(candidateMasks[cell] ?? 0, puzzle.size),
|
|
houseLabels: (compiled.unitsByCell[cell] ?? []).map((unitIndex) =>
|
|
houseLabel(compiled.units[unitIndex]!),
|
|
),
|
|
}));
|
|
}
|
|
|
|
export function inspectCandidateHouse(
|
|
puzzle: NormalizedPuzzle,
|
|
values: readonly number[],
|
|
candidateMasks: readonly number[],
|
|
unitIndex: number,
|
|
): HouseCandidateInspection | undefined {
|
|
const compiled = compilePuzzle(puzzle);
|
|
return inspectCandidateHouseWithCompiled(
|
|
compiled,
|
|
values,
|
|
candidateMasks,
|
|
unitIndex,
|
|
);
|
|
}
|
|
|
|
function inspectCandidateHouseWithCompiled(
|
|
compiled: CompiledPuzzle,
|
|
values: readonly number[],
|
|
candidateMasks: readonly number[],
|
|
unitIndex: number,
|
|
): HouseCandidateInspection | undefined {
|
|
const { puzzle } = compiled;
|
|
const unit = compiled.units[unitIndex];
|
|
if (unit === undefined) return undefined;
|
|
const placed = new Set(
|
|
unit.cells
|
|
.map((cell) => values[cell] ?? 0)
|
|
.filter((value) => value >= 1 && value <= puzzle.size),
|
|
);
|
|
const missingValues = Array.from(
|
|
{ length: puzzle.size },
|
|
(_, index) => index + 1,
|
|
).filter((value) => !placed.has(value));
|
|
const positions = missingValues.map((value) => {
|
|
const cells = unit.cells.filter(
|
|
(cell) =>
|
|
(values[cell] ?? 0) === 0 &&
|
|
((candidateMasks[cell] ?? 0) & (1 << (value - 1))) !== 0,
|
|
);
|
|
return {
|
|
value,
|
|
cells,
|
|
linkKind:
|
|
cells.length === 2
|
|
? ("strong" as const)
|
|
: cells.length > 2
|
|
? ("weak" as const)
|
|
: ("none" as const),
|
|
};
|
|
});
|
|
return {
|
|
unitIndex,
|
|
kind: unit.kind,
|
|
index: unit.index,
|
|
label: houseLabel(unit),
|
|
cells: unit.cells,
|
|
missingValues,
|
|
positions,
|
|
};
|
|
}
|
|
|
|
export function inspectCandidateHouses(
|
|
puzzle: NormalizedPuzzle,
|
|
values: readonly number[],
|
|
candidateMasks: readonly number[],
|
|
unitIndices?: readonly number[],
|
|
): HouseCandidateInspection[] {
|
|
const compiled = compilePuzzle(puzzle);
|
|
const indices = unitIndices ?? compiled.units.map((_, index) => index);
|
|
return [...new Set(indices)]
|
|
.map((unitIndex) =>
|
|
inspectCandidateHouseWithCompiled(
|
|
compiled,
|
|
values,
|
|
candidateMasks,
|
|
unitIndex,
|
|
),
|
|
)
|
|
.filter((inspection): inspection is HouseCandidateInspection =>
|
|
Boolean(inspection),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Derive the standard candidate-link graph from houses and cells.
|
|
*
|
|
* - two positions for a digit in a house form a strong link;
|
|
* - three or more positions form pairwise weak links;
|
|
* - two candidates in one cell form a strong link;
|
|
* - three or more candidates in one cell form pairwise weak links.
|
|
*/
|
|
export function deriveCandidateLinks(
|
|
puzzle: NormalizedPuzzle,
|
|
candidateMasks: readonly number[],
|
|
options: CandidateLinkOptions = {},
|
|
): CandidateLink[] {
|
|
const compiled = compilePuzzle(puzzle);
|
|
const activeValues =
|
|
options.values === undefined
|
|
? undefined
|
|
: new Set(
|
|
options.values.filter(
|
|
(value) =>
|
|
Number.isInteger(value) && value >= 1 && value <= puzzle.size,
|
|
),
|
|
);
|
|
const links = new Map<
|
|
string,
|
|
{
|
|
kind: CandidateLinkKind;
|
|
a: CandidateNode;
|
|
b: CandidateNode;
|
|
contexts: CandidateLinkContext[];
|
|
}
|
|
>();
|
|
const unitIndices =
|
|
options.unitIndices ?? compiled.units.map((_, index) => index);
|
|
|
|
for (const unitIndex of new Set(unitIndices)) {
|
|
const unit = compiled.units[unitIndex];
|
|
if (unit === undefined) continue;
|
|
for (let value = 1; value <= puzzle.size; value += 1) {
|
|
if (activeValues !== undefined && !activeValues.has(value)) continue;
|
|
const nodes = unit.cells
|
|
.filter(
|
|
(cell) => ((candidateMasks[cell] ?? 0) & (1 << (value - 1))) !== 0,
|
|
)
|
|
.map((cell) => ({ cell, value }));
|
|
if (nodes.length < 2) continue;
|
|
const kind: CandidateLinkKind = nodes.length === 2 ? "strong" : "weak";
|
|
for (let a = 0; a < nodes.length; a += 1) {
|
|
for (let b = a + 1; b < nodes.length; b += 1) {
|
|
addLink(links, kind, nodes[a]!, nodes[b]!, {
|
|
kind: "house",
|
|
label: houseLabel(unit),
|
|
unitKind: unit.kind,
|
|
unitIndex,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (options.includeCellLinks !== false) {
|
|
const cellIndices =
|
|
options.cellIndices ??
|
|
Array.from({ length: puzzle.size ** 2 }, (_, cell) => cell);
|
|
for (const cell of new Set(cellIndices)) {
|
|
if (!Number.isInteger(cell) || cell < 0 || cell >= puzzle.size ** 2)
|
|
continue;
|
|
const allValues = candidateValues(candidateMasks[cell] ?? 0, puzzle.size);
|
|
if (allValues.length < 2) continue;
|
|
const kind: CandidateLinkKind =
|
|
allValues.length === 2 ? "strong" : "weak";
|
|
const visibleValues =
|
|
activeValues === undefined
|
|
? allValues
|
|
: allValues.filter((value) => activeValues.has(value));
|
|
for (let a = 0; a < visibleValues.length; a += 1) {
|
|
for (let b = a + 1; b < visibleValues.length; b += 1) {
|
|
addLink(
|
|
links,
|
|
kind,
|
|
{ cell, value: visibleValues[a]! },
|
|
{ cell, value: visibleValues[b]! },
|
|
{ kind: "cell", label: `Cell ${cellLabel(cell, puzzle.size)}` },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...links.entries()]
|
|
.map(([id, link]) => ({ id, ...link }))
|
|
.sort((a, b) => {
|
|
if (a.kind !== b.kind) return a.kind === "strong" ? -1 : 1;
|
|
return a.id.localeCompare(b.id, undefined, { numeric: true });
|
|
});
|
|
}
|
|
|
|
export function candidateCellsForValues(
|
|
candidateMasks: readonly number[],
|
|
values: readonly number[],
|
|
): number[] {
|
|
const combinedMask = values.reduce(
|
|
(mask, value) =>
|
|
Number.isInteger(value) && value >= 1 && value <= 30
|
|
? mask | (1 << (value - 1))
|
|
: mask,
|
|
0,
|
|
);
|
|
if (combinedMask === 0) return [];
|
|
return candidateMasks.flatMap((mask, cell) =>
|
|
(mask & combinedMask) !== 0 ? [cell] : [],
|
|
);
|
|
}
|
|
|
|
export function cellLabel(cell: number, size: number): string {
|
|
return `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
|
|
}
|