feat: complete advanced Sudoku workbench
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
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(" ");
|
||||
}
|
||||
Reference in New Issue
Block a user