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> = { "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, ): 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(" "); }