Files
sudoku-tools/src/components/guidedHint.ts
T

224 lines
7.3 KiB
TypeScript

import type { LogicalStep, LogicalTechnique } from "../solver";
import { symbolFor } from "../state/session";
export const GUIDED_HINT_STAGES = [
"focus",
"technique",
"reasoning",
"preview",
] as const;
export type GuidedHintStage = (typeof GUIDED_HINT_STAGES)[number];
export interface GuidedHintCellSets {
readonly focusCells: readonly number[];
readonly placementCells: readonly number[];
readonly eliminationCells: readonly number[];
readonly affectedCells: readonly number[];
}
export interface GuidedHintOverlay {
readonly focusCells: readonly number[];
readonly placementCells: readonly number[];
readonly eliminationCells: readonly number[];
}
const TECHNIQUE_DESCRIPTIONS: Partial<Record<LogicalTechnique, string>> = {
"naked-single": "A cell has only one legal candidate left.",
"hidden-single": "A digit has only one possible position in a house.",
"naked-pair":
"Two cells reserve the same two candidates, excluding them elsewhere in their house.",
"naked-triple":
"Three cells reserve three candidates, excluding them elsewhere in their house.",
"naked-quad":
"Four cells reserve four candidates, excluding them elsewhere in their house.",
"hidden-pair": "Two digits can occur in only the same two cells of a house.",
"hidden-triple":
"Three digits can occur in only the same three cells of a house.",
"hidden-quad":
"Four digits can occur in only the same four cells of a house.",
pointing:
"A candidate confined to one line inside a region can be removed farther along that line.",
claiming:
"A candidate confined to one region along a line can be removed from the rest of that region.",
"x-wing":
"Two matching rows or columns lock a candidate into two opposite positions.",
swordfish:
"Three matching rows or columns lock a candidate into three crossing lines.",
"xy-wing":
"Three linked bivalue cells force a shared candidate out of cells that see both wings.",
"xyz-wing":
"A three-candidate pivot and two wings force their shared candidate elsewhere.",
"killer-cage":
"A cage's remaining sum and legal combinations restrict its unsolved cells.",
};
function uniqueCells(cells: readonly number[]): number[] {
return [...new Set(cells)].filter(
(cell) => Number.isInteger(cell) && cell >= 0,
);
}
function naturalList(items: readonly string[]): string {
if (items.length <= 1) return items[0] ?? "";
if (items.length === 2) return `${items[0]} and ${items[1]}`;
return `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
}
export function guidedHintStageIndex(stage: GuidedHintStage): number {
return GUIDED_HINT_STAGES.indexOf(stage);
}
export function nextGuidedHintStage(
stage: GuidedHintStage,
): GuidedHintStage | undefined {
return GUIDED_HINT_STAGES[guidedHintStageIndex(stage) + 1];
}
export function isGuidedHintStageRevealed(
current: GuidedHintStage,
stage: GuidedHintStage,
): boolean {
return guidedHintStageIndex(current) >= guidedHintStageIndex(stage);
}
export function logicalTechniqueName(technique: LogicalTechnique): string {
return technique
.split("-")
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join(" ");
}
export function logicalTechniqueDescription(
technique: LogicalTechnique,
): string {
return (
TECHNIQUE_DESCRIPTIONS[technique] ??
`This ${logicalTechniqueName(technique).toLowerCase()} pattern creates a logical deduction.`
);
}
export function guidedHintCellName(cell: number, size: number): string {
return `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
}
export function deriveGuidedHintCellSets(
step: LogicalStep,
): GuidedHintCellSets {
const focusCells = uniqueCells(step.focusCells);
const placementCells = uniqueCells(
step.placements.map((placement) => placement.cell),
);
const eliminationCells = uniqueCells(
step.eliminations.map((elimination) => elimination.cell),
);
return {
focusCells,
placementCells,
eliminationCells,
affectedCells: uniqueCells([...placementCells, ...eliminationCells]),
};
}
/**
* A guided step is safe to disclose only when every premise and every effect
* is currently visible. This deliberately includes focus cells: an otherwise
* harmless-looking technique name or explanation can reveal a hidden clue.
*/
export function guidedHintStepIsVisible(
step: LogicalStep,
hiddenCells: ReadonlySet<number>,
): boolean {
const { focusCells, placementCells, eliminationCells } =
deriveGuidedHintCellSets(step);
return [...focusCells, ...placementCells, ...eliminationCells].every(
(cell) => !hiddenCells.has(cell),
);
}
/**
* Returns only the cells that may be visualised at the current disclosure
* stage. In particular, effect cells stay absent until the preview stage.
*/
export function guidedHintOverlay(
step: LogicalStep,
stage: GuidedHintStage,
): GuidedHintOverlay {
const cells = deriveGuidedHintCellSets(step);
return {
focusCells: cells.focusCells,
placementCells: stage === "preview" ? cells.placementCells : [],
eliminationCells: stage === "preview" ? cells.eliminationCells : [],
};
}
export function guidedHintFocusSummary(
step: LogicalStep,
size: number,
): string {
const { focusCells, affectedCells } = deriveGuidedHintCellSets(step);
const cells = focusCells.length > 0 ? focusCells : affectedCells;
if (cells.length === 0) return "Review the current candidate grid.";
if (cells.length === 1) {
return `Look closely at ${guidedHintCellName(cells[0]!, size)}.`;
}
const rows = new Set(cells.map((cell) => Math.floor(cell / size)));
if (rows.size === 1) {
return `Look across row ${String((Math.floor(cells[0]! / size) || 0) + 1)}.`;
}
const columns = new Set(cells.map((cell) => cell % size));
if (columns.size === 1) {
return `Look down column ${String(((cells[0] ?? 0) % size) + 1)}.`;
}
const boxSize = Math.sqrt(size);
if (Number.isInteger(boxSize)) {
const boxes = new Set(
cells.map((cell) => {
const row = Math.floor(cell / size);
const column = cell % size;
return (
Math.floor(row / boxSize) * boxSize + Math.floor(column / boxSize)
);
}),
);
if (boxes.size === 1) {
return `Look within box ${String((boxes.values().next().value as number) + 1)}.`;
}
}
if (cells.length <= 4) {
return `Compare ${naturalList(cells.map((cell) => guidedHintCellName(cell, size)))}.`;
}
return `Compare the ${String(cells.length)} highlighted cells.`;
}
export function guidedHintEffectItems(
step: LogicalStep,
size: number,
): readonly string[] {
const placements = step.placements.map(
({ cell, value }) =>
`Place ${symbolFor(value, size)} in ${guidedHintCellName(cell, size)}.`,
);
const eliminations = step.eliminations.map(({ cell, values }) => {
const symbols = values.map((value) => symbolFor(value, size));
const object =
symbols.length === 1
? symbols[0]
: naturalList(symbols.map((symbol) => String(symbol)));
return `Remove ${object} from ${guidedHintCellName(cell, size)}.`;
});
return [...placements, ...eliminations];
}
export function guidedHintEffectSummary(
step: LogicalStep,
size: number,
): string {
const effects = guidedHintEffectItems(step, size);
if (effects.length === 0) return "This deduction does not change the board.";
return effects.join(" ");
}