feat: complete advanced Sudoku workbench

This commit is contained in:
2026-08-31 08:20:30 +02:00
parent 8ca9300ab3
commit 0a1bdc1a8c
99 changed files with 20793 additions and 923 deletions
+183
View File
@@ -0,0 +1,183 @@
import {
useEffect,
useRef,
useState,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from "react";
import {
BOARD_SCALE_MAX,
BOARD_SCALE_MIN,
BOARD_SCALE_STEP,
normalizeBoardScale,
readStoredBoardScale,
writeStoredBoardScale,
} from "../state/uiPreferences";
interface PanOrigin {
readonly pointerId: number;
readonly clientX: number;
readonly clientY: number;
readonly scrollLeft: number;
readonly scrollTop: number;
}
export function BoardViewport({
children,
onPanModeChange,
}: {
readonly children: ReactNode;
readonly onPanModeChange?: (enabled: boolean) => void;
}) {
const [scale, setScale] = useState(readStoredBoardScale);
const [panMode, setPanMode] = useState(false);
const scrollerRef = useRef<HTMLDivElement>(null);
const panOriginRef = useRef<PanOrigin | undefined>(undefined);
useEffect(() => writeStoredBoardScale(scale), [scale]);
const updateScale = (value: number) => setScale(normalizeBoardScale(value));
const fitBoard = () => {
updateScale(1);
if (scrollerRef.current !== null) {
scrollerRef.current.scrollLeft = 0;
scrollerRef.current.scrollTop = 0;
}
};
const onKeyDownCapture = (event: KeyboardEvent<HTMLElement>) => {
if (!(event.ctrlKey || event.metaKey)) return;
if (["+", "="].includes(event.key)) {
event.preventDefault();
event.stopPropagation();
updateScale(scale + BOARD_SCALE_STEP);
} else if (event.key === "-") {
event.preventDefault();
event.stopPropagation();
updateScale(scale - BOARD_SCALE_STEP);
} else if (event.key === "0") {
event.preventDefault();
event.stopPropagation();
fitBoard();
}
};
const beginPan = (event: PointerEvent<HTMLDivElement>) => {
if (!panMode || (event.pointerType === "mouse" && event.button !== 0))
return;
const scroller = scrollerRef.current;
if (scroller === null) return;
event.preventDefault();
scroller.setPointerCapture?.(event.pointerId);
panOriginRef.current = {
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
scrollLeft: scroller.scrollLeft,
scrollTop: scroller.scrollTop,
};
};
const continuePan = (event: PointerEvent<HTMLDivElement>) => {
const origin = panOriginRef.current;
const scroller = scrollerRef.current;
if (
!panMode ||
origin === undefined ||
origin.pointerId !== event.pointerId ||
scroller === null
) {
return;
}
event.preventDefault();
scroller.scrollLeft = origin.scrollLeft - (event.clientX - origin.clientX);
scroller.scrollTop = origin.scrollTop - (event.clientY - origin.clientY);
};
const endPan = (event: PointerEvent<HTMLDivElement>) => {
if (panOriginRef.current?.pointerId !== event.pointerId) return;
scrollerRef.current?.releasePointerCapture?.(event.pointerId);
panOriginRef.current = undefined;
};
const percentage = Math.round(scale * 100);
return (
<section
className={`board-viewport${panMode ? " is-pan-mode" : ""}`}
aria-label="Board zoom and pan"
onKeyDownCapture={onKeyDownCapture}
>
<div className="board-viewport__controls">
<div
className="board-zoom-buttons"
role="group"
aria-label="Board zoom"
>
<button
type="button"
aria-label="Zoom board out"
disabled={scale <= BOARD_SCALE_MIN}
onClick={() => updateScale(scale - BOARD_SCALE_STEP)}
>
</button>
<output aria-live="polite" aria-label="Board zoom level">
{percentage}%
</output>
<button
type="button"
aria-label="Zoom board in"
disabled={scale >= BOARD_SCALE_MAX}
onClick={() => updateScale(scale + BOARD_SCALE_STEP)}
>
+
</button>
<button type="button" onClick={fitBoard}>
Fit board
</button>
</div>
<button
type="button"
className={panMode ? "is-active" : ""}
aria-pressed={panMode}
onClick={() =>
setPanMode((current) => {
const enabled = !current;
panOriginRef.current = undefined;
onPanModeChange?.(enabled);
return enabled;
})
}
>
{panMode ? "Stop panning" : "Pan board"}
</button>
</div>
{panMode && (
<p className="board-pan-notice" role="status">
Pan mode: drag the board to move it. Cell taps are paused.
</p>
)}
<div
ref={scrollerRef}
className="board-viewport__scroller"
tabIndex={0}
aria-label={
panMode
? "Scrollable puzzle board; pan mode is on"
: "Scrollable puzzle board"
}
onPointerDown={beginPan}
onPointerMove={continuePan}
onPointerUp={endPan}
onPointerCancel={endPan}
>
<div
className="board-viewport__canvas"
style={{
width: `${String(percentage)}%`,
maxWidth: `${String(48 * scale)}rem`,
}}
>
{children}
</div>
</div>
</section>
);
}
+659 -71
View File
@@ -1,6 +1,19 @@
import { useState } from "react";
import { cellsFormQuadruple } from "../domain/geometry";
import type { PuzzleDefinition, VariantConstraint } from "../domain/types";
import { useId, useState } from "react";
import {
cellsFormQuadruple,
classicRegions,
constraintLabel,
constraintMetadata,
littleKillerCells,
littleKillerDirectionEntersGrid,
validatePuzzle,
} from "../domain";
import type {
LittleKillerDirection,
OutsideClueSide,
PuzzleDefinition,
VariantConstraint,
} from "../domain/types";
import {
removeKillerCagesAtCells,
replaceOverlappingKillerCages,
@@ -17,6 +30,65 @@ interface ConstraintEditorProps {
busy: boolean;
}
type OutsideConstraintType =
"x-sum" | "skyscraper" | "little-killer" | "sandwich";
const LITTLE_KILLER_DIRECTIONS = [
["down-right", "Down right ↘"],
["down-left", "Down left ↙"],
["up-right", "Up right ↗"],
["up-left", "Up left ↖"],
] as const satisfies readonly (readonly [LittleKillerDirection, string])[];
function firstLittleKillerDirection(side: OutsideClueSide) {
return (
LITTLE_KILLER_DIRECTIONS.find(([direction]) =>
littleKillerDirectionEntersGrid(side, direction),
)?.[0] ?? "down-right"
);
}
function reachableSandwichSums(size: number): ReadonlySet<number> {
let totals = new Set([0]);
for (let digit = 2; digit < size; digit += 1) {
totals = new Set([...totals, ...[...totals].map((total) => total + digit)]);
}
return totals;
}
function sameOrderedCells(
first: readonly number[],
second: readonly number[],
): boolean {
return (
first.length === second.length &&
first.every((cell, index) => cell === second[index])
);
}
function hasClassicRegionPartition(puzzle: PuzzleDefinition): boolean {
const expected = classicRegions(puzzle.size);
const actual = puzzle.regions ?? expected;
if (actual.length !== expected.length) return false;
const expectedToActual = new Map<number, number>();
const actualToExpected = new Map<number, number>();
return expected.every((expectedRegion, cell) => {
const actualRegion = actual[cell];
if (actualRegion === undefined) return false;
const mappedActual = expectedToActual.get(expectedRegion);
const mappedExpected = actualToExpected.get(actualRegion);
if (
(mappedActual !== undefined && mappedActual !== actualRegion) ||
(mappedExpected !== undefined && mappedExpected !== expectedRegion)
) {
return false;
}
expectedToActual.set(expectedRegion, actualRegion);
actualToExpected.set(actualRegion, expectedRegion);
return true;
});
}
function describeConstraint(constraint: VariantConstraint, size: number) {
const cell = (index: number) =>
`r${String(Math.floor(index / size) + 1)}c${String((index % size) + 1)}`;
@@ -33,6 +105,8 @@ function describeConstraint(constraint: VariantConstraint, size: number) {
return "anti-king";
case "non-consecutive":
return "non-consecutive";
case "disjoint-groups":
return "disjoint groups · matching box positions do not repeat";
case "killer-cage":
return marked(
`${String(constraint.sum)} cage · ${String(constraint.cells.length)} cells${constraint.noRepeat === false ? " · repeats allowed" : ""}`,
@@ -71,13 +145,63 @@ function describeConstraint(constraint: VariantConstraint, size: number) {
);
case "maximum":
return marked(`maximum · ${cell(constraint.cell)}`);
case "minimum":
return marked(`minimum · ${cell(constraint.cell)}`);
case "odd":
return marked(`odd circle · ${cell(constraint.cell)}`);
case "even":
return marked(`even square · ${cell(constraint.cell)}`);
case "little-killer":
return marked(
`little killer ${String(constraint.sum)} · ${constraint.side} ${String(constraint.index + 1)} · ${constraint.direction.replace("-", " ")}`,
);
case "sandwich":
return marked(
`sandwich ${String(constraint.sum)} · ${constraint.side} ${String(constraint.index + 1)}`,
);
case "between-line":
return marked(
`between line · ${String(constraint.cells.length)} ordered cells`,
);
case "german-whisper":
return marked(
`German whisper ≥ ${String(constraint.minimumDifference ?? Math.ceil(size / 2))} · ${String(constraint.cells.length)} ordered cells`,
);
case "region-sum-line":
return marked(
`region-sum line · ${String(constraint.cells.length)} ordered cells`,
);
case "clone":
return marked(
`clone regions · ${String(constraint.cells.length)} + ${String(constraint.cloneCells.length)} paired cells`,
);
case "extra-region":
return `extra region · ${String(constraint.cells.length)} cells`;
case "modular-line":
return marked(
`modular line · ${String(constraint.cells.length)} ordered cells`,
);
case "entropic-line":
return marked(
`entropic line · ${String(constraint.cells.length)} ordered cells`,
);
case "zipper-line":
return marked(
`zipper line · ${String(constraint.cells.length)} ordered cells`,
);
case "double-arrow":
return marked(
`double arrow · ${String(constraint.cells.length)} ordered cells`,
);
case "indexer":
return marked(`${constraint.kind} indexer · ${cell(constraint.cell)}`);
case "fog":
return `fog · ${String(constraint.lights.length)} initial light${constraint.lights.length === 1 ? "" : "s"} · radius ${String(constraint.revealRadius ?? 1)}`;
}
}
function supportsPolarity(constraint: VariantConstraint): boolean {
return !["diagonal", "anti-knight", "anti-king", "non-consecutive"].includes(
constraint.type,
);
return constraintMetadata(constraint.type).negatable;
}
function parseClueDigits(text: string, size: number): number[] {
@@ -109,21 +233,31 @@ export function ConstraintEditor({
const [region, setRegion] = useState(1);
const [newCluesAreFalse, setNewCluesAreFalse] = useState(false);
const [quadrupleDigits, setQuadrupleDigits] = useState("1, 2, 3");
const [outsideType, setOutsideType] = useState<"x-sum" | "skyscraper">(
"x-sum",
);
const [outsideSide, setOutsideSide] = useState<
"top" | "right" | "bottom" | "left"
>("top");
const [outsideType, setOutsideType] =
useState<OutsideConstraintType>("x-sum");
const [outsideSide, setOutsideSide] = useState<OutsideClueSide>("top");
const [littleKillerDirection, setLittleKillerDirection] =
useState<LittleKillerDirection>("down-right");
const [outsideLine, setOutsideLine] = useState(1);
const [outsideValue, setOutsideValue] = useState(3);
const [whisperMinimumDifference, setWhisperMinimumDifference] = useState(
Math.ceil(puzzle.size / 2),
);
const [indexerKind, setIndexerKind] = useState<"row" | "column" | "box">(
"row",
);
const [fogRevealRadius, setFogRevealRadius] = useState<0 | 1>(1);
const fogReasonId = useId();
const constraints = puzzle.constraints ?? [];
const append = (constraint: VariantConstraint) =>
const append = (
constraint: VariantConstraint,
replace: (candidate: VariantConstraint) => boolean = () => false,
) =>
onChange({
...puzzle,
constraints: [
...constraints,
...constraints.filter((candidate) => !replace(candidate)),
supportsPolarity(constraint) && newCluesAreFalse
? ({ ...constraint, negated: true } as VariantConstraint)
: constraint,
@@ -146,6 +280,7 @@ export function ConstraintEditor({
? cageSum >= 1 && cageSum <= puzzle.size ** 3
: cageSum >= minimumCageSum && cageSum <= maximumCageSum);
const parsedQuadrupleDigits = parseClueDigits(quadrupleDigits, puzzle.size);
const selectionIsUnique = new Set(selection).size === selection.length;
const validQuadruple =
cellsFormQuadruple(puzzle.size, selection) &&
parsedQuadrupleDigits.length >= 1 &&
@@ -155,9 +290,95 @@ export function ConstraintEditor({
(digit) => Number.isInteger(digit) && digit >= 1 && digit <= puzzle.size,
);
const selectedCageExists = selectionTouchesKillerCage(constraints, selection);
const selectedCells = new Set(selection);
const selectedCellMarkerExists = constraints.some(
(constraint) =>
(constraint.type === "maximum" ||
constraint.type === "minimum" ||
constraint.type === "odd" ||
constraint.type === "even") &&
selectedCells.has(constraint.cell),
);
const littleKillerLine = littleKillerCells(
puzzle.size,
outsideSide,
outsideLine - 1,
littleKillerDirection,
);
const validOutsidePosition =
Number.isInteger(outsideLine) &&
outsideLine >= 1 &&
outsideLine <= puzzle.size;
const validLittleKillerDirection = littleKillerDirectionEntersGrid(
outsideSide,
littleKillerDirection,
);
const outsideMinimum = newCluesAreFalse
? outsideType === "sandwich"
? 0
: 1
: outsideType === "sandwich"
? 0
: outsideType === "little-killer"
? littleKillerLine.length
: 1;
const outsideMaximum = newCluesAreFalse
? puzzle.size ** 4
: outsideType === "x-sum"
? (puzzle.size * (puzzle.size + 1)) / 2
: outsideType === "skyscraper"
? puzzle.size
: outsideType === "little-killer"
? littleKillerLine.length * puzzle.size
: (puzzle.size * (puzzle.size + 1)) / 2 - puzzle.size - 1;
const validOutsideValue =
Number.isInteger(outsideValue) &&
outsideValue >= outsideMinimum &&
outsideValue <= outsideMaximum &&
(newCluesAreFalse ||
outsideType !== "sandwich" ||
reachableSandwichSums(puzzle.size).has(outsideValue));
const validOutsideClue =
validOutsidePosition &&
validOutsideValue &&
(outsideType !== "little-killer" ||
(validLittleKillerDirection && littleKillerLine.length >= 2));
const validWhisperDifference =
Number.isInteger(whisperMinimumDifference) &&
whisperMinimumDifference >= 1 &&
whisperMinimumDifference < puzzle.size;
const cloneHalf = selection.length / 2;
const cloneSource = Number.isInteger(cloneHalf)
? selection.slice(0, cloneHalf)
: [];
const cloneTarget = Number.isInteger(cloneHalf)
? selection.slice(cloneHalf)
: [];
const validClone =
selectionIsUnique &&
cloneSource.length >= 1 &&
cloneSource.length === cloneTarget.length &&
cloneTarget.every((cell) => !new Set(cloneSource).has(cell));
const solutionIsComplete =
puzzle.solution?.length === puzzle.size * puzzle.size &&
puzzle.solution.every(
(value, cell) =>
Number.isInteger(value) &&
value >= 1 &&
value <= puzzle.size &&
((puzzle.givens[cell] ?? 0) === 0 || puzzle.givens[cell] === value),
);
const trustedSolution = solutionIsComplete && validatePuzzle(puzzle).valid;
const boxIndexerAvailable = hasClassicRegionPartition(puzzle);
const fogDisabledReason = !trustedSolution
? "Fog of War requires a complete trusted solution. Generate or import one before choosing initial lights."
: !atLeast(1) || !selectionIsUnique
? "Select at least one unique initial light cell."
: undefined;
const toggleGlobal = (
type: "anti-knight" | "anti-king" | "non-consecutive",
type: "anti-knight" | "anti-king" | "non-consecutive" | "disjoint-groups",
) => {
const exists = constraints.some((constraint) => constraint.type === type);
onChange({
@@ -180,7 +401,11 @@ export function ConstraintEditor({
Grid
<select
value={puzzle.size}
onChange={(event) => onNewGrid(Number(event.target.value))}
onChange={(event) => {
const size = Number(event.target.value);
setWhisperMinimumDifference(Math.ceil(size / 2));
onNewGrid(size);
}}
>
<option value="4">4 × 4</option>
<option value="6">6 × 6</option>
@@ -419,10 +644,335 @@ export function ConstraintEditor({
<button
type="button"
disabled={!need(1)}
onClick={() => append({ type: "maximum", cell: selection[0]! })}
onClick={() =>
append(
{ type: "maximum", cell: selection[0]! },
(constraint) =>
constraint.type === "maximum" &&
constraint.cell === selection[0],
)
}
>
Maximum cell
</button>
<button
type="button"
disabled={!need(1)}
onClick={() =>
append(
{ type: "minimum", cell: selection[0]! },
(constraint) =>
constraint.type === "minimum" &&
constraint.cell === selection[0],
)
}
>
Minimum cell
</button>
<button
type="button"
disabled={!need(1)}
onClick={() =>
append(
{ type: "odd", cell: selection[0]! },
(constraint) =>
constraint.type === "odd" && constraint.cell === selection[0],
)
}
>
Odd cell (circle)
</button>
<button
type="button"
disabled={!need(1)}
onClick={() =>
append(
{ type: "even", cell: selection[0]! },
(constraint) =>
constraint.type === "even" &&
constraint.cell === selection[0],
)
}
>
Even cell (square)
</button>
<button
type="button"
className="danger"
disabled={!selectedCellMarkerExists}
onClick={() =>
onChange({
...puzzle,
constraints: constraints.filter(
(constraint) =>
!(
(constraint.type === "maximum" ||
constraint.type === "minimum" ||
constraint.type === "odd" ||
constraint.type === "even") &&
selectedCells.has(constraint.cell)
),
),
})
}
>
Remove selected cell markers
</button>
</div>
<div className="pack-constraint-controls stack">
<div>
<p className="eyebrow">Expansion lines &amp; regions</p>
<p className="muted">
Selection order defines each line. For clone regions, select all
source cells first and then the same number of paired clone cells.
</p>
</div>
<div className="inline-fields">
<label className="compact-field">
Whisper minimum difference
<input
type="number"
min="1"
max={puzzle.size - 1}
value={whisperMinimumDifference}
onChange={(event) =>
setWhisperMinimumDifference(Number(event.target.value))
}
/>
</label>
</div>
<div className="button-grid">
<button
type="button"
disabled={!atLeast(3) || !selectionIsUnique}
onClick={() =>
append(
{ type: "between-line", cells: selection },
(constraint) =>
constraint.type === "between-line" &&
sameOrderedCells(constraint.cells, selection),
)
}
>
Between line
</button>
<button
type="button"
disabled={
!atLeast(2) || !selectionIsUnique || !validWhisperDifference
}
onClick={() =>
append(
{
type: "german-whisper",
cells: selection,
minimumDifference: whisperMinimumDifference,
},
(constraint) =>
constraint.type === "german-whisper" &&
sameOrderedCells(constraint.cells, selection),
)
}
>
German whisper
</button>
<button
type="button"
disabled={!atLeast(2) || !selectionIsUnique}
onClick={() =>
append(
{ type: "region-sum-line", cells: selection },
(constraint) =>
constraint.type === "region-sum-line" &&
sameOrderedCells(constraint.cells, selection),
)
}
>
Region-sum line
</button>
<button
type="button"
disabled={!validClone}
onClick={() =>
append(
{
type: "clone",
cells: cloneSource,
cloneCells: cloneTarget,
},
(constraint) =>
constraint.type === "clone" &&
sameOrderedCells(constraint.cells, cloneSource) &&
sameOrderedCells(constraint.cloneCells, cloneTarget),
)
}
>
Clone selection halves
</button>
<button
type="button"
disabled={selection.length !== puzzle.size || !selectionIsUnique}
onClick={() =>
append(
{ type: "extra-region", cells: selection },
(constraint) =>
constraint.type === "extra-region" &&
new Set(constraint.cells).size === selection.length &&
selection.every((cell) => constraint.cells.includes(cell)),
)
}
>
Extra region ({String(puzzle.size)} cells)
</button>
</div>
</div>
<div className="pack-constraint-controls pack-three-controls stack">
<div>
<p className="eyebrow">Pattern lines, indexers &amp; fog</p>
<p className="muted">
Selection order runs from the first selected cell to the last.
Indexers use one selected marker cell. Fog uses every selected
cell as an initial light.
</p>
</div>
<div className="inline-fields">
<label className="compact-field">
Indexer kind
<select
value={indexerKind}
onChange={(event) =>
setIndexerKind(event.target.value as "row" | "column" | "box")
}
>
<option value="row">Row indexer</option>
<option value="column">Column indexer</option>
<option value="box" disabled={!boxIndexerAvailable}>
Box indexer
</option>
</select>
</label>
<label className="compact-field">
Fog reveal radius
<select
value={fogRevealRadius}
onChange={(event) =>
setFogRevealRadius(Number(event.target.value) as 0 | 1)
}
>
<option value="1">1 cell (3 × 3 area)</option>
<option value="0">0 cells (entered cell only)</option>
</select>
</label>
</div>
<p className="muted pack-three-requirements">
Entropic lines require a grid size divisible by 3; zipper lines
require an odd number of selected cells.
{!boxIndexerAvailable &&
" Box indexers require the standard rectangular box layout."}
</p>
<div className="button-grid">
<button
type="button"
disabled={!atLeast(3) || !selectionIsUnique}
onClick={() =>
append(
{ type: "modular-line", cells: selection },
(constraint) =>
constraint.type === "modular-line" &&
sameOrderedCells(constraint.cells, selection),
)
}
>
Modular line
</button>
<button
type="button"
disabled={
!atLeast(3) || !selectionIsUnique || puzzle.size % 3 !== 0
}
onClick={() =>
append(
{ type: "entropic-line", cells: selection },
(constraint) =>
constraint.type === "entropic-line" &&
sameOrderedCells(constraint.cells, selection),
)
}
>
Entropic line
</button>
<button
type="button"
disabled={
!atLeast(3) || !selectionIsUnique || selection.length % 2 === 0
}
onClick={() =>
append(
{ type: "zipper-line", cells: selection },
(constraint) =>
constraint.type === "zipper-line" &&
sameOrderedCells(constraint.cells, selection),
)
}
>
Zipper line
</button>
<button
type="button"
disabled={!atLeast(3) || !selectionIsUnique}
onClick={() =>
append(
{ type: "double-arrow", cells: selection },
(constraint) =>
constraint.type === "double-arrow" &&
sameOrderedCells(constraint.cells, selection),
)
}
>
Double arrow
</button>
<button
type="button"
disabled={
!need(1) || (indexerKind === "box" && !boxIndexerAvailable)
}
onClick={() =>
append(
{
type: "indexer",
kind: indexerKind,
cell: selection[0]!,
},
(constraint) =>
constraint.type === "indexer" &&
constraint.cell === selection[0],
)
}
>
Add / replace indexer
</button>
<button
type="button"
disabled={fogDisabledReason !== undefined}
aria-describedby={fogReasonId}
onClick={() =>
append(
{
type: "fog",
lights: selection,
revealRadius: fogRevealRadius,
},
(constraint) => constraint.type === "fog",
)
}
>
Add / replace Fog of War
</button>
</div>
<p id={fogReasonId} className="muted fog-setter-reason" role="status">
{fogDisabledReason ??
"Fog is ready: correct entries and givens reveal nearby cells."}
</p>
</div>
<div className="inline-fields">
<label className="compact-field">
@@ -478,10 +1028,11 @@ export function ConstraintEditor({
<section className="panel-section">
<p className="eyebrow">Outside clues</p>
<h3>X-sums and skyscrapers</h3>
<h3>Edge and diagonal sums</h3>
<p className="muted">
Choose the edge and row or column. Σ badges are X-sums; badges are
visibility counts.
visibility counts; 1N marks sandwich sums. Little-killer arrows show
the diagonal direction.
</p>
<div className="inline-fields outside-clue-fields">
<label className="compact-field">
@@ -489,22 +1040,28 @@ export function ConstraintEditor({
<select
value={outsideType}
onChange={(event) =>
setOutsideType(event.target.value as "x-sum" | "skyscraper")
setOutsideType(event.target.value as OutsideConstraintType)
}
>
<option value="x-sum">X-sum</option>
<option value="skyscraper">Skyscraper</option>
<option value="little-killer">Little killer</option>
<option value="sandwich">Sandwich sum</option>
</select>
</label>
<label className="compact-field">
Side
<select
value={outsideSide}
onChange={(event) =>
setOutsideSide(
event.target.value as "top" | "right" | "bottom" | "left",
)
}
onChange={(event) => {
const side = event.target.value as OutsideClueSide;
setOutsideSide(side);
if (
!littleKillerDirectionEntersGrid(side, littleKillerDirection)
) {
setLittleKillerDirection(firstLittleKillerDirection(side));
}
}}
>
<option value="top">Top</option>
<option value="right">Right</option>
@@ -522,52 +1079,78 @@ export function ConstraintEditor({
onChange={(event) => setOutsideLine(Number(event.target.value))}
/>
</label>
{outsideType === "little-killer" && (
<label className="compact-field">
Direction
<select
value={littleKillerDirection}
onChange={(event) =>
setLittleKillerDirection(
event.target.value as LittleKillerDirection,
)
}
>
{LITTLE_KILLER_DIRECTIONS.map(([direction, label]) => (
<option
key={direction}
value={direction}
disabled={
!littleKillerDirectionEntersGrid(outsideSide, direction)
}
>
{label}
</option>
))}
</select>
</label>
)}
<label className="compact-field">
Clue
{outsideType === "skyscraper" ? "Count" : "Sum"}
<input
type="number"
min="1"
max={
newCluesAreFalse
? puzzle.size ** 4
: outsideType === "x-sum"
? (puzzle.size * (puzzle.size + 1)) / 2
: puzzle.size
}
min={outsideMinimum}
max={outsideMaximum}
value={outsideValue}
onChange={(event) => setOutsideValue(Number(event.target.value))}
/>
</label>
<button
type="button"
disabled={
!Number.isInteger(outsideLine) ||
outsideLine < 1 ||
outsideLine > puzzle.size ||
!Number.isInteger(outsideValue) ||
outsideValue < 1 ||
outsideValue >
(newCluesAreFalse
? puzzle.size ** 4
: outsideType === "x-sum"
? (puzzle.size * (puzzle.size + 1)) / 2
: puzzle.size)
}
disabled={!validOutsideClue}
onClick={() => {
const clue: VariantConstraint =
outsideType === "x-sum"
? {
const clue: VariantConstraint = (() => {
switch (outsideType) {
case "x-sum":
return {
type: "x-sum",
side: outsideSide,
index: outsideLine - 1,
sum: outsideValue,
}
: {
};
case "skyscraper":
return {
type: "skyscraper",
side: outsideSide,
index: outsideLine - 1,
count: outsideValue,
};
case "little-killer":
return {
type: "little-killer",
side: outsideSide,
index: outsideLine - 1,
direction: littleKillerDirection,
sum: outsideValue,
};
case "sandwich":
return {
type: "sandwich",
side: outsideSide,
index: outsideLine - 1,
sum: outsideValue,
};
}
})();
const withoutExisting = constraints.filter(
(constraint) =>
constraint.type !== outsideType ||
@@ -593,25 +1176,30 @@ export function ConstraintEditor({
<section className="panel-section">
<p className="eyebrow">Global rules</p>
<div className="button-grid">
{(["anti-knight", "anti-king", "non-consecutive"] as const).map(
(type) => (
<button
key={type}
type="button"
className={
constraints.some((constraint) => constraint.type === type)
? "is-active"
: ""
}
aria-pressed={constraints.some(
(constraint) => constraint.type === type,
)}
onClick={() => toggleGlobal(type)}
>
{type}
</button>
),
)}
{(
[
"anti-knight",
"anti-king",
"non-consecutive",
"disjoint-groups",
] as const
).map((type) => (
<button
key={type}
type="button"
className={
constraints.some((constraint) => constraint.type === type)
? "is-active"
: ""
}
aria-pressed={constraints.some(
(constraint) => constraint.type === type,
)}
onClick={() => toggleGlobal(type)}
>
{constraintLabel(type)}
</button>
))}
{(["main", "anti"] as const).map((direction) => {
const active = constraints.some(
(constraint) =>
+428 -20
View File
@@ -2,8 +2,13 @@ import { useMemo, useState, type FormEvent } from "react";
import {
GENERATOR_VARIANTS,
PRACTICE_TECHNIQUES,
type BatchRanking,
type ClueSymmetry,
type ConstraintDensity,
type DifficultyAssessment,
type GeneratedVariantBatch,
type GeneratedVariantPuzzle,
type GenerateVariantBatchOptions,
type GenerationDifficultyTarget,
type GeneratorVariant,
type GenerateVariantOptions,
@@ -31,13 +36,21 @@ export function GeneratorWorkspace({
busy,
assessment,
generation,
batch,
onGenerate,
onGenerateBatch,
onSelectGenerated,
onCancel,
onRate,
}: {
busy: boolean;
assessment?: DifficultyAssessment;
generation?: GeneratedVariantPuzzle;
batch?: GeneratedVariantBatch;
onGenerate: (options: GenerateVariantOptions) => void;
onGenerateBatch?: (options: GenerateVariantBatchOptions) => void;
onSelectGenerated?: (generation: GeneratedVariantPuzzle) => void;
onCancel?: () => void;
onRate: () => void;
}) {
const [variant, setVariant] = useState<GeneratorVariant>("classic");
@@ -48,35 +61,107 @@ export function GeneratorWorkspace({
const [size, setSize] = useState(9);
const [targetDifficulty, setTargetDifficulty] =
useState<GenerationDifficultyTarget>("medium");
const [symmetry, setSymmetry] = useState<"none" | "rotational">("rotational");
const [additionalVariants, setAdditionalVariants] = useState<
GeneratorVariant[]
>([]);
const [symmetry, setSymmetry] = useState<ClueSymmetry>("rotational");
const [constraintCount, setConstraintCount] = useState(8);
const [constraintDensity, setConstraintDensity] =
useState<ConstraintDensity>("balanced");
const [minimalGivens, setMinimalGivens] = useState(false);
const [batchSize, setBatchSize] = useState(1);
const [batchRanking, setBatchRanking] = useState<BatchRanking>("difficulty");
const [seed, setSeed] = useState("");
const [requiredTechnique, setRequiredTechnique] = useState<
PracticeTechnique | ""
>("");
const [maxTechniqueAttempts, setMaxTechniqueAttempts] = useState(10);
const usesMarkingCount = ![
"classic",
"diagonal",
"killer",
"anti-knight",
"anti-king",
"non-consecutive",
].includes(variant);
const [minimumTechniqueCount, setMinimumTechniqueCount] = useState(1);
const [maximumTechniqueCount, setMaximumTechniqueCount] = useState("");
const [forbiddenTechnique, setForbiddenTechnique] = useState<
PracticeTechnique | ""
>("");
const [hardestTechnique, setHardestTechnique] = useState<
PracticeTechnique | ""
>("");
const selectedFamilies = [variant, ...additionalVariants];
const usesMarkingCount = selectedFamilies.some(
(family) =>
![
"classic",
"diagonal",
"killer",
"anti-knight",
"anti-king",
"non-consecutive",
].includes(family),
);
const usesConstraintDensity = selectedFamilies.some(
(family) =>
family === "killer" ||
![
"classic",
"diagonal",
"anti-knight",
"anti-king",
"non-consecutive",
].includes(family),
);
const hasTechniqueProfile =
requiredTechnique !== "" ||
forbiddenTechnique !== "" ||
hardestTechnique !== "" ||
maximumTechniqueCount !== "" ||
minimumTechniqueCount !== 1;
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
onGenerate({
const profileCounts =
requiredTechnique !== "" &&
(minimumTechniqueCount !== 1 || maximumTechniqueCount !== "")
? [
{
technique: requiredTechnique,
min: minimumTechniqueCount,
...(maximumTechniqueCount === ""
? {}
: { max: Number(maximumTechniqueCount) }),
},
]
: [];
const options: GenerateVariantBatchOptions = {
variant,
...(additionalVariants.length === 0
? {}
: { variants: selectedFamilies }),
size,
targetDifficulty,
symmetry,
...(usesMarkingCount ? { constraintCount } : {}),
...(requiredTechnique === ""
? {}
: { requiredTechnique, maxTechniqueAttempts }),
...(usesConstraintDensity && constraintDensity !== "balanced"
? { constraintDensity }
: {}),
...(minimalGivens ? { minimalGivens: true } : {}),
...(requiredTechnique === "" ? {} : { requiredTechnique }),
...(hasTechniqueProfile
? {
techniqueProfile: {
...(forbiddenTechnique === ""
? {}
: { forbidden: [forbiddenTechnique] }),
...(profileCounts.length === 0 ? {} : { counts: profileCounts }),
...(hardestTechnique === "" ? {} : { hardestTechnique }),
},
maxTechniqueAttempts,
}
: {}),
seed: seed.trim() || `local-${Date.now().toString(36)}`,
});
};
if (batchSize > 1 && onGenerateBatch !== undefined) {
onGenerateBatch({ ...options, batchSize, ranking: batchRanking });
} else {
onGenerate(options);
}
};
return (
@@ -103,6 +188,16 @@ export function GeneratorWorkspace({
(item) => item.id === next,
)!;
setVariant(next);
setAdditionalVariants((current) =>
current.filter(
(family) =>
family !== next &&
(
GENERATOR_VARIANTS.find(({ id }) => id === family)
?.supportedSizes as readonly number[]
).includes(size),
),
);
if (
!(
nextDefinition.supportedSizes as readonly number[]
@@ -128,7 +223,18 @@ export function GeneratorWorkspace({
Grid
<select
value={size}
onChange={(event) => setSize(Number(event.target.value))}
onChange={(event) => {
const nextSize = Number(event.target.value);
setSize(nextSize);
setAdditionalVariants((current) =>
current.filter((family) =>
(
GENERATOR_VARIANTS.find(({ id }) => id === family)
?.supportedSizes as readonly number[]
).includes(nextSize),
),
);
}}
>
{definition.supportedSizes.map((supportedSize) => (
<option key={supportedSize} value={supportedSize}>
@@ -137,6 +243,35 @@ export function GeneratorWorkspace({
))}
</select>
</label>
<fieldset className="generator-family-picker">
<legend>Mix in variant families</legend>
<div className="generator-family-options">
{GENERATOR_VARIANTS.filter(({ id }) => id !== variant).map(
(item) => {
const supported = (
item.supportedSizes as readonly number[]
).includes(size);
return (
<label key={item.id}>
<input
type="checkbox"
checked={additionalVariants.includes(item.id)}
disabled={!supported}
onChange={(event) =>
setAdditionalVariants((current) =>
event.target.checked
? [...current, item.id]
: current.filter((id) => id !== item.id),
)
}
/>
{item.label}
</label>
);
},
)}
</div>
</fieldset>
<label>
Requested profile
<select
@@ -159,10 +294,15 @@ export function GeneratorWorkspace({
<select
value={symmetry}
onChange={(event) =>
setSymmetry(event.target.value as "none" | "rotational")
setSymmetry(event.target.value as ClueSymmetry)
}
>
<option value="rotational">Rotational</option>
<option value="horizontal">Horizontal reflection</option>
<option value="vertical">Vertical reflection</option>
<option value="diagonal-main">Main diagonal</option>
<option value="diagonal-anti">Anti-diagonal</option>
<option value="orthogonal">Four-way orthogonal</option>
<option value="none">None</option>
</select>
</label>
@@ -180,6 +320,54 @@ export function GeneratorWorkspace({
/>
</label>
)}
{usesConstraintDensity && (
<label>
Constraint density
<select
value={constraintDensity}
onChange={(event) =>
setConstraintDensity(event.target.value as ConstraintDensity)
}
>
<option value="sparse">Sparse</option>
<option value="balanced">Balanced</option>
<option value="dense">Dense</option>
</select>
</label>
)}
<label className="checkbox-field">
<input
type="checkbox"
checked={minimalGivens}
onChange={(event) => setMinimalGivens(event.target.checked)}
/>
Prove minimal givens
</label>
<label>
Batch size
<input
type="number"
min="1"
max="12"
value={batchSize}
onChange={(event) => setBatchSize(Number(event.target.value))}
/>
</label>
{batchSize > 1 && (
<label>
Batch ranking
<select
value={batchRanking}
onChange={(event) =>
setBatchRanking(event.target.value as BatchRanking)
}
>
<option value="difficulty">Difficulty</option>
<option value="fewest-givens">Fewest givens</option>
<option value="most-givens">Most givens</option>
</select>
</label>
)}
<label>
Seed
<input
@@ -202,7 +390,8 @@ export function GeneratorWorkspace({
<option value="">No required technique</option>
{PRACTICE_TECHNIQUES.filter(
(technique) =>
technique !== "killer-cage" || variant === "killer",
technique !== "killer-cage" ||
selectedFamilies.includes("killer"),
).map((technique) => (
<option key={technique} value={technique}>
{techniqueLabel(technique)}
@@ -211,6 +400,75 @@ export function GeneratorWorkspace({
</select>
</label>
{requiredTechnique !== "" && (
<>
<label>
Minimum occurrences
<input
type="number"
min="1"
max="10000"
value={minimumTechniqueCount}
onChange={(event) =>
setMinimumTechniqueCount(Number(event.target.value))
}
/>
</label>
<label>
Maximum occurrences
<input
type="number"
min="0"
max="10000"
value={maximumTechniqueCount}
placeholder="no maximum"
onChange={(event) =>
setMaximumTechniqueCount(event.target.value)
}
/>
</label>
</>
)}
<label>
Forbidden technique
<select
value={forbiddenTechnique}
onChange={(event) =>
setForbiddenTechnique(
event.target.value as PracticeTechnique | "",
)
}
>
<option value="">None</option>
{PRACTICE_TECHNIQUES.map((technique) => (
<option key={technique} value={technique}>
{techniqueLabel(technique)}
</option>
))}
</select>
</label>
<label>
Hardest technique target
<select
value={hardestTechnique}
onChange={(event) =>
setHardestTechnique(
event.target.value as PracticeTechnique | "",
)
}
>
<option value="">Any</option>
{PRACTICE_TECHNIQUES.filter(
(technique) =>
technique !== "killer-cage" ||
selectedFamilies.includes("killer"),
).map((technique) => (
<option key={technique} value={technique}>
{techniqueLabel(technique)}
</option>
))}
</select>
</label>
{hasTechniqueProfile && (
<label>
Mining attempts
<input
@@ -228,8 +486,17 @@ export function GeneratorWorkspace({
<p className="generator-description">{definition.description}</p>
<div className="action-row">
<button className="primary-button" type="submit" disabled={busy}>
{busy ? "Working locally…" : `Generate ${definition.label}`}
{busy
? "Working locally…"
: batchSize > 1
? `Generate ${String(batchSize)}-puzzle batch`
: `Generate ${definition.label}`}
</button>
{busy && onCancel !== undefined && (
<button type="button" onClick={onCancel}>
Cancel generation
</button>
)}
<button type="button" disabled={busy} onClick={onRate}>
Rate current puzzle
</button>
@@ -237,8 +504,8 @@ export function GeneratorWorkspace({
<p className="muted">
Difficulty is an estimate from reproducible solver evidence, not a
universal promise. Technique practice only returns a puzzle whose
logical path contains the requested move. Uniqueness is never claimed
after a safety limit.
complete independently analysed path matches every requested rule.
Uniqueness and minimality are never claimed after a safety limit.
</p>
</form>
@@ -291,6 +558,16 @@ export function GeneratorWorkspace({
</strong>
</span>
)}
{generation && (
<span>
Minimality <strong>{generation.minimality.status}</strong>
</span>
)}
{generation?.techniqueProfile && (
<span>
Profile <strong>{generation.techniqueProfile.status}</strong>
</span>
)}
</div>
<p>{assessment.summary}</p>
{generation && (
@@ -298,8 +575,139 @@ export function GeneratorWorkspace({
Seed: <code>{String(generation.seed)}</code> · Found in{" "}
{generation.generationAttempts} attempt
{generation.generationAttempts === 1 ? "" : "s"}.
{generation.families.length > 1
? ` Families: ${generation.families.join(", ")}.`
: ""}
</p>
)}
{generation?.minimality.status === "proven-minimal" && (
<p className="muted">
Minimal-givens proof completed in{" "}
{generation.minimality.checksPerformed} bounded checks; every
remaining given is critical.
{!generation.minimality.symmetryPreserved
? " Individual minimization changed the requested clue symmetry."
: ""}
</p>
)}
{generation?.minimality.status === "unknown" && (
<p className="muted">
Minimality remains unknown after{" "}
{generation.minimality.checksPerformed} bounded checks. Unresolved
givens: {generation.minimality.unknownCells.length}. Reasons:{" "}
{generation.minimality.limitReasons
.map((reason) => reason.replaceAll("-", " "))
.join(", ") || "incomplete evidence"}
.
</p>
)}
{generation?.techniqueProfile && (
<p className="muted">
Independent logical path:{" "}
{generation.techniqueProfile.completePath
? "complete"
: "incomplete"}
{generation.techniqueProfile.requirements.length === 0
? ""
: ` · ${generation.techniqueProfile.requirements
.map(
({ technique, actual, minimum, maximum }) =>
`${techniqueLabel(technique)} ${String(actual)}${minimum === undefined ? "" : `${String(minimum)}`}${maximum === undefined ? "" : `${String(maximum)}`}`,
)
.join(" · ")}`}
{generation.techniqueProfile.requestedHardestTechnique ===
undefined
? ""
: ` · Hardest: ${techniqueLabel(generation.techniqueProfile.actualHardestTechnique)} (target ${techniqueLabel(generation.techniqueProfile.requestedHardestTechnique)})`}
.
</p>
)}
</section>
)}
{batch && (
<section className="panel-section generator-batch" aria-live="polite">
<div className="section-heading">
<div>
<p className="eyebrow">Ranked local batch</p>
<h3>
{batch.completed} of {batch.requested} generated
</h3>
</div>
<span className="status-pill">
{batch.ranking.replaceAll("-", " ")}
</span>
</div>
{batch.summaries.length === 0 ? (
<p>No candidate passed the bounded checks.</p>
) : (
<div className="setter-quality__table-wrap">
<table className="setter-quality__table">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Seed</th>
<th scope="col">Families</th>
<th scope="col">Givens</th>
<th scope="col">Score</th>
<th scope="col">Evidence</th>
<th scope="col">Open</th>
</tr>
</thead>
<tbody>
{batch.summaries.map((summary, index) => {
const entry = batch.entries[index];
return (
<tr key={String(summary.seed)}>
<th scope="row">{summary.rank}</th>
<td>
<code>{String(summary.seed)}</code>
</td>
<td>{summary.families.join(" + ")}</td>
<td>{summary.clueCount}</td>
<td>{summary.score ?? "—"}</td>
<td>
{summary.minimalityStatus}
{summary.profileStatus
? ` · ${summary.profileStatus}`
: ""}
</td>
<td>
<button
type="button"
disabled={entry === undefined}
onClick={() => {
if (entry !== undefined) {
onSelectGenerated?.(entry);
}
}}
>
Use
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{batch.failures.length > 0 && (
<details>
<summary>
{batch.failures.length} bounded candidate
{batch.failures.length === 1 ? "" : "s"} did not complete or
match the profile
</summary>
<ul className="muted">
{batch.failures.map((failure) => (
<li key={String(failure.seed)}>
<code>{String(failure.seed)}</code>: {failure.message}
</li>
))}
</ul>
</details>
)}
</section>
)}
</div>
+273
View File
@@ -0,0 +1,273 @@
import type { LogicalStep } from "../solver";
import {
GUIDED_HINT_STAGES,
guidedHintEffectItems,
guidedHintFocusSummary,
isGuidedHintStageRevealed,
logicalTechniqueDescription,
logicalTechniqueName,
nextGuidedHintStage,
type GuidedHintStage,
} from "./guidedHint";
const STAGE_LABELS: Record<GuidedHintStage, string> = {
focus: "Where to look",
technique: "Technique",
reasoning: "Reasoning",
preview: "Effects preview",
};
function revealButtonLabel(stage: GuidedHintStage): string | undefined {
const next = nextGuidedHintStage(stage);
if (next === "technique") return "Reveal technique";
if (next === "reasoning") return "Reveal reasoning";
if (next === "preview") return "Preview effects";
return undefined;
}
function stageStatus(stage: GuidedHintStage): string {
if (stage === "focus") return "Hint ready: where to look.";
if (stage === "technique") return "Technique revealed.";
if (stage === "reasoning") return "Reasoning revealed.";
return "Effects ready to preview and apply.";
}
export interface GuidedHintProps {
readonly size: number;
readonly step?: LogicalStep;
readonly stage: GuidedHintStage;
readonly busy?: boolean;
readonly error?: string;
readonly candidateTrackingActive?: boolean;
readonly autoMaintainPeerNotes: boolean;
readonly onRequestHint: () => void;
readonly onRevealNext: () => void;
readonly onApply: () => void;
readonly onDismiss: () => void;
readonly onFillLegalCandidates: () => void;
readonly onRemoveInvalidNotes: () => void;
readonly onAutoMaintainPeerNotesChange: (enabled: boolean) => void;
}
/**
* A controlled, progressively disclosed hint. The caller owns the hint step
* and stage so it can keep the board overlay and undo history in sync.
*/
export function GuidedHint({
size,
step,
stage,
busy = false,
error,
candidateTrackingActive = false,
autoMaintainPeerNotes,
onRequestHint,
onRevealNext,
onApply,
onDismiss,
onFillLegalCandidates,
onRemoveInvalidNotes,
onAutoMaintainPeerNotesChange,
}: GuidedHintProps) {
const nextLabel = step === undefined ? undefined : revealButtonLabel(stage);
const effectItems =
step !== undefined && stage === "preview"
? guidedHintEffectItems(step, size)
: [];
return (
<section className="guided-hint stack" aria-labelledby="guided-hint-title">
<div className="guided-hint__heading">
<div>
<p className="eyebrow">Guided solving</p>
<h3 id="guided-hint-title">One clue at a time</h3>
</div>
{step !== undefined && (
<button
type="button"
className="guided-hint__dismiss"
onClick={onDismiss}
disabled={busy}
>
Dismiss
</button>
)}
</div>
<p className="guided-hint__intro muted">
Reveal only as much help as you want. Nothing changes until you apply
the fully previewed step.
</p>
<p
className="guided-hint__status status-line"
role="status"
aria-live="polite"
aria-atomic="true"
>
{busy
? "Finding a logical next step locally…"
: error !== undefined
? "The hint could not be prepared."
: step === undefined
? "No guided hint is open."
: stageStatus(stage)}
</p>
{error !== undefined && (
<p className="guided-hint__error error-callout" role="alert">
{error}
</p>
)}
{step !== undefined && (
<>
<ol
className="guided-hint__progress"
aria-label="Hint reveal progress"
>
{GUIDED_HINT_STAGES.map((item) => {
const revealed = isGuidedHintStageRevealed(stage, item);
return (
<li
key={item}
className={revealed ? "is-revealed" : "is-concealed"}
aria-current={item === stage ? "step" : undefined}
>
{STAGE_LABELS[item]}
</li>
);
})}
</ol>
<div className="guided-hint__stage">
<section
className="guided-hint__focus"
aria-labelledby="hint-focus-title"
>
<p className="eyebrow" id="hint-focus-title">
Where to look
</p>
<p>{guidedHintFocusSummary(step, size)}</p>
</section>
{isGuidedHintStageRevealed(stage, "technique") && (
<section
className="guided-hint__technique"
aria-labelledby="hint-technique-title"
>
<p className="eyebrow" id="hint-technique-title">
Technique
</p>
<p>
<strong>{logicalTechniqueName(step.technique)}</strong>
</p>
<p className="muted">
{logicalTechniqueDescription(step.technique)}
</p>
</section>
)}
{isGuidedHintStageRevealed(stage, "reasoning") && (
<section
className="guided-hint__reasoning"
aria-labelledby="hint-reasoning-title"
>
<p className="eyebrow" id="hint-reasoning-title">
Why it works
</p>
<p>{step.explanation}</p>
</section>
)}
{stage === "preview" && (
<section
className="guided-hint__preview"
aria-labelledby="hint-preview-title"
>
<p className="eyebrow" id="hint-preview-title">
Effects preview
</p>
{effectItems.length > 0 ? (
<ul className="guided-hint__effect-list">
{effectItems.map((effect, index) => (
<li key={`${String(index)}-${effect}`}>{effect}</li>
))}
</ul>
) : (
<p>This deduction does not change the board.</p>
)}
{step.eliminations.length > 0 && !candidateTrackingActive && (
<p className="guided-hint__tracking-note muted">
Applying this elimination will start a complete legal
centre-candidate grid, then remove the previewed candidates.
</p>
)}
</section>
)}
</div>
<div className="guided-hint__actions action-row">
{nextLabel !== undefined && (
<button type="button" onClick={onRevealNext} disabled={busy}>
{nextLabel}
</button>
)}
{stage === "preview" && (
<button
type="button"
className="guided-hint__apply"
onClick={onApply}
disabled={busy || effectItems.length === 0}
>
Apply this step
</button>
)}
<button type="button" onClick={onRequestHint} disabled={busy}>
New hint
</button>
</div>
</>
)}
{step === undefined && (
<div className="guided-hint__actions action-row">
<button type="button" onClick={onRequestHint} disabled={busy}>
Get a guided hint
</button>
</div>
)}
<fieldset className="guided-hint__maintenance">
<legend>Candidate maintenance</legend>
<p className="muted" id="guided-candidate-status">
{candidateTrackingActive
? "The guided candidate grid is active."
: "Candidate tracking is currently inactive."}
</p>
<div
className="guided-hint__maintenance-actions action-row"
aria-describedby="guided-candidate-status"
>
<button type="button" onClick={onFillLegalCandidates} disabled={busy}>
Fill legal candidates
</button>
<button type="button" onClick={onRemoveInvalidNotes} disabled={busy}>
Remove invalid notes
</button>
</div>
<label className="guided-hint__auto-maintain check-row">
<input
type="checkbox"
checked={autoMaintainPeerNotes}
disabled={busy}
onChange={(event) =>
onAutoMaintainPeerNotesChange(event.currentTarget.checked)
}
/>
Automatically remove peer notes after placing a digit
</label>
</fieldset>
</section>
);
}
+134 -36
View File
@@ -14,12 +14,13 @@ export function HelpDialog({
<h3>Five complementary workspaces</h3>
<p>
<strong>Play</strong> keeps values, two kinds of notes, colours,
branches, replay and elapsed time. <strong>Set</strong> edits clues
and constraints. <strong>Generate</strong> constructs and rates
bounded, seedable variants. <strong>Solve</strong> explains logical
steps and can verify uniqueness. <strong>Helpers</strong> answers
focused sum, candidate and relation questions without changing the
board.
branches, replay, guided hints and elapsed time.{" "}
<strong>Set</strong> edits registry-backed clues and runs bounded
setter-quality checks. <strong>Generate</strong> constructs and
ranks bounded, seedable single or batch variants.{" "}
<strong>Solve</strong> explains logical steps and can verify
uniqueness. <strong>Helpers</strong> answers focused sum, candidate
and relation questions without changing the board.
</p>
</section>
<section>
@@ -65,16 +66,63 @@ export function HelpDialog({
<dt>Ctrl/ + click</dt>
<dd>Highlight every placed copy of that digit</dd>
</div>
<div>
<dt>M</dt>
<dd>Toggle tap-by-tap multi-selection</dd>
</div>
<div>
<dt>Ctrl/ + + / </dt>
<dd>Zoom the board while focus is in the board area</dd>
</div>
<div>
<dt>Ctrl/ + 0</dt>
<dd>Fit the board at its default scale</dd>
</div>
</dl>
</section>
<section>
<h3>Touch, zoom and selection</h3>
<p>
Board zoom is stored in this browser. Use <strong>Pan board</strong>
before dragging a zoomed board; its strong border and status message
indicate that cell taps are temporarily paused. Stop panning to edit
again. <strong>Tap multi-select</strong> toggles individual cells
without a drag gesture and keeps one active cell for keyboard entry.
On narrow screens the entry pad stays close to the bottom edge and
respects the device safe area.
</p>
</section>
<section>
<h3>Hints and solutions</h3>
<p>
Candidate legality is computed independently from handwritten notes.
Logical deductions report their premises, affected houses,
placements and eliminations. Exact search is separately labelled; it
proves feasibility or uniqueness but is not presented as a human
explanation.
A guided hint reveals where to look, the technique, its reasoning
and an effects preview in separate stages; the board changes only
after <strong>Apply this step</strong>. Candidate legality is
computed independently from handwritten notes. Controls can fill
legal centre candidates, prune invalid centre/corner notes and
optionally maintain peer notes after placements. Erasing a value
never invents candidates.
</p>
<p>
Logical deductions report premises, affected houses, placements and
eliminations for singles, subsets, intersections, fish, wings,
colouring, chains and Killer cages. Unique Rectangle is disabled
unless a completed exact search has already proved uniqueness. Exact
search is separately labelled and is not presented as a human
explanation; reaching a limit remains unknown.
</p>
</section>
<section>
<h3>Setter-quality checks</h3>
<p>
Quick analysis checks for zero, one or two solutions and can show
the cells where two completions differ. Full analysis uses bounded
deletion searches to classify givens and constraints as critical,
redundant or unknown, build a cell heatmap, or localise a
contradictory core. Optional minimality is claimed only when the
unique baseline and every required removal check complete. Per-check
and aggregate budgets are visible, and analysis can be cancelled
without turning a capped search into proof.
</p>
</section>
<section>
@@ -84,9 +132,10 @@ export function HelpDialog({
checkpoint, isolate a hypothesis, or inspect an earlier grid.
Discarding a hypothesis restores its exact starting state but keeps
the abandoned path available in replay. Replayed grids are read-only
until you return live or deliberately branch from that step. This
working history stays in the current browser session; save the
puzzle to the Library for durable puzzle progress.
until you return live or deliberately branch from that step.
Validated history is included in local autosaves and explicit
Library saves, so restoring that progress also restores its
savepoints and branches.
</p>
</section>
<section>
@@ -115,25 +164,44 @@ export function HelpDialog({
<section>
<h3>Variant and false clues</h3>
<p>
The setter supports cages, lines, pair clues, X-sums, skyscrapers,
quadruples and maximum cells. Enable Wrogn mode to make new local
clues false, or switch existing clues individually or as a batch.
Red dashed artwork and a mark identify false clues; Σ and
identify X-sum and skyscraper readings. A false multi-cell clue
often stays undecided until enough of its cells are known, so exact
searches for dense liar puzzles can be substantially slower.
The shared registry covers classic/irregular/extra regions;
diagonal, anti, disjoint and non-consecutive rules; cages, parity,
extrema and quadruples; thermo, arrow, renban, palindrome, between,
whisper, region-sum, modular, entropic, zipper and double-arrow
lines; Kropki, 5/10 and inequality pairs; X-sum, skyscraper,
little-killer and sandwich clues; clone regions and row/column/box
indexers.
</p>
<p>
Enable Wrogn mode to make a supported local clue false, or switch
existing clues individually or as a batch. Red dashed artwork and a
mark identify false clues; global house rules, extra regions and
fog are not given a misleading false mode. Fog is display-only and
can be set only with a complete solution which validates against the
current puzzle. Wrong entries reveal nothing and the trusted
solution remains in the local puzzle document.
</p>
</section>
<section>
<h3>Generation and ratings</h3>
<p>
Generation runs in a worker with explicit time and search limits. A
requested level guides clue removal; the reported rating is then
calculated independently from logical techniques, clue load and
bounded exact-search evidence. Practice mode deterministically mines
several candidates and succeeds only when the analysed solve path
contains the requested technique. A limit never becomes a false
uniqueness claim.
Generation runs in a worker with explicit time and search limits. It
supports single or deterministic batch generation, compatible mixed
families, sparse/balanced/dense local constraints and several
rotational, reflection and diagonal clue symmetries. Minimal-givens
mode reports proof or the exact unknown reason; individual
minimisation can break the requested visual symmetry and reports
that fact.
</p>
<p>
Technique profiles can require, forbid or count techniques and set
an exact hardest technique. A result is accepted only when an
independent complete logical path matches the whole profile and an
independent exact search proves uniqueness. Requested difficulty
guides clue removal; the reported 0100 rating is then calculated
from logical techniques, clue load and exact-search evidence. A
cancelled or bounded-out candidate remains a failure or unknown,
never a uniqueness/minimality claim.
</p>
</section>
<section>
@@ -142,19 +210,49 @@ export function HelpDialog({
Files, text, solving and generation stay in this browser. Compact
grids, project JSON, share fragments, supported f-puzzles,
SudokuPad/CTC inline data and supported Penpa+ long links are
decoded locally. Server short IDs are intentionally rejected. SVG,
PNG and PDF rendering also stays in the browser. Review an export
before sharing: titles, authors, rules, solutions, progress and
aid-mémoire entries may be included.
decoded locally. Server short IDs are intentionally rejected. The
compatibility check separates mapped Sudoku semantics, preserved
inert drawings, retained metadata and warnings before import. Source
identity and allowlisted drawings survive local edits and saves but
never become rules by appearance alone.
</p>
<p>
Export includes Sudoku Tools JSON/share data, f-puzzles, SudokuPad
JSON or self-contained SCL where representable, plus local SVG, PNG
and PDF rendering. Unsupported false/global semantics and visual
geometry are rejected rather than silently weakened. Review an
export before sharing: titles, authors, rules, solutions, progress,
source metadata, drawings and aid-mémoire entries may be included.
</p>
</section>
<section>
<h3>Recovery, Library and offline use</h3>
<p>
A separate debounced local autosave can restore or discard an
interrupted session, including validated bounded history, branches
and savepoints. Explicit Library projects offer title/tag search,
completion filters, safe previews and selected export, duplication
or deletion. Browser storage can be cleared independently, so export
important projects.
</p>
<p>
On a production HTTPS host, the first successful load can install a
relative-scope offline application shell. Service-worker support is
progressive enhancement: puzzle work stays local and remains usable
when registration is unavailable.
</p>
</section>
<section>
<h3>Screen-reader detail</h3>
<p>
Each Sudoku cell reports its row, column, region, value or notes,
colour, conflict state, candidate highlights and touching variant
clues. The board and aid-mémoire use real row and gridcell roles;
their keyboard instructions are attached to the grids.
Each visible Sudoku cell reports its row, column, region, value,
colour-and-pattern mark, conflict state and touching variant clues.
Candidate detail can be set to <strong>Off</strong>, concise counts,
or detailed digits without changing what is drawn. The board and
aid-mémoire use real row and gridcell roles; their keyboard
instructions are attached to the grids. Fogged cells report only
that they are obscured: hidden values, notes, clues, candidate
overlays and guided-hint steps are not exposed through board labels.
</p>
</section>
</div>
+141 -15
View File
@@ -3,8 +3,11 @@ import type { PuzzleDefinition } from "../domain/types";
import { normalizePuzzle } from "../domain/validation";
import {
encodePuzzleHash,
exportSudokuPadJson,
exportSudokuPadPayload,
exportFpuzzlesJson,
exportFpuzzlesUrl,
extractPreservedDocumentExtras,
fromDomainPuzzle,
importPuzzle,
renderPuzzlePdf,
@@ -13,6 +16,9 @@ import {
serializePlainGrid,
serializeSudokuDocument,
toDomainPuzzle,
type PreservedSudokuDocumentExtras,
type PuzzleImportMappingPreview,
type PuzzleImportResult,
type SudokuDocument,
} from "../formats";
import type { PlaySession } from "../state/session";
@@ -24,8 +30,9 @@ function withProgress(
puzzle: PuzzleDefinition,
session: PlaySession,
aidMemoire?: PortableAidMemoire,
preservedExtras?: PreservedSudokuDocumentExtras,
): SudokuDocument {
const base = fromDomainPuzzle(puzzle);
const base = fromDomainPuzzle(puzzle, preservedExtras);
return {
...base,
values: [...session.values],
@@ -64,11 +71,13 @@ function checkedPuzzle(document: SudokuDocument): PuzzleDefinition {
return normalizePuzzle(toDomainPuzzle(document) as PuzzleDefinition);
}
interface ImportExportDialogProps {
export interface ImportExportDialogProps {
open: boolean;
puzzle: PuzzleDefinition;
session: PlaySession;
aidMemoire?: PortableAidMemoire;
/** Source-only data retained while the domain puzzle is edited. */
preservedExtras?: PreservedSudokuDocumentExtras;
onClose: () => void;
onImport: (
puzzle: PuzzleDefinition,
@@ -82,14 +91,41 @@ interface ImportExportDialogProps {
| "elapsedMs"
| "aidMemoire"
>,
preservedExtras?: PreservedSudokuDocumentExtras,
) => void;
}
function PreviewList({
title,
entries,
}: {
readonly title: string;
readonly entries: PuzzleImportMappingPreview["mappedSemantics"];
}) {
return (
<div>
<h4>{title}</h4>
{entries.length === 0 ? (
<p className="muted">None.</p>
) : (
<ul>
{entries.map((entry) => (
<li key={entry.key}>
{entry.label}: {entry.count}
</li>
))}
</ul>
)}
</div>
);
}
export function ImportExportDialog({
open,
puzzle,
session,
aidMemoire,
preservedExtras,
onClose,
onImport,
}: ImportExportDialogProps) {
@@ -97,13 +133,17 @@ export function ImportExportDialog({
const [feedback, setFeedback] = useState("");
const [includeProgress, setIncludeProgress] = useState(true);
const [busy, setBusy] = useState(false);
const [inspected, setInspected] = useState<{
readonly input: string;
readonly result: PuzzleImportResult<SudokuDocument>;
}>();
const fileRef = useRef<HTMLInputElement>(null);
const documentValue = useMemo(
() =>
includeProgress
? withProgress(puzzle, session, aidMemoire)
: fromDomainPuzzle(puzzle),
[aidMemoire, includeProgress, puzzle, session],
? withProgress(puzzle, session, aidMemoire, preservedExtras)
: fromDomainPuzzle(puzzle, preservedExtras),
[aidMemoire, includeProgress, preservedExtras, puzzle, session],
);
const copy = async (value: string, label: string) => {
@@ -147,6 +187,7 @@ export function ImportExportDialog({
try {
const result = await importPuzzle(input);
checkedPuzzle(result.document);
setInspected({ input, result });
const givenCount = result.document.givens.filter(
(value) => value !== 0,
).length;
@@ -154,6 +195,7 @@ export function ImportExportDialog({
`${result.label}: ${String(result.document.size)}×${String(result.document.size)}, ${String(givenCount)} givens and ${String(result.document.constraints.length)} constraints. Compatible and ready to import.`,
);
} catch (error) {
setInspected(undefined);
setFeedback(errorMessage(error, "The puzzle could not be inspected."));
} finally {
setBusy(false);
@@ -163,17 +205,24 @@ export function ImportExportDialog({
const applyImport = async () => {
setBusy(true);
try {
const result = await importPuzzle(input);
const result =
inspected?.input === input
? inspected.result
: await importPuzzle(input);
const parsed = result.document;
onImport(checkedPuzzle(parsed), {
values: parsed.values,
cornerMarks: parsed.cornerMarks,
centerMarks: parsed.centerMarks,
candidates: parsed.candidates,
colors: parsed.colors,
elapsedMs: parsed.elapsedMs,
aidMemoire: parsed.aidMemoire,
});
onImport(
checkedPuzzle(parsed),
{
values: parsed.values,
cornerMarks: parsed.cornerMarks,
centerMarks: parsed.centerMarks,
candidates: parsed.candidates,
colors: parsed.colors,
elapsedMs: parsed.elapsedMs,
aidMemoire: parsed.aidMemoire,
},
extractPreservedDocumentExtras(parsed),
);
setFeedback(`${result.label} imported locally.`);
onClose();
} catch (error) {
@@ -239,6 +288,7 @@ export function ImportExportDialog({
placeholder="Paste 81 characters, JSON or a puzzle URL…"
onChange={(event) => {
setInput(event.target.value);
setInspected(undefined);
setFeedback("");
}}
/>
@@ -263,6 +313,7 @@ export function ImportExportDialog({
.text()
.then((contents) => {
setInput(contents);
setInspected(undefined);
setFeedback(
`${file.name} loaded locally; review and import it.`,
);
@@ -287,6 +338,41 @@ export function ImportExportDialog({
Import locally
</button>
</div>
{inspected?.input === input && (
<section
className="import-mapping-preview stack"
aria-label="Import mapping preview"
>
<div>
<p className="eyebrow">Mapping preview</p>
<h3>{inspected.result.label}</h3>
</div>
<PreviewList
title="Mapped semantics"
entries={inspected.result.preview.mappedSemantics}
/>
<PreviewList
title="Preserved visuals"
entries={inspected.result.preview.preservedVisuals}
/>
<PreviewList
title="Preserved metadata"
entries={inspected.result.preview.preservedMetadata}
/>
<div>
<h4>Warnings</h4>
{inspected.result.preview.warnings.length === 0 ? (
<p className="muted">None.</p>
) : (
<ul>
{inspected.result.preview.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
)}
</div>
</section>
)}
</section>
<section className="stack">
@@ -357,6 +443,46 @@ export function ImportExportDialog({
>
Copy f-puzzles URL
</button>
<button
type="button"
disabled={busy}
onClick={() =>
exportText(
"sudoku.scl.json",
"application/json",
"SudokuPad JSON",
() => exportSudokuPadJson(documentValue, true),
)
}
>
Download SudokuPad JSON
</button>
<button
type="button"
disabled={busy}
onClick={() =>
exportText(
"sudoku.scl",
"text/plain",
"SudokuPad SCL payload",
() => exportSudokuPadPayload(documentValue),
)
}
>
Download SudokuPad SCL payload
</button>
<button
type="button"
disabled={busy}
onClick={() =>
void copyExport(
() => exportSudokuPadPayload(documentValue),
"SudokuPad SCL payload",
)
}
>
Copy SudokuPad SCL payload
</button>
<button
type="button"
disabled={busy}
+263 -14
View File
@@ -1,4 +1,4 @@
import { useRef } from "react";
import { useMemo, useRef, useState } from "react";
import type { SudokuProjectSummary } from "../storage";
import { Modal } from "./Modal";
@@ -9,6 +9,26 @@ function date(value: number) {
}).format(new Date(value));
}
function LibraryThumbnail({ item }: { item: SudokuProjectSummary }) {
return (
<span
className="library-thumbnail"
style={{
gridTemplateColumns: `repeat(${String(item.size)}, 1fr)`,
fontSize: `${String(Math.max(0.18, Math.min(0.62, 2.7 / item.size)))}rem`,
}}
role="img"
aria-label={`${String(item.size)} by ${String(item.size)} puzzle preview`}
>
{[...item.thumbnail].map((symbol, index) => (
<span key={index} aria-hidden="true">
{symbol === "." ? "" : symbol}
</span>
))}
</span>
);
}
export function LibraryDialog({
open,
summaries,
@@ -21,6 +41,10 @@ export function LibraryDialog({
onDelete,
onClear,
onExport,
onExportSelected,
onDuplicateSelected,
onDeleteSelected,
onUpdateTags,
onImport,
}: {
open: boolean;
@@ -34,16 +58,73 @@ export function LibraryDialog({
onDelete: (id: string) => void;
onClear: () => void;
onExport: () => void;
onExportSelected: (ids: readonly string[]) => void;
onDuplicateSelected: (ids: readonly string[]) => void;
onDeleteSelected: (ids: readonly string[]) => void;
onUpdateTags: (id: string, tags: readonly string[]) => void;
onImport: (file: File) => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState("");
const [completion, setCompletion] = useState<"all" | "open" | "complete">(
"all",
);
const [tag, setTag] = useState("");
const [selected, setSelected] = useState<ReadonlySet<string>>(
() => new Set(),
);
const [editingTagsFor, setEditingTagsFor] = useState<string>();
const [tagDraft, setTagDraft] = useState("");
const allTags = useMemo(
() =>
[...new Set(summaries.flatMap((item) => item.tags))].sort((a, b) =>
a.localeCompare(b),
),
[summaries],
);
const filtered = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
return summaries.filter((item) => {
if (
query &&
!item.title.toLocaleLowerCase().includes(query) &&
!item.tags.some((value) => value.toLocaleLowerCase().includes(query))
) {
return false;
}
if (tag && !item.tags.includes(tag)) return false;
return (
completion === "all" ||
(completion === "complete" ? item.completed : !item.completed)
);
});
}, [completion, search, summaries, tag]);
const knownIds = useMemo(
() => new Set(summaries.map((item) => item.id)),
[summaries],
);
const selectedIds = useMemo(
() => [...selected].filter((id) => knownIds.has(id)),
[knownIds, selected],
);
const toggleSelected = (id: string) => {
setSelected((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<Modal open={open} title="Local puzzle library" onClose={onClose} wide>
<div className="library-toolbar">
<div>
<p>
{mode === "indexeddb"
? "Saved in this browser profile."
? "Saved in this browser profile. Working changes are autosaved separately."
: "IndexedDB is unavailable; saves last only for this open tab."}
</p>
<p className="muted">
@@ -81,38 +162,206 @@ export function LibraryDialog({
/>
</div>
</div>
<div className="library-filters" aria-label="Filter saved puzzles">
<label>
<span>Search</span>
<input
type="search"
value={search}
placeholder="Title or tag"
onChange={(event) => setSearch(event.target.value)}
/>
</label>
<label>
<span>Status</span>
<select
aria-label="Completion filter"
value={completion}
onChange={(event) =>
setCompletion(event.target.value as typeof completion)
}
>
<option value="all">All progress</option>
<option value="open">In progress</option>
<option value="complete">Complete</option>
</select>
</label>
<label>
<span>Tag</span>
<select value={tag} onChange={(event) => setTag(event.target.value)}>
<option value="">All tags</option>
{allTags.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</label>
</div>
{feedback && (
<p className="status-line" role="status">
{feedback}
</p>
)}
{summaries.length > 0 && (
<div className="library-selection-toolbar">
<span>
{String(selectedIds.length)} selected · {String(filtered.length)}{" "}
shown
</span>
<div className="action-row">
<button
type="button"
className="text-button"
disabled={busy || filtered.length === 0}
onClick={() =>
setSelected(new Set(filtered.map((item) => item.id)))
}
>
Select shown
</button>
<button
type="button"
className="text-button"
disabled={busy || selectedIds.length === 0}
onClick={() => setSelected(new Set())}
>
Clear selection
</button>
<button
type="button"
disabled={busy || selectedIds.length === 0}
onClick={() => onExportSelected(selectedIds)}
>
Export selected
</button>
<button
type="button"
disabled={busy || selectedIds.length === 0}
onClick={() => onDuplicateSelected(selectedIds)}
>
Duplicate selected
</button>
<button
type="button"
className="danger"
disabled={busy || selectedIds.length === 0}
onClick={() => onDeleteSelected(selectedIds)}
>
Delete selected
</button>
</div>
</div>
)}
{summaries.length === 0 ? (
<div className="empty-state">
<h3>No saved puzzles</h3>
<p>Save the current puzzle to build a local library.</p>
</div>
) : filtered.length === 0 ? (
<div className="empty-state">
<h3>No matching puzzles</h3>
<p>Adjust the title, tag or progress filters.</p>
</div>
) : (
<ul className="library-list">
{summaries.map((item) => (
{filtered.map((item) => (
<li key={item.id}>
<label className="library-selector">
<input
type="checkbox"
checked={selected.has(item.id)}
aria-label={`Select ${item.title || "Untitled puzzle"}`}
onChange={() => toggleSelected(item.id)}
/>
</label>
<button
className="library-open"
type="button"
onClick={() => onOpen(item.id)}
>
<strong>{item.title || "Untitled puzzle"}</strong>
<span>
{item.size}×{item.size} · {date(item.updatedAt)}
{item.completed ? " · complete" : ""}
<LibraryThumbnail item={item} />
<span className="library-copy">
<strong>{item.title || "Untitled puzzle"}</strong>
<span>
{item.size}×{item.size} · {date(item.updatedAt)}
{item.completed ? " · complete" : " · in progress"}
</span>
</span>
</button>
<button
className="text-button danger"
type="button"
onClick={() => onDelete(item.id)}
>
Delete
</button>
<div className="library-item-actions">
<div className="library-tags" aria-label="Project tags">
{item.tags.map((value) => (
<button
key={value}
type="button"
className="tag-chip"
onClick={() => setTag(value)}
>
{value}
</button>
))}
</div>
{editingTagsFor === item.id ? (
<form
className="library-tag-editor"
onSubmit={(event) => {
event.preventDefault();
onUpdateTags(
item.id,
tagDraft
.split(",")
.map((value) => value.trim())
.filter(Boolean),
);
setEditingTagsFor(undefined);
}}
>
<label>
<span className="sr-only">Comma-separated tags</span>
<input
aria-label={`Tags for ${item.title || "Untitled puzzle"}`}
value={tagDraft}
maxLength={400}
placeholder="classic, hard"
onChange={(event) => setTagDraft(event.target.value)}
/>
</label>
<button type="submit" disabled={busy}>
Apply
</button>
<button
type="button"
className="text-button"
onClick={() => setEditingTagsFor(undefined)}
>
Cancel
</button>
</form>
) : (
<button
className="text-button"
type="button"
onClick={() => {
setEditingTagsFor(item.id);
setTagDraft(item.tags.join(", "));
}}
>
Edit tags
</button>
)}
<button
className="text-button danger"
type="button"
onClick={() => onDelete(item.id)}
>
Delete
</button>
</div>
</li>
))}
</ul>
+4 -1
View File
@@ -1,5 +1,6 @@
import type { EntryMode } from "../state/session";
import { symbolFor } from "../state/session";
import { colorMarkDescription } from "../state/uiPreferences";
const modes: Array<{ mode: EntryMode; label: string; key: string }> = [
{ mode: "value", label: "Value", key: "Z" },
@@ -56,7 +57,9 @@ export function NumberPad({
{mode === "color" ? (
<>
<span aria-hidden="true" />{" "}
<span className="sr-only">Colour {value}</span>
<span className="sr-only">
Colour {value}: {colorMarkDescription(value)}
</span>
</>
) : (
symbolFor(value, size)
+139
View File
@@ -0,0 +1,139 @@
import type { SVGAttributes } from "react";
import type {
SafeVisualAnchor,
SafeVisualPrimitive,
SafeVisualStyle,
} from "../formats";
export interface SafeVisualLayerProps {
readonly size: number;
readonly visuals: readonly SafeVisualPrimitive[];
readonly layer: "underlay" | "overlay";
}
function anchorPoint(size: number, anchor: SafeVisualAnchor) {
if (anchor.kind === "coordinate") {
return { x: anchor.x, y: anchor.y };
}
return {
x: (anchor.cell % size) + 0.5 + (anchor.offsetX ?? 0),
y: Math.floor(anchor.cell / size) + 0.5 + (anchor.offsetY ?? 0),
};
}
function visualStyle(
style: SafeVisualStyle | undefined,
): SVGAttributes<SVGElement> {
return {
stroke: style?.stroke ?? "transparent",
fill: style?.fill ?? "transparent",
...(style?.strokeWidth === undefined
? {}
: { strokeWidth: style.strokeWidth }),
...(style?.opacity === undefined ? {} : { opacity: style.opacity }),
};
}
/**
* Render already-normalized inert drawings with typed SVG properties only.
* Text is supplied as a React text node; no source markup is ever interpreted.
*/
export function SafeVisualLayer({
size,
visuals,
layer,
}: SafeVisualLayerProps) {
return (
<g
className={`safe-visual-layer safe-visual-layer--${layer}`}
data-visual-layer={layer}
>
{visuals.map((visual, index) => {
if (visual.layer !== layer) return null;
const common = {
...visualStyle(visual.style),
className: `source-visual source-visual--${visual.type}`,
"data-visual": index,
};
if (visual.type === "line") {
const start = anchorPoint(size, visual.start);
const end = anchorPoint(size, visual.end);
return (
<line
key={index}
{...common}
x1={start.x}
y1={start.y}
x2={end.x}
y2={end.y}
/>
);
}
if (visual.type === "polyline") {
const points = visual.points
.map((anchor) => anchorPoint(size, anchor))
.map(({ x, y }) => `${String(x)},${String(y)}`)
.join(" ");
return visual.closed === true ? (
<polygon key={index} {...common} points={points} />
) : (
<polyline key={index} {...common} points={points} />
);
}
if (visual.type === "rectangle") {
const center = anchorPoint(size, visual.center);
return (
<rect
key={index}
{...common}
x={center.x - visual.width / 2}
y={center.y - visual.height / 2}
width={visual.width}
height={visual.height}
rx={visual.cornerRadius}
/>
);
}
if (visual.type === "ellipse") {
const center = anchorPoint(size, visual.center);
return (
<ellipse
key={index}
{...common}
cx={center.x}
cy={center.y}
rx={visual.radiusX}
ry={visual.radiusY}
/>
);
}
if (visual.type === "circle") {
const center = anchorPoint(size, visual.center);
return (
<circle
key={index}
{...common}
cx={center.x}
cy={center.y}
r={visual.radius}
/>
);
}
const position = anchorPoint(size, visual.position);
return (
<text
key={index}
{...common}
x={position.x}
y={position.y}
fontSize={visual.style?.fontSize}
textAnchor="middle"
dominantBaseline="central"
>
{visual.text}
</text>
);
})}
</g>
);
}
+740
View File
@@ -0,0 +1,740 @@
import { useState, type FormEvent } from "react";
import type {
PuzzleQualityAnalysis,
QualityItemAssessment,
QualityItemReference,
analyzePuzzleQuality,
} from "../solver/quality";
import { symbolFor } from "../state/session";
export type SetterQualityRunOptions = NonNullable<
Parameters<typeof analyzePuzzleQuality>[1]
>;
export type SetterQualityRunKind = "quick" | "minimality";
export interface SetterQualityLabProps {
readonly size: number;
readonly result?: PuzzleQualityAnalysis;
readonly running?: SetterQualityRunKind;
readonly error?: string;
readonly initialOptions?: Partial<SetterQualityRunOptions>;
readonly onRunQuick: (options: SetterQualityRunOptions) => void;
readonly onRunMinimality: (options: SetterQualityRunOptions) => void;
readonly onCancel: () => void;
readonly onFocusCells: (cells: readonly number[]) => void;
readonly onFocusItem: (item: QualityItemReference) => void;
}
interface QualityControls {
readonly perCheckMaxNodes: number;
readonly perCheckTimeoutMs: number;
readonly aggregateMaxChecks: number;
readonly aggregateMaxNodes: number;
readonly aggregateTimeoutMs: number;
}
const DEFAULT_CONTROLS: QualityControls = {
perCheckMaxNodes: 2_000_000,
perCheckTimeoutMs: 10_000,
aggregateMaxChecks: 1_000,
aggregateMaxNodes: 20_000_000,
aggregateTimeoutMs: 30_000,
};
const CONTROL_MAXIMUMS: Record<keyof QualityControls, number> = {
perCheckMaxNodes: 100_000_000,
perCheckTimeoutMs: 120_000,
aggregateMaxChecks: 20_000,
aggregateMaxNodes: 2_000_000_000,
aggregateTimeoutMs: 600_000,
};
function boundedPositiveInteger(
value: string,
fallback: number,
maximum: number,
): number {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0
? Math.min(parsed, maximum)
: fallback;
}
function humanize(value: string): string {
return value
.replaceAll(/([a-z])([A-Z])/gu, "$1 $2")
.replaceAll("-", " ")
.replace(/^./u, (first) => first.toUpperCase());
}
function cellName(cell: number, size: number): string {
return `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
}
function itemLabel(item: QualityItemReference, size: number): string {
if (item.kind === "given") {
return `Given ${symbolFor(item.value, size)} at ${cellName(item.cell, size)}`;
}
return `Constraint ${String(item.index + 1)} · ${humanize(item.constraintType)}`;
}
function itemKey(item: QualityItemReference): string {
return item.kind === "given"
? `given-${String(item.cell)}`
: `constraint-${String(item.index)}`;
}
function ItemList({
title,
items,
size,
emptyLabel,
onFocusItem,
}: {
title: string;
items: readonly QualityItemReference[];
size: number;
emptyLabel: string;
onFocusItem: (item: QualityItemReference) => void;
}) {
return (
<section className="setter-quality__item-group">
<h5>{title}</h5>
{items.length === 0 ? (
<p className="muted">{emptyLabel}</p>
) : (
<ul className="setter-quality__item-list">
{items.map((item) => (
<li key={itemKey(item)}>
<button type="button" onClick={() => onFocusItem(item)}>
{itemLabel(item, size)}
</button>
</li>
))}
</ul>
)}
</section>
);
}
function AssessmentTable({
title,
assessments,
size,
onFocusItem,
}: {
title: string;
assessments: readonly QualityItemAssessment[];
size: number;
onFocusItem: (item: QualityItemReference) => void;
}) {
const counts = {
critical: assessments.filter(
({ classification }) => classification === "critical",
).length,
redundant: assessments.filter(
({ classification }) => classification === "redundant",
).length,
unknown: assessments.filter(
({ classification }) => classification === "unknown",
).length,
};
return (
<section className="setter-quality__assessments">
<div className="section-heading">
<h4>{title}</h4>
<p className="setter-quality__counts muted">
{counts.critical} critical · {counts.redundant} redundant ·{" "}
{counts.unknown} unknown
</p>
</div>
{assessments.length === 0 ? (
<p className="muted">
No {title.toLowerCase()} were available to test.
</p>
) : (
<div className="setter-quality__table-wrap">
<table className="setter-quality__table">
<thead>
<tr>
<th scope="col">Clue</th>
<th scope="col">Finding</th>
<th scope="col">Evidence</th>
</tr>
</thead>
<tbody>
{assessments.map((assessment) => (
<tr
key={itemKey(assessment.item)}
className={`is-${assessment.classification}`}
>
<th scope="row">
<button
type="button"
onClick={() => onFocusItem(assessment.item)}
>
{itemLabel(assessment.item, size)}
</button>
</th>
<td>
<strong>{humanize(assessment.classification)}</strong>
</td>
<td>
{assessment.classification === "unknown"
? `Unknown / incomplete${assessment.unknownReason ? `: ${humanize(assessment.unknownReason)}` : ""}`
: assessment.solutionStatus
? `Without clue: ${humanize(assessment.solutionStatus)}`
: "Completed bounded check"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
function solutionTitle(
status: PuzzleQualityAnalysis["solutionStatus"],
): string {
if (status === "unique") return "Unique completion proven";
if (status === "multiple") return "Multiple completions found";
if (status === "unsatisfiable") return "No completion exists";
return "Solution status unknown";
}
function solutionDescription(
status: PuzzleQualityAnalysis["solutionStatus"],
): string {
if (status === "unique")
return "The bounded exact check completed and proved one solution.";
if (status === "multiple")
return "Two different completions are enough to prove ambiguity.";
if (status === "unsatisfiable")
return "The bounded exact check completed without finding any valid completion.";
return "Incomplete / unknown: a configured safety bound was reached before the solution status could be proved.";
}
function QualityResults({
size,
result,
onFocusCells,
onFocusItem,
}: {
size: number;
result: PuzzleQualityAnalysis;
onFocusCells: (cells: readonly number[]) => void;
onFocusItem: (item: QualityItemReference) => void;
}) {
const witness = result.ambiguityWitness;
const minimality = result.minimality;
return (
<div className="setter-quality__results stack">
<section className="setter-quality__solution analysis-summary">
<div className="section-heading">
<div>
<p className="eyebrow">Solution status</p>
<h3>{solutionTitle(result.solutionStatus)}</h3>
</div>
<span
className={`status-pill status-${result.solutionStatus}`}
aria-label={`Solution status: ${humanize(result.solutionStatus)}`}
>
{humanize(result.solutionStatus)}
</span>
</div>
<p>{solutionDescription(result.solutionStatus)}</p>
</section>
{witness !== undefined && (
<section
className="setter-quality__witness panel-section"
aria-labelledby="quality-witness-title"
>
<div className="section-heading">
<div>
<p className="eyebrow">Ambiguity witness</p>
<h4 id="quality-witness-title">
{witness.differences.length} differing cell
{witness.differences.length === 1 ? "" : "s"}
</h4>
</div>
<button
type="button"
disabled={witness.differences.length === 0}
onClick={() =>
onFocusCells(witness.differences.map(({ cell }) => cell))
}
>
Focus differences
</button>
</div>
<p className="muted">
These values come from two concrete valid completions; they are not
guesses.
</p>
<div className="setter-quality__table-wrap">
<table className="setter-quality__table">
<thead>
<tr>
<th scope="col">Cell</th>
<th scope="col">Solution A</th>
<th scope="col">Solution B</th>
</tr>
</thead>
<tbody>
{witness.differences.map(({ cell, first, second }) => (
<tr key={cell}>
<th scope="row">
<button
type="button"
onClick={() => onFocusCells([cell])}
>
{cellName(cell, size)}
</button>
</th>
<td>{symbolFor(first, size)}</td>
<td>{symbolFor(second, size)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
<section
className={`setter-quality__contradiction panel-section status-${result.contradiction.status}`}
aria-labelledby="quality-contradiction-title"
>
<div className="section-heading">
<div>
<p className="eyebrow">Contradiction localization</p>
<h4 id="quality-contradiction-title">
{result.contradiction.status === "localized"
? "Contradiction suspects localized"
: result.contradiction.status === "incomplete"
? "Incomplete / unknown"
: "Not applicable"}
</h4>
</div>
<span className="status-pill">
{humanize(result.contradiction.status)}
</span>
</div>
{result.contradiction.reason && <p>{result.contradiction.reason}</p>}
{result.contradiction.status === "not-applicable" && (
<p className="muted">
Contradiction localization only applies after unsatisfiability is
proved.
</p>
)}
<div className="setter-quality__contradiction-groups">
<ItemList
title="Retained suspect core"
items={result.contradiction.core}
size={size}
emptyLabel="No retained suspects were established."
onFocusItem={onFocusItem}
/>
<ItemList
title="Proven necessary suspects"
items={result.contradiction.necessary}
size={size}
emptyLabel="No individual suspect was proved necessary."
onFocusItem={onFocusItem}
/>
<ItemList
title="Removable while still contradictory"
items={result.contradiction.removable}
size={size}
emptyLabel="No removable suspects were established."
onFocusItem={onFocusItem}
/>
<ItemList
title="Unknown at safety bounds"
items={result.contradiction.unknown}
size={size}
emptyLabel="No unresolved suspects."
onFocusItem={onFocusItem}
/>
</div>
</section>
<section className="setter-quality__redundancy panel-section">
<p className="eyebrow">Leave-one-out evidence</p>
<h3>Clue criticality and redundancy</h3>
<p className="muted">
Critical means removing that clue admits another completion. Redundant
means uniqueness was proved without it. Unknown always remains unknown
when a bound interrupts proof.
</p>
{result.analysisDepth === "baseline" ? (
<p className="setter-quality__not-analysed">
Not analysed in the quick baseline. Run the full bounded minimality
analysis to classify individual clues.
</p>
) : (
<>
<AssessmentTable
title="Givens"
assessments={result.redundancy.givens}
size={size}
onFocusItem={onFocusItem}
/>
<AssessmentTable
title="Constraints"
assessments={result.redundancy.constraints}
size={size}
onFocusItem={onFocusItem}
/>
</>
)}
</section>
<section
className="setter-quality__heatmap panel-section"
aria-labelledby="quality-heatmap-title"
>
<p className="eyebrow">Criticality heatmap</p>
<h3 id="quality-heatmap-title">Cell evidence</h3>
<p className="muted" id="quality-heatmap-help">
Every button states its evidence, so colour is never the only signal.
Select a cell to focus it on the puzzle.
</p>
{result.criticalityHeatmap.length === 0 ? (
<p>
{result.analysisDepth === "baseline"
? "Not analysed in the quick baseline."
: "No cell-level evidence is available."}
</p>
) : (
<div
className="setter-quality__heatmap-grid"
role="group"
aria-label="Cell criticality heatmap"
aria-describedby="quality-heatmap-help"
style={{
gridTemplateColumns: `repeat(${String(size)}, minmax(0, 1fr))`,
}}
>
{result.criticalityHeatmap.map((entry) => {
const evidenceClass =
entry.score === null
? "unknown"
: entry.criticalWeight > entry.redundantWeight
? "critical"
: entry.redundantWeight > entry.criticalWeight
? "redundant"
: entry.criticalWeight + entry.redundantWeight > 0
? "mixed"
: "neutral";
const score =
entry.score === null ? "unknown" : entry.score.toFixed(2);
const label = `${cellName(entry.cell, size)}: ${evidenceClass}; score ${score}; critical weight ${String(entry.criticalWeight)}, redundant weight ${String(entry.redundantWeight)}, unknown weight ${String(entry.unknownWeight)}`;
return (
<button
key={entry.cell}
type="button"
className={`setter-quality__heat-cell is-${evidenceClass}`}
aria-label={label}
title={label}
onClick={() => onFocusCells([entry.cell])}
>
<span>{cellName(entry.cell, size)}</span>
<strong>
{entry.score === null ? "?" : entry.score.toFixed(1)}
</strong>
</button>
);
})}
</div>
)}
</section>
<section className="setter-quality__minimality panel-section">
<div className="section-heading">
<div>
<p className="eyebrow">Bounded minimality</p>
<h3>
{minimality === undefined
? "Not requested"
: humanize(minimality.status)}
</h3>
</div>
<span className="status-pill">
{minimality === undefined ? "Not run" : humanize(minimality.status)}
</span>
</div>
{minimality === undefined ? (
<p>
Minimality is unknown because the full bounded minimality pass was
not requested.
</p>
) : (
<>
{minimality.reason && <p>{minimality.reason}</p>}
<ItemList
title="Redundant clues"
items={minimality.redundant}
size={size}
emptyLabel="No redundant clue was proved."
onFocusItem={onFocusItem}
/>
<ItemList
title="Unknown clues"
items={minimality.unknown}
size={size}
emptyLabel="No clue remained unknown."
onFocusItem={onFocusItem}
/>
</>
)}
</section>
<section className="setter-quality__metrics panel-section">
<p className="eyebrow">Bounded-search record</p>
<h3>Checks and limits</h3>
<dl className="setter-quality__metric-list">
<div>
<dt>Analysis depth</dt>
<dd>
{result.analysisDepth === "baseline"
? "Quick baseline"
: "Full setter QC"}
</dd>
</div>
<div>
<dt>Analysis completeness</dt>
<dd>
{result.budget.truncated
? "Incomplete / unknown conclusions remain"
: "All requested checks completed"}
</dd>
</div>
<div>
<dt>Checks performed / planned</dt>
<dd>
{result.budget.checksPerformed.toLocaleString()} /{" "}
{result.budget.checksPlanned.toLocaleString()}
</dd>
</div>
<div>
<dt>Recorded check details</dt>
<dd>{result.checks.length.toLocaleString()}</dd>
</div>
<div>
<dt>Nodes searched</dt>
<dd>{result.budget.nodes.toLocaleString()}</dd>
</div>
<div>
<dt>Elapsed</dt>
<dd>{result.budget.elapsedMs.toLocaleString()} ms</dd>
</div>
<div>
<dt>Unknown reasons</dt>
<dd>
{result.budget.unknownReasons.length === 0
? "None"
: result.budget.unknownReasons.map(humanize).join(", ")}
</dd>
</div>
<div>
<dt>Per-check node / time bound</dt>
<dd>
{result.bounds.perCheck.maxNodes.toLocaleString()} nodes /{" "}
{result.bounds.perCheck.timeoutMs.toLocaleString()} ms
</dd>
</div>
<div>
<dt>Aggregate check / node / time bound</dt>
<dd>
{result.bounds.aggregate.maxChecks.toLocaleString()} checks /{" "}
{result.bounds.aggregate.maxNodes.toLocaleString()} nodes /{" "}
{result.bounds.aggregate.timeoutMs.toLocaleString()} ms
</dd>
</div>
</dl>
</section>
</div>
);
}
export function SetterQualityLab({
size,
result,
running,
error,
initialOptions,
onRunQuick,
onRunMinimality,
onCancel,
onFocusCells,
onFocusItem,
}: SetterQualityLabProps) {
const [controls, setControls] = useState<QualityControls>(() => ({
perCheckMaxNodes:
initialOptions?.perCheckMaxNodes ?? DEFAULT_CONTROLS.perCheckMaxNodes,
perCheckTimeoutMs:
initialOptions?.perCheckTimeoutMs ?? DEFAULT_CONTROLS.perCheckTimeoutMs,
aggregateMaxChecks:
initialOptions?.aggregateMaxChecks ?? DEFAULT_CONTROLS.aggregateMaxChecks,
aggregateMaxNodes:
initialOptions?.aggregateMaxNodes ?? DEFAULT_CONTROLS.aggregateMaxNodes,
aggregateTimeoutMs:
initialOptions?.aggregateTimeoutMs ?? DEFAULT_CONTROLS.aggregateTimeoutMs,
}));
const options = (
analysisDepth: "baseline" | "full",
proveMinimality: boolean,
): SetterQualityRunOptions => ({
...controls,
analysisDepth,
proveMinimality,
});
const runQuick = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
onRunQuick(options("baseline", false));
};
const setControl = (key: keyof QualityControls, value: string) => {
setControls((current) => ({
...current,
[key]: boundedPositiveInteger(value, current[key], CONTROL_MAXIMUMS[key]),
}));
};
return (
<section
className="setter-quality stack"
aria-labelledby="setter-quality-title"
>
<div>
<p className="eyebrow">Setter quality</p>
<h2 id="setter-quality-title">Uniqueness, redundancy and minimality</h2>
<p className="muted">
Every claim is backed by a bounded local exact-search check. Reaching
a limit produces an explicit unknown result, never a guessed verdict.
</p>
</div>
<form
className="setter-quality__controls panel-section"
onSubmit={runQuick}
>
<div className="field-grid">
<label>
Nodes per check
<input
type="number"
min="1"
max="100000000"
value={controls.perCheckMaxNodes}
disabled={running !== undefined}
onChange={(event) =>
setControl("perCheckMaxNodes", event.target.value)
}
/>
</label>
<label>
Time per check (ms)
<input
type="number"
min="1"
max="120000"
value={controls.perCheckTimeoutMs}
disabled={running !== undefined}
onChange={(event) =>
setControl("perCheckTimeoutMs", event.target.value)
}
/>
</label>
<label>
Maximum checks
<input
type="number"
min="1"
max="20000"
value={controls.aggregateMaxChecks}
disabled={running !== undefined}
onChange={(event) =>
setControl("aggregateMaxChecks", event.target.value)
}
/>
</label>
<label>
Total node budget
<input
type="number"
min="1"
max="2000000000"
value={controls.aggregateMaxNodes}
disabled={running !== undefined}
onChange={(event) =>
setControl("aggregateMaxNodes", event.target.value)
}
/>
</label>
<label>
Total time budget (ms)
<input
type="number"
min="1"
max="600000"
value={controls.aggregateTimeoutMs}
disabled={running !== undefined}
onChange={(event) =>
setControl("aggregateTimeoutMs", event.target.value)
}
/>
</label>
</div>
<div className="setter-quality__actions action-row">
<button type="submit" disabled={running !== undefined}>
Run quick quality check
</button>
<button
type="button"
disabled={running !== undefined}
onClick={() => onRunMinimality(options("full", true))}
>
Run full bounded minimality
</button>
{running !== undefined && (
<button type="button" onClick={onCancel}>
Cancel analysis
</button>
)}
</div>
</form>
{running !== undefined && (
<p
className="setter-quality__running status-line"
role="status"
aria-live="polite"
>
{running === "quick"
? "Quick quality check running locally…"
: "Full bounded minimality analysis running locally…"}
</p>
)}
{error !== undefined && (
<p className="setter-quality__error error-callout" role="alert">
{error}
</p>
)}
{result !== undefined && (
<QualityResults
size={size}
result={result}
onFocusCells={onFocusCells}
onFocusItem={onFocusItem}
/>
)}
</section>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+60
View File
@@ -0,0 +1,60 @@
import type { FogConstraint, NormalizedPuzzle } from "../domain";
/**
* Resolve display-only Fog of War visibility without changing puzzle rules.
* A trusted correct entry and every given act as reveal sources in addition to
* the authored lights.
*/
export function foggedCellsForPuzzle(
puzzle: NormalizedPuzzle,
values: readonly number[],
): ReadonlySet<number> {
const fogConstraints = puzzle.constraints.filter(
(constraint): constraint is FogConstraint => constraint.type === "fog",
);
if (fogConstraints.length === 0) return new Set<number>();
const correctEntries = values.flatMap((value, cell) =>
value !== 0 && puzzle.solution?.[cell] === value ? [cell] : [],
);
const givens = puzzle.givens.flatMap((value, cell) =>
value === 0 ? [] : [cell],
);
const visible = new Set<number>();
for (const constraint of fogConstraints) {
const radius = constraint.revealRadius ?? 1;
const sources = new Set([
...constraint.lights,
...givens,
...correctEntries,
]);
for (const source of sources) {
const row = Math.floor(source / puzzle.size);
const column = source % puzzle.size;
for (let rowOffset = -radius; rowOffset <= radius; rowOffset += 1) {
for (
let columnOffset = -radius;
columnOffset <= radius;
columnOffset += 1
) {
const targetRow = row + rowOffset;
const targetColumn = column + columnOffset;
if (
targetRow >= 0 &&
targetRow < puzzle.size &&
targetColumn >= 0 &&
targetColumn < puzzle.size
) {
visible.add(targetRow * puzzle.size + targetColumn);
}
}
}
}
}
return new Set(
Array.from({ length: puzzle.size * puzzle.size }, (_, cell) => cell).filter(
(cell) => !visible.has(cell),
),
);
}
+223
View File
@@ -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(" ");
}