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