feat: complete advanced Sudoku workbench

This commit is contained in:
2026-08-31 08:20:30 +02:00
parent 8ca9300ab3
commit 0a1bdc1a8c
99 changed files with 20793 additions and 923 deletions
+183
View File
@@ -0,0 +1,183 @@
import {
useEffect,
useRef,
useState,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from "react";
import {
BOARD_SCALE_MAX,
BOARD_SCALE_MIN,
BOARD_SCALE_STEP,
normalizeBoardScale,
readStoredBoardScale,
writeStoredBoardScale,
} from "../state/uiPreferences";
interface PanOrigin {
readonly pointerId: number;
readonly clientX: number;
readonly clientY: number;
readonly scrollLeft: number;
readonly scrollTop: number;
}
export function BoardViewport({
children,
onPanModeChange,
}: {
readonly children: ReactNode;
readonly onPanModeChange?: (enabled: boolean) => void;
}) {
const [scale, setScale] = useState(readStoredBoardScale);
const [panMode, setPanMode] = useState(false);
const scrollerRef = useRef<HTMLDivElement>(null);
const panOriginRef = useRef<PanOrigin | undefined>(undefined);
useEffect(() => writeStoredBoardScale(scale), [scale]);
const updateScale = (value: number) => setScale(normalizeBoardScale(value));
const fitBoard = () => {
updateScale(1);
if (scrollerRef.current !== null) {
scrollerRef.current.scrollLeft = 0;
scrollerRef.current.scrollTop = 0;
}
};
const onKeyDownCapture = (event: KeyboardEvent<HTMLElement>) => {
if (!(event.ctrlKey || event.metaKey)) return;
if (["+", "="].includes(event.key)) {
event.preventDefault();
event.stopPropagation();
updateScale(scale + BOARD_SCALE_STEP);
} else if (event.key === "-") {
event.preventDefault();
event.stopPropagation();
updateScale(scale - BOARD_SCALE_STEP);
} else if (event.key === "0") {
event.preventDefault();
event.stopPropagation();
fitBoard();
}
};
const beginPan = (event: PointerEvent<HTMLDivElement>) => {
if (!panMode || (event.pointerType === "mouse" && event.button !== 0))
return;
const scroller = scrollerRef.current;
if (scroller === null) return;
event.preventDefault();
scroller.setPointerCapture?.(event.pointerId);
panOriginRef.current = {
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
scrollLeft: scroller.scrollLeft,
scrollTop: scroller.scrollTop,
};
};
const continuePan = (event: PointerEvent<HTMLDivElement>) => {
const origin = panOriginRef.current;
const scroller = scrollerRef.current;
if (
!panMode ||
origin === undefined ||
origin.pointerId !== event.pointerId ||
scroller === null
) {
return;
}
event.preventDefault();
scroller.scrollLeft = origin.scrollLeft - (event.clientX - origin.clientX);
scroller.scrollTop = origin.scrollTop - (event.clientY - origin.clientY);
};
const endPan = (event: PointerEvent<HTMLDivElement>) => {
if (panOriginRef.current?.pointerId !== event.pointerId) return;
scrollerRef.current?.releasePointerCapture?.(event.pointerId);
panOriginRef.current = undefined;
};
const percentage = Math.round(scale * 100);
return (
<section
className={`board-viewport${panMode ? " is-pan-mode" : ""}`}
aria-label="Board zoom and pan"
onKeyDownCapture={onKeyDownCapture}
>
<div className="board-viewport__controls">
<div
className="board-zoom-buttons"
role="group"
aria-label="Board zoom"
>
<button
type="button"
aria-label="Zoom board out"
disabled={scale <= BOARD_SCALE_MIN}
onClick={() => updateScale(scale - BOARD_SCALE_STEP)}
>
</button>
<output aria-live="polite" aria-label="Board zoom level">
{percentage}%
</output>
<button
type="button"
aria-label="Zoom board in"
disabled={scale >= BOARD_SCALE_MAX}
onClick={() => updateScale(scale + BOARD_SCALE_STEP)}
>
+
</button>
<button type="button" onClick={fitBoard}>
Fit board
</button>
</div>
<button
type="button"
className={panMode ? "is-active" : ""}
aria-pressed={panMode}
onClick={() =>
setPanMode((current) => {
const enabled = !current;
panOriginRef.current = undefined;
onPanModeChange?.(enabled);
return enabled;
})
}
>
{panMode ? "Stop panning" : "Pan board"}
</button>
</div>
{panMode && (
<p className="board-pan-notice" role="status">
Pan mode: drag the board to move it. Cell taps are paused.
</p>
)}
<div
ref={scrollerRef}
className="board-viewport__scroller"
tabIndex={0}
aria-label={
panMode
? "Scrollable puzzle board; pan mode is on"
: "Scrollable puzzle board"
}
onPointerDown={beginPan}
onPointerMove={continuePan}
onPointerUp={endPan}
onPointerCancel={endPan}
>
<div
className="board-viewport__canvas"
style={{
width: `${String(percentage)}%`,
maxWidth: `${String(48 * scale)}rem`,
}}
>
{children}
</div>
</div>
</section>
);
}
+659 -71
View File
@@ -1,6 +1,19 @@
import { useState } from "react";
import { cellsFormQuadruple } from "../domain/geometry";
import type { PuzzleDefinition, VariantConstraint } from "../domain/types";
import { useId, useState } from "react";
import {
cellsFormQuadruple,
classicRegions,
constraintLabel,
constraintMetadata,
littleKillerCells,
littleKillerDirectionEntersGrid,
validatePuzzle,
} from "../domain";
import type {
LittleKillerDirection,
OutsideClueSide,
PuzzleDefinition,
VariantConstraint,
} from "../domain/types";
import {
removeKillerCagesAtCells,
replaceOverlappingKillerCages,
@@ -17,6 +30,65 @@ interface ConstraintEditorProps {
busy: boolean;
}
type OutsideConstraintType =
"x-sum" | "skyscraper" | "little-killer" | "sandwich";
const LITTLE_KILLER_DIRECTIONS = [
["down-right", "Down right ↘"],
["down-left", "Down left ↙"],
["up-right", "Up right ↗"],
["up-left", "Up left ↖"],
] as const satisfies readonly (readonly [LittleKillerDirection, string])[];
function firstLittleKillerDirection(side: OutsideClueSide) {
return (
LITTLE_KILLER_DIRECTIONS.find(([direction]) =>
littleKillerDirectionEntersGrid(side, direction),
)?.[0] ?? "down-right"
);
}
function reachableSandwichSums(size: number): ReadonlySet<number> {
let totals = new Set([0]);
for (let digit = 2; digit < size; digit += 1) {
totals = new Set([...totals, ...[...totals].map((total) => total + digit)]);
}
return totals;
}
function sameOrderedCells(
first: readonly number[],
second: readonly number[],
): boolean {
return (
first.length === second.length &&
first.every((cell, index) => cell === second[index])
);
}
function hasClassicRegionPartition(puzzle: PuzzleDefinition): boolean {
const expected = classicRegions(puzzle.size);
const actual = puzzle.regions ?? expected;
if (actual.length !== expected.length) return false;
const expectedToActual = new Map<number, number>();
const actualToExpected = new Map<number, number>();
return expected.every((expectedRegion, cell) => {
const actualRegion = actual[cell];
if (actualRegion === undefined) return false;
const mappedActual = expectedToActual.get(expectedRegion);
const mappedExpected = actualToExpected.get(actualRegion);
if (
(mappedActual !== undefined && mappedActual !== actualRegion) ||
(mappedExpected !== undefined && mappedExpected !== expectedRegion)
) {
return false;
}
expectedToActual.set(expectedRegion, actualRegion);
actualToExpected.set(actualRegion, expectedRegion);
return true;
});
}
function describeConstraint(constraint: VariantConstraint, size: number) {
const cell = (index: number) =>
`r${String(Math.floor(index / size) + 1)}c${String((index % size) + 1)}`;
@@ -33,6 +105,8 @@ function describeConstraint(constraint: VariantConstraint, size: number) {
return "anti-king";
case "non-consecutive":
return "non-consecutive";
case "disjoint-groups":
return "disjoint groups · matching box positions do not repeat";
case "killer-cage":
return marked(
`${String(constraint.sum)} cage · ${String(constraint.cells.length)} cells${constraint.noRepeat === false ? " · repeats allowed" : ""}`,
@@ -71,13 +145,63 @@ function describeConstraint(constraint: VariantConstraint, size: number) {
);
case "maximum":
return marked(`maximum · ${cell(constraint.cell)}`);
case "minimum":
return marked(`minimum · ${cell(constraint.cell)}`);
case "odd":
return marked(`odd circle · ${cell(constraint.cell)}`);
case "even":
return marked(`even square · ${cell(constraint.cell)}`);
case "little-killer":
return marked(
`little killer ${String(constraint.sum)} · ${constraint.side} ${String(constraint.index + 1)} · ${constraint.direction.replace("-", " ")}`,
);
case "sandwich":
return marked(
`sandwich ${String(constraint.sum)} · ${constraint.side} ${String(constraint.index + 1)}`,
);
case "between-line":
return marked(
`between line · ${String(constraint.cells.length)} ordered cells`,
);
case "german-whisper":
return marked(
`German whisper ≥ ${String(constraint.minimumDifference ?? Math.ceil(size / 2))} · ${String(constraint.cells.length)} ordered cells`,
);
case "region-sum-line":
return marked(
`region-sum line · ${String(constraint.cells.length)} ordered cells`,
);
case "clone":
return marked(
`clone regions · ${String(constraint.cells.length)} + ${String(constraint.cloneCells.length)} paired cells`,
);
case "extra-region":
return `extra region · ${String(constraint.cells.length)} cells`;
case "modular-line":
return marked(
`modular line · ${String(constraint.cells.length)} ordered cells`,
);
case "entropic-line":
return marked(
`entropic line · ${String(constraint.cells.length)} ordered cells`,
);
case "zipper-line":
return marked(
`zipper line · ${String(constraint.cells.length)} ordered cells`,
);
case "double-arrow":
return marked(
`double arrow · ${String(constraint.cells.length)} ordered cells`,
);
case "indexer":
return marked(`${constraint.kind} indexer · ${cell(constraint.cell)}`);
case "fog":
return `fog · ${String(constraint.lights.length)} initial light${constraint.lights.length === 1 ? "" : "s"} · radius ${String(constraint.revealRadius ?? 1)}`;
}
}
function supportsPolarity(constraint: VariantConstraint): boolean {
return !["diagonal", "anti-knight", "anti-king", "non-consecutive"].includes(
constraint.type,
);
return constraintMetadata(constraint.type).negatable;
}
function parseClueDigits(text: string, size: number): number[] {
@@ -109,21 +233,31 @@ export function ConstraintEditor({
const [region, setRegion] = useState(1);
const [newCluesAreFalse, setNewCluesAreFalse] = useState(false);
const [quadrupleDigits, setQuadrupleDigits] = useState("1, 2, 3");
const [outsideType, setOutsideType] = useState<"x-sum" | "skyscraper">(
"x-sum",
);
const [outsideSide, setOutsideSide] = useState<
"top" | "right" | "bottom" | "left"
>("top");
const [outsideType, setOutsideType] =
useState<OutsideConstraintType>("x-sum");
const [outsideSide, setOutsideSide] = useState<OutsideClueSide>("top");
const [littleKillerDirection, setLittleKillerDirection] =
useState<LittleKillerDirection>("down-right");
const [outsideLine, setOutsideLine] = useState(1);
const [outsideValue, setOutsideValue] = useState(3);
const [whisperMinimumDifference, setWhisperMinimumDifference] = useState(
Math.ceil(puzzle.size / 2),
);
const [indexerKind, setIndexerKind] = useState<"row" | "column" | "box">(
"row",
);
const [fogRevealRadius, setFogRevealRadius] = useState<0 | 1>(1);
const fogReasonId = useId();
const constraints = puzzle.constraints ?? [];
const append = (constraint: VariantConstraint) =>
const append = (
constraint: VariantConstraint,
replace: (candidate: VariantConstraint) => boolean = () => false,
) =>
onChange({
...puzzle,
constraints: [
...constraints,
...constraints.filter((candidate) => !replace(candidate)),
supportsPolarity(constraint) && newCluesAreFalse
? ({ ...constraint, negated: true } as VariantConstraint)
: constraint,
@@ -146,6 +280,7 @@ export function ConstraintEditor({
? cageSum >= 1 && cageSum <= puzzle.size ** 3
: cageSum >= minimumCageSum && cageSum <= maximumCageSum);
const parsedQuadrupleDigits = parseClueDigits(quadrupleDigits, puzzle.size);
const selectionIsUnique = new Set(selection).size === selection.length;
const validQuadruple =
cellsFormQuadruple(puzzle.size, selection) &&
parsedQuadrupleDigits.length >= 1 &&
@@ -155,9 +290,95 @@ export function ConstraintEditor({
(digit) => Number.isInteger(digit) && digit >= 1 && digit <= puzzle.size,
);
const selectedCageExists = selectionTouchesKillerCage(constraints, selection);
const selectedCells = new Set(selection);
const selectedCellMarkerExists = constraints.some(
(constraint) =>
(constraint.type === "maximum" ||
constraint.type === "minimum" ||
constraint.type === "odd" ||
constraint.type === "even") &&
selectedCells.has(constraint.cell),
);
const littleKillerLine = littleKillerCells(
puzzle.size,
outsideSide,
outsideLine - 1,
littleKillerDirection,
);
const validOutsidePosition =
Number.isInteger(outsideLine) &&
outsideLine >= 1 &&
outsideLine <= puzzle.size;
const validLittleKillerDirection = littleKillerDirectionEntersGrid(
outsideSide,
littleKillerDirection,
);
const outsideMinimum = newCluesAreFalse
? outsideType === "sandwich"
? 0
: 1
: outsideType === "sandwich"
? 0
: outsideType === "little-killer"
? littleKillerLine.length
: 1;
const outsideMaximum = newCluesAreFalse
? puzzle.size ** 4
: outsideType === "x-sum"
? (puzzle.size * (puzzle.size + 1)) / 2
: outsideType === "skyscraper"
? puzzle.size
: outsideType === "little-killer"
? littleKillerLine.length * puzzle.size
: (puzzle.size * (puzzle.size + 1)) / 2 - puzzle.size - 1;
const validOutsideValue =
Number.isInteger(outsideValue) &&
outsideValue >= outsideMinimum &&
outsideValue <= outsideMaximum &&
(newCluesAreFalse ||
outsideType !== "sandwich" ||
reachableSandwichSums(puzzle.size).has(outsideValue));
const validOutsideClue =
validOutsidePosition &&
validOutsideValue &&
(outsideType !== "little-killer" ||
(validLittleKillerDirection && littleKillerLine.length >= 2));
const validWhisperDifference =
Number.isInteger(whisperMinimumDifference) &&
whisperMinimumDifference >= 1 &&
whisperMinimumDifference < puzzle.size;
const cloneHalf = selection.length / 2;
const cloneSource = Number.isInteger(cloneHalf)
? selection.slice(0, cloneHalf)
: [];
const cloneTarget = Number.isInteger(cloneHalf)
? selection.slice(cloneHalf)
: [];
const validClone =
selectionIsUnique &&
cloneSource.length >= 1 &&
cloneSource.length === cloneTarget.length &&
cloneTarget.every((cell) => !new Set(cloneSource).has(cell));
const solutionIsComplete =
puzzle.solution?.length === puzzle.size * puzzle.size &&
puzzle.solution.every(
(value, cell) =>
Number.isInteger(value) &&
value >= 1 &&
value <= puzzle.size &&
((puzzle.givens[cell] ?? 0) === 0 || puzzle.givens[cell] === value),
);
const trustedSolution = solutionIsComplete && validatePuzzle(puzzle).valid;
const boxIndexerAvailable = hasClassicRegionPartition(puzzle);
const fogDisabledReason = !trustedSolution
? "Fog of War requires a complete trusted solution. Generate or import one before choosing initial lights."
: !atLeast(1) || !selectionIsUnique
? "Select at least one unique initial light cell."
: undefined;
const toggleGlobal = (
type: "anti-knight" | "anti-king" | "non-consecutive",
type: "anti-knight" | "anti-king" | "non-consecutive" | "disjoint-groups",
) => {
const exists = constraints.some((constraint) => constraint.type === type);
onChange({
@@ -180,7 +401,11 @@ export function ConstraintEditor({
Grid
<select
value={puzzle.size}
onChange={(event) => onNewGrid(Number(event.target.value))}
onChange={(event) => {
const size = Number(event.target.value);
setWhisperMinimumDifference(Math.ceil(size / 2));
onNewGrid(size);
}}
>
<option value="4">4 × 4</option>
<option value="6">6 × 6</option>
@@ -419,10 +644,335 @@ export function ConstraintEditor({
<button
type="button"
disabled={!need(1)}
onClick={() => append({ type: "maximum", cell: selection[0]! })}
onClick={() =>
append(
{ type: "maximum", cell: selection[0]! },
(constraint) =>
constraint.type === "maximum" &&
constraint.cell === selection[0],
)
}
>
Maximum cell
</button>
<button
type="button"
disabled={!need(1)}
onClick={() =>
append(
{ type: "minimum", cell: selection[0]! },
(constraint) =>
constraint.type === "minimum" &&
constraint.cell === selection[0],
)
}
>
Minimum cell
</button>
<button
type="button"
disabled={!need(1)}
onClick={() =>
append(
{ type: "odd", cell: selection[0]! },
(constraint) =>
constraint.type === "odd" && constraint.cell === selection[0],
)
}
>
Odd cell (circle)
</button>
<button
type="button"
disabled={!need(1)}
onClick={() =>
append(
{ type: "even", cell: selection[0]! },
(constraint) =>
constraint.type === "even" &&
constraint.cell === selection[0],
)
}
>
Even cell (square)
</button>
<button
type="button"
className="danger"
disabled={!selectedCellMarkerExists}
onClick={() =>
onChange({
...puzzle,
constraints: constraints.filter(
(constraint) =>
!(
(constraint.type === "maximum" ||
constraint.type === "minimum" ||
constraint.type === "odd" ||
constraint.type === "even") &&
selectedCells.has(constraint.cell)
),
),
})
}
>
Remove selected cell markers
</button>
</div>
<div className="pack-constraint-controls stack">
<div>
<p className="eyebrow">Expansion lines &amp; regions</p>
<p className="muted">
Selection order defines each line. For clone regions, select all
source cells first and then the same number of paired clone cells.
</p>
</div>
<div className="inline-fields">
<label className="compact-field">
Whisper minimum difference
<input
type="number"
min="1"
max={puzzle.size - 1}
value={whisperMinimumDifference}
onChange={(event) =>
setWhisperMinimumDifference(Number(event.target.value))
}
/>
</label>
</div>
<div className="button-grid">
<button
type="button"
disabled={!atLeast(3) || !selectionIsUnique}
onClick={() =>
append(
{ type: "between-line", cells: selection },
(constraint) =>
constraint.type === "between-line" &&
sameOrderedCells(constraint.cells, selection),
)
}
>
Between line
</button>
<button
type="button"
disabled={
!atLeast(2) || !selectionIsUnique || !validWhisperDifference
}
onClick={() =>
append(
{
type: "german-whisper",
cells: selection,
minimumDifference: whisperMinimumDifference,
},
(constraint) =>
constraint.type === "german-whisper" &&
sameOrderedCells(constraint.cells, selection),
)
}
>
German whisper
</button>
<button
type="button"
disabled={!atLeast(2) || !selectionIsUnique}
onClick={() =>
append(
{ type: "region-sum-line", cells: selection },
(constraint) =>
constraint.type === "region-sum-line" &&
sameOrderedCells(constraint.cells, selection),
)
}
>
Region-sum line
</button>
<button
type="button"
disabled={!validClone}
onClick={() =>
append(
{
type: "clone",
cells: cloneSource,
cloneCells: cloneTarget,
},
(constraint) =>
constraint.type === "clone" &&
sameOrderedCells(constraint.cells, cloneSource) &&
sameOrderedCells(constraint.cloneCells, cloneTarget),
)
}
>
Clone selection halves
</button>
<button
type="button"
disabled={selection.length !== puzzle.size || !selectionIsUnique}
onClick={() =>
append(
{ type: "extra-region", cells: selection },
(constraint) =>
constraint.type === "extra-region" &&
new Set(constraint.cells).size === selection.length &&
selection.every((cell) => constraint.cells.includes(cell)),
)
}
>
Extra region ({String(puzzle.size)} cells)
</button>
</div>
</div>
<div className="pack-constraint-controls pack-three-controls stack">
<div>
<p className="eyebrow">Pattern lines, indexers &amp; fog</p>
<p className="muted">
Selection order runs from the first selected cell to the last.
Indexers use one selected marker cell. Fog uses every selected
cell as an initial light.
</p>
</div>
<div className="inline-fields">
<label className="compact-field">
Indexer kind
<select
value={indexerKind}
onChange={(event) =>
setIndexerKind(event.target.value as "row" | "column" | "box")
}
>
<option value="row">Row indexer</option>
<option value="column">Column indexer</option>
<option value="box" disabled={!boxIndexerAvailable}>
Box indexer
</option>
</select>
</label>
<label className="compact-field">
Fog reveal radius
<select
value={fogRevealRadius}
onChange={(event) =>
setFogRevealRadius(Number(event.target.value) as 0 | 1)
}
>
<option value="1">1 cell (3 × 3 area)</option>
<option value="0">0 cells (entered cell only)</option>
</select>
</label>
</div>
<p className="muted pack-three-requirements">
Entropic lines require a grid size divisible by 3; zipper lines
require an odd number of selected cells.
{!boxIndexerAvailable &&
" Box indexers require the standard rectangular box layout."}
</p>
<div className="button-grid">
<button
type="button"
disabled={!atLeast(3) || !selectionIsUnique}
onClick={() =>
append(
{ type: "modular-line", cells: selection },
(constraint) =>
constraint.type === "modular-line" &&
sameOrderedCells(constraint.cells, selection),
)
}
>
Modular line
</button>
<button
type="button"
disabled={
!atLeast(3) || !selectionIsUnique || puzzle.size % 3 !== 0
}
onClick={() =>
append(
{ type: "entropic-line", cells: selection },
(constraint) =>
constraint.type === "entropic-line" &&
sameOrderedCells(constraint.cells, selection),
)
}
>
Entropic line
</button>
<button
type="button"
disabled={
!atLeast(3) || !selectionIsUnique || selection.length % 2 === 0
}
onClick={() =>
append(
{ type: "zipper-line", cells: selection },
(constraint) =>
constraint.type === "zipper-line" &&
sameOrderedCells(constraint.cells, selection),
)
}
>
Zipper line
</button>
<button
type="button"
disabled={!atLeast(3) || !selectionIsUnique}
onClick={() =>
append(
{ type: "double-arrow", cells: selection },
(constraint) =>
constraint.type === "double-arrow" &&
sameOrderedCells(constraint.cells, selection),
)
}
>
Double arrow
</button>
<button
type="button"
disabled={
!need(1) || (indexerKind === "box" && !boxIndexerAvailable)
}
onClick={() =>
append(
{
type: "indexer",
kind: indexerKind,
cell: selection[0]!,
},
(constraint) =>
constraint.type === "indexer" &&
constraint.cell === selection[0],
)
}
>
Add / replace indexer
</button>
<button
type="button"
disabled={fogDisabledReason !== undefined}
aria-describedby={fogReasonId}
onClick={() =>
append(
{
type: "fog",
lights: selection,
revealRadius: fogRevealRadius,
},
(constraint) => constraint.type === "fog",
)
}
>
Add / replace Fog of War
</button>
</div>
<p id={fogReasonId} className="muted fog-setter-reason" role="status">
{fogDisabledReason ??
"Fog is ready: correct entries and givens reveal nearby cells."}
</p>
</div>
<div className="inline-fields">
<label className="compact-field">
@@ -478,10 +1028,11 @@ export function ConstraintEditor({
<section className="panel-section">
<p className="eyebrow">Outside clues</p>
<h3>X-sums and skyscrapers</h3>
<h3>Edge and diagonal sums</h3>
<p className="muted">
Choose the edge and row or column. Σ badges are X-sums; badges are
visibility counts.
visibility counts; 1N marks sandwich sums. Little-killer arrows show
the diagonal direction.
</p>
<div className="inline-fields outside-clue-fields">
<label className="compact-field">
@@ -489,22 +1040,28 @@ export function ConstraintEditor({
<select
value={outsideType}
onChange={(event) =>
setOutsideType(event.target.value as "x-sum" | "skyscraper")
setOutsideType(event.target.value as OutsideConstraintType)
}
>
<option value="x-sum">X-sum</option>
<option value="skyscraper">Skyscraper</option>
<option value="little-killer">Little killer</option>
<option value="sandwich">Sandwich sum</option>
</select>
</label>
<label className="compact-field">
Side
<select
value={outsideSide}
onChange={(event) =>
setOutsideSide(
event.target.value as "top" | "right" | "bottom" | "left",
)
}
onChange={(event) => {
const side = event.target.value as OutsideClueSide;
setOutsideSide(side);
if (
!littleKillerDirectionEntersGrid(side, littleKillerDirection)
) {
setLittleKillerDirection(firstLittleKillerDirection(side));
}
}}
>
<option value="top">Top</option>
<option value="right">Right</option>
@@ -522,52 +1079,78 @@ export function ConstraintEditor({
onChange={(event) => setOutsideLine(Number(event.target.value))}
/>
</label>
{outsideType === "little-killer" && (
<label className="compact-field">
Direction
<select
value={littleKillerDirection}
onChange={(event) =>
setLittleKillerDirection(
event.target.value as LittleKillerDirection,
)
}
>
{LITTLE_KILLER_DIRECTIONS.map(([direction, label]) => (
<option
key={direction}
value={direction}
disabled={
!littleKillerDirectionEntersGrid(outsideSide, direction)
}
>
{label}
</option>
))}
</select>
</label>
)}
<label className="compact-field">
Clue
{outsideType === "skyscraper" ? "Count" : "Sum"}
<input
type="number"
min="1"
max={
newCluesAreFalse
? puzzle.size ** 4
: outsideType === "x-sum"
? (puzzle.size * (puzzle.size + 1)) / 2
: puzzle.size
}
min={outsideMinimum}
max={outsideMaximum}
value={outsideValue}
onChange={(event) => setOutsideValue(Number(event.target.value))}
/>
</label>
<button
type="button"
disabled={
!Number.isInteger(outsideLine) ||
outsideLine < 1 ||
outsideLine > puzzle.size ||
!Number.isInteger(outsideValue) ||
outsideValue < 1 ||
outsideValue >
(newCluesAreFalse
? puzzle.size ** 4
: outsideType === "x-sum"
? (puzzle.size * (puzzle.size + 1)) / 2
: puzzle.size)
}
disabled={!validOutsideClue}
onClick={() => {
const clue: VariantConstraint =
outsideType === "x-sum"
? {
const clue: VariantConstraint = (() => {
switch (outsideType) {
case "x-sum":
return {
type: "x-sum",
side: outsideSide,
index: outsideLine - 1,
sum: outsideValue,
}
: {
};
case "skyscraper":
return {
type: "skyscraper",
side: outsideSide,
index: outsideLine - 1,
count: outsideValue,
};
case "little-killer":
return {
type: "little-killer",
side: outsideSide,
index: outsideLine - 1,
direction: littleKillerDirection,
sum: outsideValue,
};
case "sandwich":
return {
type: "sandwich",
side: outsideSide,
index: outsideLine - 1,
sum: outsideValue,
};
}
})();
const withoutExisting = constraints.filter(
(constraint) =>
constraint.type !== outsideType ||
@@ -593,25 +1176,30 @@ export function ConstraintEditor({
<section className="panel-section">
<p className="eyebrow">Global rules</p>
<div className="button-grid">
{(["anti-knight", "anti-king", "non-consecutive"] as const).map(
(type) => (
<button
key={type}
type="button"
className={
constraints.some((constraint) => constraint.type === type)
? "is-active"
: ""
}
aria-pressed={constraints.some(
(constraint) => constraint.type === type,
)}
onClick={() => toggleGlobal(type)}
>
{type}
</button>
),
)}
{(
[
"anti-knight",
"anti-king",
"non-consecutive",
"disjoint-groups",
] as const
).map((type) => (
<button
key={type}
type="button"
className={
constraints.some((constraint) => constraint.type === type)
? "is-active"
: ""
}
aria-pressed={constraints.some(
(constraint) => constraint.type === type,
)}
onClick={() => toggleGlobal(type)}
>
{constraintLabel(type)}
</button>
))}
{(["main", "anti"] as const).map((direction) => {
const active = constraints.some(
(constraint) =>
+428 -20
View File
@@ -2,8 +2,13 @@ import { useMemo, useState, type FormEvent } from "react";
import {
GENERATOR_VARIANTS,
PRACTICE_TECHNIQUES,
type BatchRanking,
type ClueSymmetry,
type ConstraintDensity,
type DifficultyAssessment,
type GeneratedVariantBatch,
type GeneratedVariantPuzzle,
type GenerateVariantBatchOptions,
type GenerationDifficultyTarget,
type GeneratorVariant,
type GenerateVariantOptions,
@@ -31,13 +36,21 @@ export function GeneratorWorkspace({
busy,
assessment,
generation,
batch,
onGenerate,
onGenerateBatch,
onSelectGenerated,
onCancel,
onRate,
}: {
busy: boolean;
assessment?: DifficultyAssessment;
generation?: GeneratedVariantPuzzle;
batch?: GeneratedVariantBatch;
onGenerate: (options: GenerateVariantOptions) => void;
onGenerateBatch?: (options: GenerateVariantBatchOptions) => void;
onSelectGenerated?: (generation: GeneratedVariantPuzzle) => void;
onCancel?: () => void;
onRate: () => void;
}) {
const [variant, setVariant] = useState<GeneratorVariant>("classic");
@@ -48,35 +61,107 @@ export function GeneratorWorkspace({
const [size, setSize] = useState(9);
const [targetDifficulty, setTargetDifficulty] =
useState<GenerationDifficultyTarget>("medium");
const [symmetry, setSymmetry] = useState<"none" | "rotational">("rotational");
const [additionalVariants, setAdditionalVariants] = useState<
GeneratorVariant[]
>([]);
const [symmetry, setSymmetry] = useState<ClueSymmetry>("rotational");
const [constraintCount, setConstraintCount] = useState(8);
const [constraintDensity, setConstraintDensity] =
useState<ConstraintDensity>("balanced");
const [minimalGivens, setMinimalGivens] = useState(false);
const [batchSize, setBatchSize] = useState(1);
const [batchRanking, setBatchRanking] = useState<BatchRanking>("difficulty");
const [seed, setSeed] = useState("");
const [requiredTechnique, setRequiredTechnique] = useState<
PracticeTechnique | ""
>("");
const [maxTechniqueAttempts, setMaxTechniqueAttempts] = useState(10);
const usesMarkingCount = ![
"classic",
"diagonal",
"killer",
"anti-knight",
"anti-king",
"non-consecutive",
].includes(variant);
const [minimumTechniqueCount, setMinimumTechniqueCount] = useState(1);
const [maximumTechniqueCount, setMaximumTechniqueCount] = useState("");
const [forbiddenTechnique, setForbiddenTechnique] = useState<
PracticeTechnique | ""
>("");
const [hardestTechnique, setHardestTechnique] = useState<
PracticeTechnique | ""
>("");
const selectedFamilies = [variant, ...additionalVariants];
const usesMarkingCount = selectedFamilies.some(
(family) =>
![
"classic",
"diagonal",
"killer",
"anti-knight",
"anti-king",
"non-consecutive",
].includes(family),
);
const usesConstraintDensity = selectedFamilies.some(
(family) =>
family === "killer" ||
![
"classic",
"diagonal",
"anti-knight",
"anti-king",
"non-consecutive",
].includes(family),
);
const hasTechniqueProfile =
requiredTechnique !== "" ||
forbiddenTechnique !== "" ||
hardestTechnique !== "" ||
maximumTechniqueCount !== "" ||
minimumTechniqueCount !== 1;
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
onGenerate({
const profileCounts =
requiredTechnique !== "" &&
(minimumTechniqueCount !== 1 || maximumTechniqueCount !== "")
? [
{
technique: requiredTechnique,
min: minimumTechniqueCount,
...(maximumTechniqueCount === ""
? {}
: { max: Number(maximumTechniqueCount) }),
},
]
: [];
const options: GenerateVariantBatchOptions = {
variant,
...(additionalVariants.length === 0
? {}
: { variants: selectedFamilies }),
size,
targetDifficulty,
symmetry,
...(usesMarkingCount ? { constraintCount } : {}),
...(requiredTechnique === ""
? {}
: { requiredTechnique, maxTechniqueAttempts }),
...(usesConstraintDensity && constraintDensity !== "balanced"
? { constraintDensity }
: {}),
...(minimalGivens ? { minimalGivens: true } : {}),
...(requiredTechnique === "" ? {} : { requiredTechnique }),
...(hasTechniqueProfile
? {
techniqueProfile: {
...(forbiddenTechnique === ""
? {}
: { forbidden: [forbiddenTechnique] }),
...(profileCounts.length === 0 ? {} : { counts: profileCounts }),
...(hardestTechnique === "" ? {} : { hardestTechnique }),
},
maxTechniqueAttempts,
}
: {}),
seed: seed.trim() || `local-${Date.now().toString(36)}`,
});
};
if (batchSize > 1 && onGenerateBatch !== undefined) {
onGenerateBatch({ ...options, batchSize, ranking: batchRanking });
} else {
onGenerate(options);
}
};
return (
@@ -103,6 +188,16 @@ export function GeneratorWorkspace({
(item) => item.id === next,
)!;
setVariant(next);
setAdditionalVariants((current) =>
current.filter(
(family) =>
family !== next &&
(
GENERATOR_VARIANTS.find(({ id }) => id === family)
?.supportedSizes as readonly number[]
).includes(size),
),
);
if (
!(
nextDefinition.supportedSizes as readonly number[]
@@ -128,7 +223,18 @@ export function GeneratorWorkspace({
Grid
<select
value={size}
onChange={(event) => setSize(Number(event.target.value))}
onChange={(event) => {
const nextSize = Number(event.target.value);
setSize(nextSize);
setAdditionalVariants((current) =>
current.filter((family) =>
(
GENERATOR_VARIANTS.find(({ id }) => id === family)
?.supportedSizes as readonly number[]
).includes(nextSize),
),
);
}}
>
{definition.supportedSizes.map((supportedSize) => (
<option key={supportedSize} value={supportedSize}>
@@ -137,6 +243,35 @@ export function GeneratorWorkspace({
))}
</select>
</label>
<fieldset className="generator-family-picker">
<legend>Mix in variant families</legend>
<div className="generator-family-options">
{GENERATOR_VARIANTS.filter(({ id }) => id !== variant).map(
(item) => {
const supported = (
item.supportedSizes as readonly number[]
).includes(size);
return (
<label key={item.id}>
<input
type="checkbox"
checked={additionalVariants.includes(item.id)}
disabled={!supported}
onChange={(event) =>
setAdditionalVariants((current) =>
event.target.checked
? [...current, item.id]
: current.filter((id) => id !== item.id),
)
}
/>
{item.label}
</label>
);
},
)}
</div>
</fieldset>
<label>
Requested profile
<select
@@ -159,10 +294,15 @@ export function GeneratorWorkspace({
<select
value={symmetry}
onChange={(event) =>
setSymmetry(event.target.value as "none" | "rotational")
setSymmetry(event.target.value as ClueSymmetry)
}
>
<option value="rotational">Rotational</option>
<option value="horizontal">Horizontal reflection</option>
<option value="vertical">Vertical reflection</option>
<option value="diagonal-main">Main diagonal</option>
<option value="diagonal-anti">Anti-diagonal</option>
<option value="orthogonal">Four-way orthogonal</option>
<option value="none">None</option>
</select>
</label>
@@ -180,6 +320,54 @@ export function GeneratorWorkspace({
/>
</label>
)}
{usesConstraintDensity && (
<label>
Constraint density
<select
value={constraintDensity}
onChange={(event) =>
setConstraintDensity(event.target.value as ConstraintDensity)
}
>
<option value="sparse">Sparse</option>
<option value="balanced">Balanced</option>
<option value="dense">Dense</option>
</select>
</label>
)}
<label className="checkbox-field">
<input
type="checkbox"
checked={minimalGivens}
onChange={(event) => setMinimalGivens(event.target.checked)}
/>
Prove minimal givens
</label>
<label>
Batch size
<input
type="number"
min="1"
max="12"
value={batchSize}
onChange={(event) => setBatchSize(Number(event.target.value))}
/>
</label>
{batchSize > 1 && (
<label>
Batch ranking
<select
value={batchRanking}
onChange={(event) =>
setBatchRanking(event.target.value as BatchRanking)
}
>
<option value="difficulty">Difficulty</option>
<option value="fewest-givens">Fewest givens</option>
<option value="most-givens">Most givens</option>
</select>
</label>
)}
<label>
Seed
<input
@@ -202,7 +390,8 @@ export function GeneratorWorkspace({
<option value="">No required technique</option>
{PRACTICE_TECHNIQUES.filter(
(technique) =>
technique !== "killer-cage" || variant === "killer",
technique !== "killer-cage" ||
selectedFamilies.includes("killer"),
).map((technique) => (
<option key={technique} value={technique}>
{techniqueLabel(technique)}
@@ -211,6 +400,75 @@ export function GeneratorWorkspace({
</select>
</label>
{requiredTechnique !== "" && (
<>
<label>
Minimum occurrences
<input
type="number"
min="1"
max="10000"
value={minimumTechniqueCount}
onChange={(event) =>
setMinimumTechniqueCount(Number(event.target.value))
}
/>
</label>
<label>
Maximum occurrences
<input
type="number"
min="0"
max="10000"
value={maximumTechniqueCount}
placeholder="no maximum"
onChange={(event) =>
setMaximumTechniqueCount(event.target.value)
}
/>
</label>
</>
)}
<label>
Forbidden technique
<select
value={forbiddenTechnique}
onChange={(event) =>
setForbiddenTechnique(
event.target.value as PracticeTechnique | "",
)
}
>
<option value="">None</option>
{PRACTICE_TECHNIQUES.map((technique) => (
<option key={technique} value={technique}>
{techniqueLabel(technique)}
</option>
))}
</select>
</label>
<label>
Hardest technique target
<select
value={hardestTechnique}
onChange={(event) =>
setHardestTechnique(
event.target.value as PracticeTechnique | "",
)
}
>
<option value="">Any</option>
{PRACTICE_TECHNIQUES.filter(
(technique) =>
technique !== "killer-cage" ||
selectedFamilies.includes("killer"),
).map((technique) => (
<option key={technique} value={technique}>
{techniqueLabel(technique)}
</option>
))}
</select>
</label>
{hasTechniqueProfile && (
<label>
Mining attempts
<input
@@ -228,8 +486,17 @@ export function GeneratorWorkspace({
<p className="generator-description">{definition.description}</p>
<div className="action-row">
<button className="primary-button" type="submit" disabled={busy}>
{busy ? "Working locally…" : `Generate ${definition.label}`}
{busy
? "Working locally…"
: batchSize > 1
? `Generate ${String(batchSize)}-puzzle batch`
: `Generate ${definition.label}`}
</button>
{busy && onCancel !== undefined && (
<button type="button" onClick={onCancel}>
Cancel generation
</button>
)}
<button type="button" disabled={busy} onClick={onRate}>
Rate current puzzle
</button>
@@ -237,8 +504,8 @@ export function GeneratorWorkspace({
<p className="muted">
Difficulty is an estimate from reproducible solver evidence, not a
universal promise. Technique practice only returns a puzzle whose
logical path contains the requested move. Uniqueness is never claimed
after a safety limit.
complete independently analysed path matches every requested rule.
Uniqueness and minimality are never claimed after a safety limit.
</p>
</form>
@@ -291,6 +558,16 @@ export function GeneratorWorkspace({
</strong>
</span>
)}
{generation && (
<span>
Minimality <strong>{generation.minimality.status}</strong>
</span>
)}
{generation?.techniqueProfile && (
<span>
Profile <strong>{generation.techniqueProfile.status}</strong>
</span>
)}
</div>
<p>{assessment.summary}</p>
{generation && (
@@ -298,8 +575,139 @@ export function GeneratorWorkspace({
Seed: <code>{String(generation.seed)}</code> · Found in{" "}
{generation.generationAttempts} attempt
{generation.generationAttempts === 1 ? "" : "s"}.
{generation.families.length > 1
? ` Families: ${generation.families.join(", ")}.`
: ""}
</p>
)}
{generation?.minimality.status === "proven-minimal" && (
<p className="muted">
Minimal-givens proof completed in{" "}
{generation.minimality.checksPerformed} bounded checks; every
remaining given is critical.
{!generation.minimality.symmetryPreserved
? " Individual minimization changed the requested clue symmetry."
: ""}
</p>
)}
{generation?.minimality.status === "unknown" && (
<p className="muted">
Minimality remains unknown after{" "}
{generation.minimality.checksPerformed} bounded checks. Unresolved
givens: {generation.minimality.unknownCells.length}. Reasons:{" "}
{generation.minimality.limitReasons
.map((reason) => reason.replaceAll("-", " "))
.join(", ") || "incomplete evidence"}
.
</p>
)}
{generation?.techniqueProfile && (
<p className="muted">
Independent logical path:{" "}
{generation.techniqueProfile.completePath
? "complete"
: "incomplete"}
{generation.techniqueProfile.requirements.length === 0
? ""
: ` · ${generation.techniqueProfile.requirements
.map(
({ technique, actual, minimum, maximum }) =>
`${techniqueLabel(technique)} ${String(actual)}${minimum === undefined ? "" : `${String(minimum)}`}${maximum === undefined ? "" : `${String(maximum)}`}`,
)
.join(" · ")}`}
{generation.techniqueProfile.requestedHardestTechnique ===
undefined
? ""
: ` · Hardest: ${techniqueLabel(generation.techniqueProfile.actualHardestTechnique)} (target ${techniqueLabel(generation.techniqueProfile.requestedHardestTechnique)})`}
.
</p>
)}
</section>
)}
{batch && (
<section className="panel-section generator-batch" aria-live="polite">
<div className="section-heading">
<div>
<p className="eyebrow">Ranked local batch</p>
<h3>
{batch.completed} of {batch.requested} generated
</h3>
</div>
<span className="status-pill">
{batch.ranking.replaceAll("-", " ")}
</span>
</div>
{batch.summaries.length === 0 ? (
<p>No candidate passed the bounded checks.</p>
) : (
<div className="setter-quality__table-wrap">
<table className="setter-quality__table">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Seed</th>
<th scope="col">Families</th>
<th scope="col">Givens</th>
<th scope="col">Score</th>
<th scope="col">Evidence</th>
<th scope="col">Open</th>
</tr>
</thead>
<tbody>
{batch.summaries.map((summary, index) => {
const entry = batch.entries[index];
return (
<tr key={String(summary.seed)}>
<th scope="row">{summary.rank}</th>
<td>
<code>{String(summary.seed)}</code>
</td>
<td>{summary.families.join(" + ")}</td>
<td>{summary.clueCount}</td>
<td>{summary.score ?? "—"}</td>
<td>
{summary.minimalityStatus}
{summary.profileStatus
? ` · ${summary.profileStatus}`
: ""}
</td>
<td>
<button
type="button"
disabled={entry === undefined}
onClick={() => {
if (entry !== undefined) {
onSelectGenerated?.(entry);
}
}}
>
Use
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{batch.failures.length > 0 && (
<details>
<summary>
{batch.failures.length} bounded candidate
{batch.failures.length === 1 ? "" : "s"} did not complete or
match the profile
</summary>
<ul className="muted">
{batch.failures.map((failure) => (
<li key={String(failure.seed)}>
<code>{String(failure.seed)}</code>: {failure.message}
</li>
))}
</ul>
</details>
)}
</section>
)}
</div>
+273
View File
@@ -0,0 +1,273 @@
import type { LogicalStep } from "../solver";
import {
GUIDED_HINT_STAGES,
guidedHintEffectItems,
guidedHintFocusSummary,
isGuidedHintStageRevealed,
logicalTechniqueDescription,
logicalTechniqueName,
nextGuidedHintStage,
type GuidedHintStage,
} from "./guidedHint";
const STAGE_LABELS: Record<GuidedHintStage, string> = {
focus: "Where to look",
technique: "Technique",
reasoning: "Reasoning",
preview: "Effects preview",
};
function revealButtonLabel(stage: GuidedHintStage): string | undefined {
const next = nextGuidedHintStage(stage);
if (next === "technique") return "Reveal technique";
if (next === "reasoning") return "Reveal reasoning";
if (next === "preview") return "Preview effects";
return undefined;
}
function stageStatus(stage: GuidedHintStage): string {
if (stage === "focus") return "Hint ready: where to look.";
if (stage === "technique") return "Technique revealed.";
if (stage === "reasoning") return "Reasoning revealed.";
return "Effects ready to preview and apply.";
}
export interface GuidedHintProps {
readonly size: number;
readonly step?: LogicalStep;
readonly stage: GuidedHintStage;
readonly busy?: boolean;
readonly error?: string;
readonly candidateTrackingActive?: boolean;
readonly autoMaintainPeerNotes: boolean;
readonly onRequestHint: () => void;
readonly onRevealNext: () => void;
readonly onApply: () => void;
readonly onDismiss: () => void;
readonly onFillLegalCandidates: () => void;
readonly onRemoveInvalidNotes: () => void;
readonly onAutoMaintainPeerNotesChange: (enabled: boolean) => void;
}
/**
* A controlled, progressively disclosed hint. The caller owns the hint step
* and stage so it can keep the board overlay and undo history in sync.
*/
export function GuidedHint({
size,
step,
stage,
busy = false,
error,
candidateTrackingActive = false,
autoMaintainPeerNotes,
onRequestHint,
onRevealNext,
onApply,
onDismiss,
onFillLegalCandidates,
onRemoveInvalidNotes,
onAutoMaintainPeerNotesChange,
}: GuidedHintProps) {
const nextLabel = step === undefined ? undefined : revealButtonLabel(stage);
const effectItems =
step !== undefined && stage === "preview"
? guidedHintEffectItems(step, size)
: [];
return (
<section className="guided-hint stack" aria-labelledby="guided-hint-title">
<div className="guided-hint__heading">
<div>
<p className="eyebrow">Guided solving</p>
<h3 id="guided-hint-title">One clue at a time</h3>
</div>
{step !== undefined && (
<button
type="button"
className="guided-hint__dismiss"
onClick={onDismiss}
disabled={busy}
>
Dismiss
</button>
)}
</div>
<p className="guided-hint__intro muted">
Reveal only as much help as you want. Nothing changes until you apply
the fully previewed step.
</p>
<p
className="guided-hint__status status-line"
role="status"
aria-live="polite"
aria-atomic="true"
>
{busy
? "Finding a logical next step locally…"
: error !== undefined
? "The hint could not be prepared."
: step === undefined
? "No guided hint is open."
: stageStatus(stage)}
</p>
{error !== undefined && (
<p className="guided-hint__error error-callout" role="alert">
{error}
</p>
)}
{step !== undefined && (
<>
<ol
className="guided-hint__progress"
aria-label="Hint reveal progress"
>
{GUIDED_HINT_STAGES.map((item) => {
const revealed = isGuidedHintStageRevealed(stage, item);
return (
<li
key={item}
className={revealed ? "is-revealed" : "is-concealed"}
aria-current={item === stage ? "step" : undefined}
>
{STAGE_LABELS[item]}
</li>
);
})}
</ol>
<div className="guided-hint__stage">
<section
className="guided-hint__focus"
aria-labelledby="hint-focus-title"
>
<p className="eyebrow" id="hint-focus-title">
Where to look
</p>
<p>{guidedHintFocusSummary(step, size)}</p>
</section>
{isGuidedHintStageRevealed(stage, "technique") && (
<section
className="guided-hint__technique"
aria-labelledby="hint-technique-title"
>
<p className="eyebrow" id="hint-technique-title">
Technique
</p>
<p>
<strong>{logicalTechniqueName(step.technique)}</strong>
</p>
<p className="muted">
{logicalTechniqueDescription(step.technique)}
</p>
</section>
)}
{isGuidedHintStageRevealed(stage, "reasoning") && (
<section
className="guided-hint__reasoning"
aria-labelledby="hint-reasoning-title"
>
<p className="eyebrow" id="hint-reasoning-title">
Why it works
</p>
<p>{step.explanation}</p>
</section>
)}
{stage === "preview" && (
<section
className="guided-hint__preview"
aria-labelledby="hint-preview-title"
>
<p className="eyebrow" id="hint-preview-title">
Effects preview
</p>
{effectItems.length > 0 ? (
<ul className="guided-hint__effect-list">
{effectItems.map((effect, index) => (
<li key={`${String(index)}-${effect}`}>{effect}</li>
))}
</ul>
) : (
<p>This deduction does not change the board.</p>
)}
{step.eliminations.length > 0 && !candidateTrackingActive && (
<p className="guided-hint__tracking-note muted">
Applying this elimination will start a complete legal
centre-candidate grid, then remove the previewed candidates.
</p>
)}
</section>
)}
</div>
<div className="guided-hint__actions action-row">
{nextLabel !== undefined && (
<button type="button" onClick={onRevealNext} disabled={busy}>
{nextLabel}
</button>
)}
{stage === "preview" && (
<button
type="button"
className="guided-hint__apply"
onClick={onApply}
disabled={busy || effectItems.length === 0}
>
Apply this step
</button>
)}
<button type="button" onClick={onRequestHint} disabled={busy}>
New hint
</button>
</div>
</>
)}
{step === undefined && (
<div className="guided-hint__actions action-row">
<button type="button" onClick={onRequestHint} disabled={busy}>
Get a guided hint
</button>
</div>
)}
<fieldset className="guided-hint__maintenance">
<legend>Candidate maintenance</legend>
<p className="muted" id="guided-candidate-status">
{candidateTrackingActive
? "The guided candidate grid is active."
: "Candidate tracking is currently inactive."}
</p>
<div
className="guided-hint__maintenance-actions action-row"
aria-describedby="guided-candidate-status"
>
<button type="button" onClick={onFillLegalCandidates} disabled={busy}>
Fill legal candidates
</button>
<button type="button" onClick={onRemoveInvalidNotes} disabled={busy}>
Remove invalid notes
</button>
</div>
<label className="guided-hint__auto-maintain check-row">
<input
type="checkbox"
checked={autoMaintainPeerNotes}
disabled={busy}
onChange={(event) =>
onAutoMaintainPeerNotesChange(event.currentTarget.checked)
}
/>
Automatically remove peer notes after placing a digit
</label>
</fieldset>
</section>
);
}
+134 -36
View File
@@ -14,12 +14,13 @@ export function HelpDialog({
<h3>Five complementary workspaces</h3>
<p>
<strong>Play</strong> keeps values, two kinds of notes, colours,
branches, replay and elapsed time. <strong>Set</strong> edits clues
and constraints. <strong>Generate</strong> constructs and rates
bounded, seedable variants. <strong>Solve</strong> explains logical
steps and can verify uniqueness. <strong>Helpers</strong> answers
focused sum, candidate and relation questions without changing the
board.
branches, replay, guided hints and elapsed time.{" "}
<strong>Set</strong> edits registry-backed clues and runs bounded
setter-quality checks. <strong>Generate</strong> constructs and
ranks bounded, seedable single or batch variants.{" "}
<strong>Solve</strong> explains logical steps and can verify
uniqueness. <strong>Helpers</strong> answers focused sum, candidate
and relation questions without changing the board.
</p>
</section>
<section>
@@ -65,16 +66,63 @@ export function HelpDialog({
<dt>Ctrl/ + click</dt>
<dd>Highlight every placed copy of that digit</dd>
</div>
<div>
<dt>M</dt>
<dd>Toggle tap-by-tap multi-selection</dd>
</div>
<div>
<dt>Ctrl/ + + / </dt>
<dd>Zoom the board while focus is in the board area</dd>
</div>
<div>
<dt>Ctrl/ + 0</dt>
<dd>Fit the board at its default scale</dd>
</div>
</dl>
</section>
<section>
<h3>Touch, zoom and selection</h3>
<p>
Board zoom is stored in this browser. Use <strong>Pan board</strong>
before dragging a zoomed board; its strong border and status message
indicate that cell taps are temporarily paused. Stop panning to edit
again. <strong>Tap multi-select</strong> toggles individual cells
without a drag gesture and keeps one active cell for keyboard entry.
On narrow screens the entry pad stays close to the bottom edge and
respects the device safe area.
</p>
</section>
<section>
<h3>Hints and solutions</h3>
<p>
Candidate legality is computed independently from handwritten notes.
Logical deductions report their premises, affected houses,
placements and eliminations. Exact search is separately labelled; it
proves feasibility or uniqueness but is not presented as a human
explanation.
A guided hint reveals where to look, the technique, its reasoning
and an effects preview in separate stages; the board changes only
after <strong>Apply this step</strong>. Candidate legality is
computed independently from handwritten notes. Controls can fill
legal centre candidates, prune invalid centre/corner notes and
optionally maintain peer notes after placements. Erasing a value
never invents candidates.
</p>
<p>
Logical deductions report premises, affected houses, placements and
eliminations for singles, subsets, intersections, fish, wings,
colouring, chains and Killer cages. Unique Rectangle is disabled
unless a completed exact search has already proved uniqueness. Exact
search is separately labelled and is not presented as a human
explanation; reaching a limit remains unknown.
</p>
</section>
<section>
<h3>Setter-quality checks</h3>
<p>
Quick analysis checks for zero, one or two solutions and can show
the cells where two completions differ. Full analysis uses bounded
deletion searches to classify givens and constraints as critical,
redundant or unknown, build a cell heatmap, or localise a
contradictory core. Optional minimality is claimed only when the
unique baseline and every required removal check complete. Per-check
and aggregate budgets are visible, and analysis can be cancelled
without turning a capped search into proof.
</p>
</section>
<section>
@@ -84,9 +132,10 @@ export function HelpDialog({
checkpoint, isolate a hypothesis, or inspect an earlier grid.
Discarding a hypothesis restores its exact starting state but keeps
the abandoned path available in replay. Replayed grids are read-only
until you return live or deliberately branch from that step. This
working history stays in the current browser session; save the
puzzle to the Library for durable puzzle progress.
until you return live or deliberately branch from that step.
Validated history is included in local autosaves and explicit
Library saves, so restoring that progress also restores its
savepoints and branches.
</p>
</section>
<section>
@@ -115,25 +164,44 @@ export function HelpDialog({
<section>
<h3>Variant and false clues</h3>
<p>
The setter supports cages, lines, pair clues, X-sums, skyscrapers,
quadruples and maximum cells. Enable Wrogn mode to make new local
clues false, or switch existing clues individually or as a batch.
Red dashed artwork and a mark identify false clues; Σ and
identify X-sum and skyscraper readings. A false multi-cell clue
often stays undecided until enough of its cells are known, so exact
searches for dense liar puzzles can be substantially slower.
The shared registry covers classic/irregular/extra regions;
diagonal, anti, disjoint and non-consecutive rules; cages, parity,
extrema and quadruples; thermo, arrow, renban, palindrome, between,
whisper, region-sum, modular, entropic, zipper and double-arrow
lines; Kropki, 5/10 and inequality pairs; X-sum, skyscraper,
little-killer and sandwich clues; clone regions and row/column/box
indexers.
</p>
<p>
Enable Wrogn mode to make a supported local clue false, or switch
existing clues individually or as a batch. Red dashed artwork and a
mark identify false clues; global house rules, extra regions and
fog are not given a misleading false mode. Fog is display-only and
can be set only with a complete solution which validates against the
current puzzle. Wrong entries reveal nothing and the trusted
solution remains in the local puzzle document.
</p>
</section>
<section>
<h3>Generation and ratings</h3>
<p>
Generation runs in a worker with explicit time and search limits. A
requested level guides clue removal; the reported rating is then
calculated independently from logical techniques, clue load and
bounded exact-search evidence. Practice mode deterministically mines
several candidates and succeeds only when the analysed solve path
contains the requested technique. A limit never becomes a false
uniqueness claim.
Generation runs in a worker with explicit time and search limits. It
supports single or deterministic batch generation, compatible mixed
families, sparse/balanced/dense local constraints and several
rotational, reflection and diagonal clue symmetries. Minimal-givens
mode reports proof or the exact unknown reason; individual
minimisation can break the requested visual symmetry and reports
that fact.
</p>
<p>
Technique profiles can require, forbid or count techniques and set
an exact hardest technique. A result is accepted only when an
independent complete logical path matches the whole profile and an
independent exact search proves uniqueness. Requested difficulty
guides clue removal; the reported 0100 rating is then calculated
from logical techniques, clue load and exact-search evidence. A
cancelled or bounded-out candidate remains a failure or unknown,
never a uniqueness/minimality claim.
</p>
</section>
<section>
@@ -142,19 +210,49 @@ export function HelpDialog({
Files, text, solving and generation stay in this browser. Compact
grids, project JSON, share fragments, supported f-puzzles,
SudokuPad/CTC inline data and supported Penpa+ long links are
decoded locally. Server short IDs are intentionally rejected. SVG,
PNG and PDF rendering also stays in the browser. Review an export
before sharing: titles, authors, rules, solutions, progress and
aid-mémoire entries may be included.
decoded locally. Server short IDs are intentionally rejected. The
compatibility check separates mapped Sudoku semantics, preserved
inert drawings, retained metadata and warnings before import. Source
identity and allowlisted drawings survive local edits and saves but
never become rules by appearance alone.
</p>
<p>
Export includes Sudoku Tools JSON/share data, f-puzzles, SudokuPad
JSON or self-contained SCL where representable, plus local SVG, PNG
and PDF rendering. Unsupported false/global semantics and visual
geometry are rejected rather than silently weakened. Review an
export before sharing: titles, authors, rules, solutions, progress,
source metadata, drawings and aid-mémoire entries may be included.
</p>
</section>
<section>
<h3>Recovery, Library and offline use</h3>
<p>
A separate debounced local autosave can restore or discard an
interrupted session, including validated bounded history, branches
and savepoints. Explicit Library projects offer title/tag search,
completion filters, safe previews and selected export, duplication
or deletion. Browser storage can be cleared independently, so export
important projects.
</p>
<p>
On a production HTTPS host, the first successful load can install a
relative-scope offline application shell. Service-worker support is
progressive enhancement: puzzle work stays local and remains usable
when registration is unavailable.
</p>
</section>
<section>
<h3>Screen-reader detail</h3>
<p>
Each Sudoku cell reports its row, column, region, value or notes,
colour, conflict state, candidate highlights and touching variant
clues. The board and aid-mémoire use real row and gridcell roles;
their keyboard instructions are attached to the grids.
Each visible Sudoku cell reports its row, column, region, value,
colour-and-pattern mark, conflict state and touching variant clues.
Candidate detail can be set to <strong>Off</strong>, concise counts,
or detailed digits without changing what is drawn. The board and
aid-mémoire use real row and gridcell roles; their keyboard
instructions are attached to the grids. Fogged cells report only
that they are obscured: hidden values, notes, clues, candidate
overlays and guided-hint steps are not exposed through board labels.
</p>
</section>
</div>
+141 -15
View File
@@ -3,8 +3,11 @@ import type { PuzzleDefinition } from "../domain/types";
import { normalizePuzzle } from "../domain/validation";
import {
encodePuzzleHash,
exportSudokuPadJson,
exportSudokuPadPayload,
exportFpuzzlesJson,
exportFpuzzlesUrl,
extractPreservedDocumentExtras,
fromDomainPuzzle,
importPuzzle,
renderPuzzlePdf,
@@ -13,6 +16,9 @@ import {
serializePlainGrid,
serializeSudokuDocument,
toDomainPuzzle,
type PreservedSudokuDocumentExtras,
type PuzzleImportMappingPreview,
type PuzzleImportResult,
type SudokuDocument,
} from "../formats";
import type { PlaySession } from "../state/session";
@@ -24,8 +30,9 @@ function withProgress(
puzzle: PuzzleDefinition,
session: PlaySession,
aidMemoire?: PortableAidMemoire,
preservedExtras?: PreservedSudokuDocumentExtras,
): SudokuDocument {
const base = fromDomainPuzzle(puzzle);
const base = fromDomainPuzzle(puzzle, preservedExtras);
return {
...base,
values: [...session.values],
@@ -64,11 +71,13 @@ function checkedPuzzle(document: SudokuDocument): PuzzleDefinition {
return normalizePuzzle(toDomainPuzzle(document) as PuzzleDefinition);
}
interface ImportExportDialogProps {
export interface ImportExportDialogProps {
open: boolean;
puzzle: PuzzleDefinition;
session: PlaySession;
aidMemoire?: PortableAidMemoire;
/** Source-only data retained while the domain puzzle is edited. */
preservedExtras?: PreservedSudokuDocumentExtras;
onClose: () => void;
onImport: (
puzzle: PuzzleDefinition,
@@ -82,14 +91,41 @@ interface ImportExportDialogProps {
| "elapsedMs"
| "aidMemoire"
>,
preservedExtras?: PreservedSudokuDocumentExtras,
) => void;
}
function PreviewList({
title,
entries,
}: {
readonly title: string;
readonly entries: PuzzleImportMappingPreview["mappedSemantics"];
}) {
return (
<div>
<h4>{title}</h4>
{entries.length === 0 ? (
<p className="muted">None.</p>
) : (
<ul>
{entries.map((entry) => (
<li key={entry.key}>
{entry.label}: {entry.count}
</li>
))}
</ul>
)}
</div>
);
}
export function ImportExportDialog({
open,
puzzle,
session,
aidMemoire,
preservedExtras,
onClose,
onImport,
}: ImportExportDialogProps) {
@@ -97,13 +133,17 @@ export function ImportExportDialog({
const [feedback, setFeedback] = useState("");
const [includeProgress, setIncludeProgress] = useState(true);
const [busy, setBusy] = useState(false);
const [inspected, setInspected] = useState<{
readonly input: string;
readonly result: PuzzleImportResult<SudokuDocument>;
}>();
const fileRef = useRef<HTMLInputElement>(null);
const documentValue = useMemo(
() =>
includeProgress
? withProgress(puzzle, session, aidMemoire)
: fromDomainPuzzle(puzzle),
[aidMemoire, includeProgress, puzzle, session],
? withProgress(puzzle, session, aidMemoire, preservedExtras)
: fromDomainPuzzle(puzzle, preservedExtras),
[aidMemoire, includeProgress, preservedExtras, puzzle, session],
);
const copy = async (value: string, label: string) => {
@@ -147,6 +187,7 @@ export function ImportExportDialog({
try {
const result = await importPuzzle(input);
checkedPuzzle(result.document);
setInspected({ input, result });
const givenCount = result.document.givens.filter(
(value) => value !== 0,
).length;
@@ -154,6 +195,7 @@ export function ImportExportDialog({
`${result.label}: ${String(result.document.size)}×${String(result.document.size)}, ${String(givenCount)} givens and ${String(result.document.constraints.length)} constraints. Compatible and ready to import.`,
);
} catch (error) {
setInspected(undefined);
setFeedback(errorMessage(error, "The puzzle could not be inspected."));
} finally {
setBusy(false);
@@ -163,17 +205,24 @@ export function ImportExportDialog({
const applyImport = async () => {
setBusy(true);
try {
const result = await importPuzzle(input);
const result =
inspected?.input === input
? inspected.result
: await importPuzzle(input);
const parsed = result.document;
onImport(checkedPuzzle(parsed), {
values: parsed.values,
cornerMarks: parsed.cornerMarks,
centerMarks: parsed.centerMarks,
candidates: parsed.candidates,
colors: parsed.colors,
elapsedMs: parsed.elapsedMs,
aidMemoire: parsed.aidMemoire,
});
onImport(
checkedPuzzle(parsed),
{
values: parsed.values,
cornerMarks: parsed.cornerMarks,
centerMarks: parsed.centerMarks,
candidates: parsed.candidates,
colors: parsed.colors,
elapsedMs: parsed.elapsedMs,
aidMemoire: parsed.aidMemoire,
},
extractPreservedDocumentExtras(parsed),
);
setFeedback(`${result.label} imported locally.`);
onClose();
} catch (error) {
@@ -239,6 +288,7 @@ export function ImportExportDialog({
placeholder="Paste 81 characters, JSON or a puzzle URL…"
onChange={(event) => {
setInput(event.target.value);
setInspected(undefined);
setFeedback("");
}}
/>
@@ -263,6 +313,7 @@ export function ImportExportDialog({
.text()
.then((contents) => {
setInput(contents);
setInspected(undefined);
setFeedback(
`${file.name} loaded locally; review and import it.`,
);
@@ -287,6 +338,41 @@ export function ImportExportDialog({
Import locally
</button>
</div>
{inspected?.input === input && (
<section
className="import-mapping-preview stack"
aria-label="Import mapping preview"
>
<div>
<p className="eyebrow">Mapping preview</p>
<h3>{inspected.result.label}</h3>
</div>
<PreviewList
title="Mapped semantics"
entries={inspected.result.preview.mappedSemantics}
/>
<PreviewList
title="Preserved visuals"
entries={inspected.result.preview.preservedVisuals}
/>
<PreviewList
title="Preserved metadata"
entries={inspected.result.preview.preservedMetadata}
/>
<div>
<h4>Warnings</h4>
{inspected.result.preview.warnings.length === 0 ? (
<p className="muted">None.</p>
) : (
<ul>
{inspected.result.preview.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
)}
</div>
</section>
)}
</section>
<section className="stack">
@@ -357,6 +443,46 @@ export function ImportExportDialog({
>
Copy f-puzzles URL
</button>
<button
type="button"
disabled={busy}
onClick={() =>
exportText(
"sudoku.scl.json",
"application/json",
"SudokuPad JSON",
() => exportSudokuPadJson(documentValue, true),
)
}
>
Download SudokuPad JSON
</button>
<button
type="button"
disabled={busy}
onClick={() =>
exportText(
"sudoku.scl",
"text/plain",
"SudokuPad SCL payload",
() => exportSudokuPadPayload(documentValue),
)
}
>
Download SudokuPad SCL payload
</button>
<button
type="button"
disabled={busy}
onClick={() =>
void copyExport(
() => exportSudokuPadPayload(documentValue),
"SudokuPad SCL payload",
)
}
>
Copy SudokuPad SCL payload
</button>
<button
type="button"
disabled={busy}
+263 -14
View File
@@ -1,4 +1,4 @@
import { useRef } from "react";
import { useMemo, useRef, useState } from "react";
import type { SudokuProjectSummary } from "../storage";
import { Modal } from "./Modal";
@@ -9,6 +9,26 @@ function date(value: number) {
}).format(new Date(value));
}
function LibraryThumbnail({ item }: { item: SudokuProjectSummary }) {
return (
<span
className="library-thumbnail"
style={{
gridTemplateColumns: `repeat(${String(item.size)}, 1fr)`,
fontSize: `${String(Math.max(0.18, Math.min(0.62, 2.7 / item.size)))}rem`,
}}
role="img"
aria-label={`${String(item.size)} by ${String(item.size)} puzzle preview`}
>
{[...item.thumbnail].map((symbol, index) => (
<span key={index} aria-hidden="true">
{symbol === "." ? "" : symbol}
</span>
))}
</span>
);
}
export function LibraryDialog({
open,
summaries,
@@ -21,6 +41,10 @@ export function LibraryDialog({
onDelete,
onClear,
onExport,
onExportSelected,
onDuplicateSelected,
onDeleteSelected,
onUpdateTags,
onImport,
}: {
open: boolean;
@@ -34,16 +58,73 @@ export function LibraryDialog({
onDelete: (id: string) => void;
onClear: () => void;
onExport: () => void;
onExportSelected: (ids: readonly string[]) => void;
onDuplicateSelected: (ids: readonly string[]) => void;
onDeleteSelected: (ids: readonly string[]) => void;
onUpdateTags: (id: string, tags: readonly string[]) => void;
onImport: (file: File) => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState("");
const [completion, setCompletion] = useState<"all" | "open" | "complete">(
"all",
);
const [tag, setTag] = useState("");
const [selected, setSelected] = useState<ReadonlySet<string>>(
() => new Set(),
);
const [editingTagsFor, setEditingTagsFor] = useState<string>();
const [tagDraft, setTagDraft] = useState("");
const allTags = useMemo(
() =>
[...new Set(summaries.flatMap((item) => item.tags))].sort((a, b) =>
a.localeCompare(b),
),
[summaries],
);
const filtered = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
return summaries.filter((item) => {
if (
query &&
!item.title.toLocaleLowerCase().includes(query) &&
!item.tags.some((value) => value.toLocaleLowerCase().includes(query))
) {
return false;
}
if (tag && !item.tags.includes(tag)) return false;
return (
completion === "all" ||
(completion === "complete" ? item.completed : !item.completed)
);
});
}, [completion, search, summaries, tag]);
const knownIds = useMemo(
() => new Set(summaries.map((item) => item.id)),
[summaries],
);
const selectedIds = useMemo(
() => [...selected].filter((id) => knownIds.has(id)),
[knownIds, selected],
);
const toggleSelected = (id: string) => {
setSelected((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<Modal open={open} title="Local puzzle library" onClose={onClose} wide>
<div className="library-toolbar">
<div>
<p>
{mode === "indexeddb"
? "Saved in this browser profile."
? "Saved in this browser profile. Working changes are autosaved separately."
: "IndexedDB is unavailable; saves last only for this open tab."}
</p>
<p className="muted">
@@ -81,38 +162,206 @@ export function LibraryDialog({
/>
</div>
</div>
<div className="library-filters" aria-label="Filter saved puzzles">
<label>
<span>Search</span>
<input
type="search"
value={search}
placeholder="Title or tag"
onChange={(event) => setSearch(event.target.value)}
/>
</label>
<label>
<span>Status</span>
<select
aria-label="Completion filter"
value={completion}
onChange={(event) =>
setCompletion(event.target.value as typeof completion)
}
>
<option value="all">All progress</option>
<option value="open">In progress</option>
<option value="complete">Complete</option>
</select>
</label>
<label>
<span>Tag</span>
<select value={tag} onChange={(event) => setTag(event.target.value)}>
<option value="">All tags</option>
{allTags.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</label>
</div>
{feedback && (
<p className="status-line" role="status">
{feedback}
</p>
)}
{summaries.length > 0 && (
<div className="library-selection-toolbar">
<span>
{String(selectedIds.length)} selected · {String(filtered.length)}{" "}
shown
</span>
<div className="action-row">
<button
type="button"
className="text-button"
disabled={busy || filtered.length === 0}
onClick={() =>
setSelected(new Set(filtered.map((item) => item.id)))
}
>
Select shown
</button>
<button
type="button"
className="text-button"
disabled={busy || selectedIds.length === 0}
onClick={() => setSelected(new Set())}
>
Clear selection
</button>
<button
type="button"
disabled={busy || selectedIds.length === 0}
onClick={() => onExportSelected(selectedIds)}
>
Export selected
</button>
<button
type="button"
disabled={busy || selectedIds.length === 0}
onClick={() => onDuplicateSelected(selectedIds)}
>
Duplicate selected
</button>
<button
type="button"
className="danger"
disabled={busy || selectedIds.length === 0}
onClick={() => onDeleteSelected(selectedIds)}
>
Delete selected
</button>
</div>
</div>
)}
{summaries.length === 0 ? (
<div className="empty-state">
<h3>No saved puzzles</h3>
<p>Save the current puzzle to build a local library.</p>
</div>
) : filtered.length === 0 ? (
<div className="empty-state">
<h3>No matching puzzles</h3>
<p>Adjust the title, tag or progress filters.</p>
</div>
) : (
<ul className="library-list">
{summaries.map((item) => (
{filtered.map((item) => (
<li key={item.id}>
<label className="library-selector">
<input
type="checkbox"
checked={selected.has(item.id)}
aria-label={`Select ${item.title || "Untitled puzzle"}`}
onChange={() => toggleSelected(item.id)}
/>
</label>
<button
className="library-open"
type="button"
onClick={() => onOpen(item.id)}
>
<strong>{item.title || "Untitled puzzle"}</strong>
<span>
{item.size}×{item.size} · {date(item.updatedAt)}
{item.completed ? " · complete" : ""}
<LibraryThumbnail item={item} />
<span className="library-copy">
<strong>{item.title || "Untitled puzzle"}</strong>
<span>
{item.size}×{item.size} · {date(item.updatedAt)}
{item.completed ? " · complete" : " · in progress"}
</span>
</span>
</button>
<button
className="text-button danger"
type="button"
onClick={() => onDelete(item.id)}
>
Delete
</button>
<div className="library-item-actions">
<div className="library-tags" aria-label="Project tags">
{item.tags.map((value) => (
<button
key={value}
type="button"
className="tag-chip"
onClick={() => setTag(value)}
>
{value}
</button>
))}
</div>
{editingTagsFor === item.id ? (
<form
className="library-tag-editor"
onSubmit={(event) => {
event.preventDefault();
onUpdateTags(
item.id,
tagDraft
.split(",")
.map((value) => value.trim())
.filter(Boolean),
);
setEditingTagsFor(undefined);
}}
>
<label>
<span className="sr-only">Comma-separated tags</span>
<input
aria-label={`Tags for ${item.title || "Untitled puzzle"}`}
value={tagDraft}
maxLength={400}
placeholder="classic, hard"
onChange={(event) => setTagDraft(event.target.value)}
/>
</label>
<button type="submit" disabled={busy}>
Apply
</button>
<button
type="button"
className="text-button"
onClick={() => setEditingTagsFor(undefined)}
>
Cancel
</button>
</form>
) : (
<button
className="text-button"
type="button"
onClick={() => {
setEditingTagsFor(item.id);
setTagDraft(item.tags.join(", "));
}}
>
Edit tags
</button>
)}
<button
className="text-button danger"
type="button"
onClick={() => onDelete(item.id)}
>
Delete
</button>
</div>
</li>
))}
</ul>
+4 -1
View File
@@ -1,5 +1,6 @@
import type { EntryMode } from "../state/session";
import { symbolFor } from "../state/session";
import { colorMarkDescription } from "../state/uiPreferences";
const modes: Array<{ mode: EntryMode; label: string; key: string }> = [
{ mode: "value", label: "Value", key: "Z" },
@@ -56,7 +57,9 @@ export function NumberPad({
{mode === "color" ? (
<>
<span aria-hidden="true" />{" "}
<span className="sr-only">Colour {value}</span>
<span className="sr-only">
Colour {value}: {colorMarkDescription(value)}
</span>
</>
) : (
symbolFor(value, size)
+139
View File
@@ -0,0 +1,139 @@
import type { SVGAttributes } from "react";
import type {
SafeVisualAnchor,
SafeVisualPrimitive,
SafeVisualStyle,
} from "../formats";
export interface SafeVisualLayerProps {
readonly size: number;
readonly visuals: readonly SafeVisualPrimitive[];
readonly layer: "underlay" | "overlay";
}
function anchorPoint(size: number, anchor: SafeVisualAnchor) {
if (anchor.kind === "coordinate") {
return { x: anchor.x, y: anchor.y };
}
return {
x: (anchor.cell % size) + 0.5 + (anchor.offsetX ?? 0),
y: Math.floor(anchor.cell / size) + 0.5 + (anchor.offsetY ?? 0),
};
}
function visualStyle(
style: SafeVisualStyle | undefined,
): SVGAttributes<SVGElement> {
return {
stroke: style?.stroke ?? "transparent",
fill: style?.fill ?? "transparent",
...(style?.strokeWidth === undefined
? {}
: { strokeWidth: style.strokeWidth }),
...(style?.opacity === undefined ? {} : { opacity: style.opacity }),
};
}
/**
* Render already-normalized inert drawings with typed SVG properties only.
* Text is supplied as a React text node; no source markup is ever interpreted.
*/
export function SafeVisualLayer({
size,
visuals,
layer,
}: SafeVisualLayerProps) {
return (
<g
className={`safe-visual-layer safe-visual-layer--${layer}`}
data-visual-layer={layer}
>
{visuals.map((visual, index) => {
if (visual.layer !== layer) return null;
const common = {
...visualStyle(visual.style),
className: `source-visual source-visual--${visual.type}`,
"data-visual": index,
};
if (visual.type === "line") {
const start = anchorPoint(size, visual.start);
const end = anchorPoint(size, visual.end);
return (
<line
key={index}
{...common}
x1={start.x}
y1={start.y}
x2={end.x}
y2={end.y}
/>
);
}
if (visual.type === "polyline") {
const points = visual.points
.map((anchor) => anchorPoint(size, anchor))
.map(({ x, y }) => `${String(x)},${String(y)}`)
.join(" ");
return visual.closed === true ? (
<polygon key={index} {...common} points={points} />
) : (
<polyline key={index} {...common} points={points} />
);
}
if (visual.type === "rectangle") {
const center = anchorPoint(size, visual.center);
return (
<rect
key={index}
{...common}
x={center.x - visual.width / 2}
y={center.y - visual.height / 2}
width={visual.width}
height={visual.height}
rx={visual.cornerRadius}
/>
);
}
if (visual.type === "ellipse") {
const center = anchorPoint(size, visual.center);
return (
<ellipse
key={index}
{...common}
cx={center.x}
cy={center.y}
rx={visual.radiusX}
ry={visual.radiusY}
/>
);
}
if (visual.type === "circle") {
const center = anchorPoint(size, visual.center);
return (
<circle
key={index}
{...common}
cx={center.x}
cy={center.y}
r={visual.radius}
/>
);
}
const position = anchorPoint(size, visual.position);
return (
<text
key={index}
{...common}
x={position.x}
y={position.y}
fontSize={visual.style?.fontSize}
textAnchor="middle"
dominantBaseline="central"
>
{visual.text}
</text>
);
})}
</g>
);
}
+740
View File
@@ -0,0 +1,740 @@
import { useState, type FormEvent } from "react";
import type {
PuzzleQualityAnalysis,
QualityItemAssessment,
QualityItemReference,
analyzePuzzleQuality,
} from "../solver/quality";
import { symbolFor } from "../state/session";
export type SetterQualityRunOptions = NonNullable<
Parameters<typeof analyzePuzzleQuality>[1]
>;
export type SetterQualityRunKind = "quick" | "minimality";
export interface SetterQualityLabProps {
readonly size: number;
readonly result?: PuzzleQualityAnalysis;
readonly running?: SetterQualityRunKind;
readonly error?: string;
readonly initialOptions?: Partial<SetterQualityRunOptions>;
readonly onRunQuick: (options: SetterQualityRunOptions) => void;
readonly onRunMinimality: (options: SetterQualityRunOptions) => void;
readonly onCancel: () => void;
readonly onFocusCells: (cells: readonly number[]) => void;
readonly onFocusItem: (item: QualityItemReference) => void;
}
interface QualityControls {
readonly perCheckMaxNodes: number;
readonly perCheckTimeoutMs: number;
readonly aggregateMaxChecks: number;
readonly aggregateMaxNodes: number;
readonly aggregateTimeoutMs: number;
}
const DEFAULT_CONTROLS: QualityControls = {
perCheckMaxNodes: 2_000_000,
perCheckTimeoutMs: 10_000,
aggregateMaxChecks: 1_000,
aggregateMaxNodes: 20_000_000,
aggregateTimeoutMs: 30_000,
};
const CONTROL_MAXIMUMS: Record<keyof QualityControls, number> = {
perCheckMaxNodes: 100_000_000,
perCheckTimeoutMs: 120_000,
aggregateMaxChecks: 20_000,
aggregateMaxNodes: 2_000_000_000,
aggregateTimeoutMs: 600_000,
};
function boundedPositiveInteger(
value: string,
fallback: number,
maximum: number,
): number {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0
? Math.min(parsed, maximum)
: fallback;
}
function humanize(value: string): string {
return value
.replaceAll(/([a-z])([A-Z])/gu, "$1 $2")
.replaceAll("-", " ")
.replace(/^./u, (first) => first.toUpperCase());
}
function cellName(cell: number, size: number): string {
return `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
}
function itemLabel(item: QualityItemReference, size: number): string {
if (item.kind === "given") {
return `Given ${symbolFor(item.value, size)} at ${cellName(item.cell, size)}`;
}
return `Constraint ${String(item.index + 1)} · ${humanize(item.constraintType)}`;
}
function itemKey(item: QualityItemReference): string {
return item.kind === "given"
? `given-${String(item.cell)}`
: `constraint-${String(item.index)}`;
}
function ItemList({
title,
items,
size,
emptyLabel,
onFocusItem,
}: {
title: string;
items: readonly QualityItemReference[];
size: number;
emptyLabel: string;
onFocusItem: (item: QualityItemReference) => void;
}) {
return (
<section className="setter-quality__item-group">
<h5>{title}</h5>
{items.length === 0 ? (
<p className="muted">{emptyLabel}</p>
) : (
<ul className="setter-quality__item-list">
{items.map((item) => (
<li key={itemKey(item)}>
<button type="button" onClick={() => onFocusItem(item)}>
{itemLabel(item, size)}
</button>
</li>
))}
</ul>
)}
</section>
);
}
function AssessmentTable({
title,
assessments,
size,
onFocusItem,
}: {
title: string;
assessments: readonly QualityItemAssessment[];
size: number;
onFocusItem: (item: QualityItemReference) => void;
}) {
const counts = {
critical: assessments.filter(
({ classification }) => classification === "critical",
).length,
redundant: assessments.filter(
({ classification }) => classification === "redundant",
).length,
unknown: assessments.filter(
({ classification }) => classification === "unknown",
).length,
};
return (
<section className="setter-quality__assessments">
<div className="section-heading">
<h4>{title}</h4>
<p className="setter-quality__counts muted">
{counts.critical} critical · {counts.redundant} redundant ·{" "}
{counts.unknown} unknown
</p>
</div>
{assessments.length === 0 ? (
<p className="muted">
No {title.toLowerCase()} were available to test.
</p>
) : (
<div className="setter-quality__table-wrap">
<table className="setter-quality__table">
<thead>
<tr>
<th scope="col">Clue</th>
<th scope="col">Finding</th>
<th scope="col">Evidence</th>
</tr>
</thead>
<tbody>
{assessments.map((assessment) => (
<tr
key={itemKey(assessment.item)}
className={`is-${assessment.classification}`}
>
<th scope="row">
<button
type="button"
onClick={() => onFocusItem(assessment.item)}
>
{itemLabel(assessment.item, size)}
</button>
</th>
<td>
<strong>{humanize(assessment.classification)}</strong>
</td>
<td>
{assessment.classification === "unknown"
? `Unknown / incomplete${assessment.unknownReason ? `: ${humanize(assessment.unknownReason)}` : ""}`
: assessment.solutionStatus
? `Without clue: ${humanize(assessment.solutionStatus)}`
: "Completed bounded check"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
function solutionTitle(
status: PuzzleQualityAnalysis["solutionStatus"],
): string {
if (status === "unique") return "Unique completion proven";
if (status === "multiple") return "Multiple completions found";
if (status === "unsatisfiable") return "No completion exists";
return "Solution status unknown";
}
function solutionDescription(
status: PuzzleQualityAnalysis["solutionStatus"],
): string {
if (status === "unique")
return "The bounded exact check completed and proved one solution.";
if (status === "multiple")
return "Two different completions are enough to prove ambiguity.";
if (status === "unsatisfiable")
return "The bounded exact check completed without finding any valid completion.";
return "Incomplete / unknown: a configured safety bound was reached before the solution status could be proved.";
}
function QualityResults({
size,
result,
onFocusCells,
onFocusItem,
}: {
size: number;
result: PuzzleQualityAnalysis;
onFocusCells: (cells: readonly number[]) => void;
onFocusItem: (item: QualityItemReference) => void;
}) {
const witness = result.ambiguityWitness;
const minimality = result.minimality;
return (
<div className="setter-quality__results stack">
<section className="setter-quality__solution analysis-summary">
<div className="section-heading">
<div>
<p className="eyebrow">Solution status</p>
<h3>{solutionTitle(result.solutionStatus)}</h3>
</div>
<span
className={`status-pill status-${result.solutionStatus}`}
aria-label={`Solution status: ${humanize(result.solutionStatus)}`}
>
{humanize(result.solutionStatus)}
</span>
</div>
<p>{solutionDescription(result.solutionStatus)}</p>
</section>
{witness !== undefined && (
<section
className="setter-quality__witness panel-section"
aria-labelledby="quality-witness-title"
>
<div className="section-heading">
<div>
<p className="eyebrow">Ambiguity witness</p>
<h4 id="quality-witness-title">
{witness.differences.length} differing cell
{witness.differences.length === 1 ? "" : "s"}
</h4>
</div>
<button
type="button"
disabled={witness.differences.length === 0}
onClick={() =>
onFocusCells(witness.differences.map(({ cell }) => cell))
}
>
Focus differences
</button>
</div>
<p className="muted">
These values come from two concrete valid completions; they are not
guesses.
</p>
<div className="setter-quality__table-wrap">
<table className="setter-quality__table">
<thead>
<tr>
<th scope="col">Cell</th>
<th scope="col">Solution A</th>
<th scope="col">Solution B</th>
</tr>
</thead>
<tbody>
{witness.differences.map(({ cell, first, second }) => (
<tr key={cell}>
<th scope="row">
<button
type="button"
onClick={() => onFocusCells([cell])}
>
{cellName(cell, size)}
</button>
</th>
<td>{symbolFor(first, size)}</td>
<td>{symbolFor(second, size)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
<section
className={`setter-quality__contradiction panel-section status-${result.contradiction.status}`}
aria-labelledby="quality-contradiction-title"
>
<div className="section-heading">
<div>
<p className="eyebrow">Contradiction localization</p>
<h4 id="quality-contradiction-title">
{result.contradiction.status === "localized"
? "Contradiction suspects localized"
: result.contradiction.status === "incomplete"
? "Incomplete / unknown"
: "Not applicable"}
</h4>
</div>
<span className="status-pill">
{humanize(result.contradiction.status)}
</span>
</div>
{result.contradiction.reason && <p>{result.contradiction.reason}</p>}
{result.contradiction.status === "not-applicable" && (
<p className="muted">
Contradiction localization only applies after unsatisfiability is
proved.
</p>
)}
<div className="setter-quality__contradiction-groups">
<ItemList
title="Retained suspect core"
items={result.contradiction.core}
size={size}
emptyLabel="No retained suspects were established."
onFocusItem={onFocusItem}
/>
<ItemList
title="Proven necessary suspects"
items={result.contradiction.necessary}
size={size}
emptyLabel="No individual suspect was proved necessary."
onFocusItem={onFocusItem}
/>
<ItemList
title="Removable while still contradictory"
items={result.contradiction.removable}
size={size}
emptyLabel="No removable suspects were established."
onFocusItem={onFocusItem}
/>
<ItemList
title="Unknown at safety bounds"
items={result.contradiction.unknown}
size={size}
emptyLabel="No unresolved suspects."
onFocusItem={onFocusItem}
/>
</div>
</section>
<section className="setter-quality__redundancy panel-section">
<p className="eyebrow">Leave-one-out evidence</p>
<h3>Clue criticality and redundancy</h3>
<p className="muted">
Critical means removing that clue admits another completion. Redundant
means uniqueness was proved without it. Unknown always remains unknown
when a bound interrupts proof.
</p>
{result.analysisDepth === "baseline" ? (
<p className="setter-quality__not-analysed">
Not analysed in the quick baseline. Run the full bounded minimality
analysis to classify individual clues.
</p>
) : (
<>
<AssessmentTable
title="Givens"
assessments={result.redundancy.givens}
size={size}
onFocusItem={onFocusItem}
/>
<AssessmentTable
title="Constraints"
assessments={result.redundancy.constraints}
size={size}
onFocusItem={onFocusItem}
/>
</>
)}
</section>
<section
className="setter-quality__heatmap panel-section"
aria-labelledby="quality-heatmap-title"
>
<p className="eyebrow">Criticality heatmap</p>
<h3 id="quality-heatmap-title">Cell evidence</h3>
<p className="muted" id="quality-heatmap-help">
Every button states its evidence, so colour is never the only signal.
Select a cell to focus it on the puzzle.
</p>
{result.criticalityHeatmap.length === 0 ? (
<p>
{result.analysisDepth === "baseline"
? "Not analysed in the quick baseline."
: "No cell-level evidence is available."}
</p>
) : (
<div
className="setter-quality__heatmap-grid"
role="group"
aria-label="Cell criticality heatmap"
aria-describedby="quality-heatmap-help"
style={{
gridTemplateColumns: `repeat(${String(size)}, minmax(0, 1fr))`,
}}
>
{result.criticalityHeatmap.map((entry) => {
const evidenceClass =
entry.score === null
? "unknown"
: entry.criticalWeight > entry.redundantWeight
? "critical"
: entry.redundantWeight > entry.criticalWeight
? "redundant"
: entry.criticalWeight + entry.redundantWeight > 0
? "mixed"
: "neutral";
const score =
entry.score === null ? "unknown" : entry.score.toFixed(2);
const label = `${cellName(entry.cell, size)}: ${evidenceClass}; score ${score}; critical weight ${String(entry.criticalWeight)}, redundant weight ${String(entry.redundantWeight)}, unknown weight ${String(entry.unknownWeight)}`;
return (
<button
key={entry.cell}
type="button"
className={`setter-quality__heat-cell is-${evidenceClass}`}
aria-label={label}
title={label}
onClick={() => onFocusCells([entry.cell])}
>
<span>{cellName(entry.cell, size)}</span>
<strong>
{entry.score === null ? "?" : entry.score.toFixed(1)}
</strong>
</button>
);
})}
</div>
)}
</section>
<section className="setter-quality__minimality panel-section">
<div className="section-heading">
<div>
<p className="eyebrow">Bounded minimality</p>
<h3>
{minimality === undefined
? "Not requested"
: humanize(minimality.status)}
</h3>
</div>
<span className="status-pill">
{minimality === undefined ? "Not run" : humanize(minimality.status)}
</span>
</div>
{minimality === undefined ? (
<p>
Minimality is unknown because the full bounded minimality pass was
not requested.
</p>
) : (
<>
{minimality.reason && <p>{minimality.reason}</p>}
<ItemList
title="Redundant clues"
items={minimality.redundant}
size={size}
emptyLabel="No redundant clue was proved."
onFocusItem={onFocusItem}
/>
<ItemList
title="Unknown clues"
items={minimality.unknown}
size={size}
emptyLabel="No clue remained unknown."
onFocusItem={onFocusItem}
/>
</>
)}
</section>
<section className="setter-quality__metrics panel-section">
<p className="eyebrow">Bounded-search record</p>
<h3>Checks and limits</h3>
<dl className="setter-quality__metric-list">
<div>
<dt>Analysis depth</dt>
<dd>
{result.analysisDepth === "baseline"
? "Quick baseline"
: "Full setter QC"}
</dd>
</div>
<div>
<dt>Analysis completeness</dt>
<dd>
{result.budget.truncated
? "Incomplete / unknown conclusions remain"
: "All requested checks completed"}
</dd>
</div>
<div>
<dt>Checks performed / planned</dt>
<dd>
{result.budget.checksPerformed.toLocaleString()} /{" "}
{result.budget.checksPlanned.toLocaleString()}
</dd>
</div>
<div>
<dt>Recorded check details</dt>
<dd>{result.checks.length.toLocaleString()}</dd>
</div>
<div>
<dt>Nodes searched</dt>
<dd>{result.budget.nodes.toLocaleString()}</dd>
</div>
<div>
<dt>Elapsed</dt>
<dd>{result.budget.elapsedMs.toLocaleString()} ms</dd>
</div>
<div>
<dt>Unknown reasons</dt>
<dd>
{result.budget.unknownReasons.length === 0
? "None"
: result.budget.unknownReasons.map(humanize).join(", ")}
</dd>
</div>
<div>
<dt>Per-check node / time bound</dt>
<dd>
{result.bounds.perCheck.maxNodes.toLocaleString()} nodes /{" "}
{result.bounds.perCheck.timeoutMs.toLocaleString()} ms
</dd>
</div>
<div>
<dt>Aggregate check / node / time bound</dt>
<dd>
{result.bounds.aggregate.maxChecks.toLocaleString()} checks /{" "}
{result.bounds.aggregate.maxNodes.toLocaleString()} nodes /{" "}
{result.bounds.aggregate.timeoutMs.toLocaleString()} ms
</dd>
</div>
</dl>
</section>
</div>
);
}
export function SetterQualityLab({
size,
result,
running,
error,
initialOptions,
onRunQuick,
onRunMinimality,
onCancel,
onFocusCells,
onFocusItem,
}: SetterQualityLabProps) {
const [controls, setControls] = useState<QualityControls>(() => ({
perCheckMaxNodes:
initialOptions?.perCheckMaxNodes ?? DEFAULT_CONTROLS.perCheckMaxNodes,
perCheckTimeoutMs:
initialOptions?.perCheckTimeoutMs ?? DEFAULT_CONTROLS.perCheckTimeoutMs,
aggregateMaxChecks:
initialOptions?.aggregateMaxChecks ?? DEFAULT_CONTROLS.aggregateMaxChecks,
aggregateMaxNodes:
initialOptions?.aggregateMaxNodes ?? DEFAULT_CONTROLS.aggregateMaxNodes,
aggregateTimeoutMs:
initialOptions?.aggregateTimeoutMs ?? DEFAULT_CONTROLS.aggregateTimeoutMs,
}));
const options = (
analysisDepth: "baseline" | "full",
proveMinimality: boolean,
): SetterQualityRunOptions => ({
...controls,
analysisDepth,
proveMinimality,
});
const runQuick = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
onRunQuick(options("baseline", false));
};
const setControl = (key: keyof QualityControls, value: string) => {
setControls((current) => ({
...current,
[key]: boundedPositiveInteger(value, current[key], CONTROL_MAXIMUMS[key]),
}));
};
return (
<section
className="setter-quality stack"
aria-labelledby="setter-quality-title"
>
<div>
<p className="eyebrow">Setter quality</p>
<h2 id="setter-quality-title">Uniqueness, redundancy and minimality</h2>
<p className="muted">
Every claim is backed by a bounded local exact-search check. Reaching
a limit produces an explicit unknown result, never a guessed verdict.
</p>
</div>
<form
className="setter-quality__controls panel-section"
onSubmit={runQuick}
>
<div className="field-grid">
<label>
Nodes per check
<input
type="number"
min="1"
max="100000000"
value={controls.perCheckMaxNodes}
disabled={running !== undefined}
onChange={(event) =>
setControl("perCheckMaxNodes", event.target.value)
}
/>
</label>
<label>
Time per check (ms)
<input
type="number"
min="1"
max="120000"
value={controls.perCheckTimeoutMs}
disabled={running !== undefined}
onChange={(event) =>
setControl("perCheckTimeoutMs", event.target.value)
}
/>
</label>
<label>
Maximum checks
<input
type="number"
min="1"
max="20000"
value={controls.aggregateMaxChecks}
disabled={running !== undefined}
onChange={(event) =>
setControl("aggregateMaxChecks", event.target.value)
}
/>
</label>
<label>
Total node budget
<input
type="number"
min="1"
max="2000000000"
value={controls.aggregateMaxNodes}
disabled={running !== undefined}
onChange={(event) =>
setControl("aggregateMaxNodes", event.target.value)
}
/>
</label>
<label>
Total time budget (ms)
<input
type="number"
min="1"
max="600000"
value={controls.aggregateTimeoutMs}
disabled={running !== undefined}
onChange={(event) =>
setControl("aggregateTimeoutMs", event.target.value)
}
/>
</label>
</div>
<div className="setter-quality__actions action-row">
<button type="submit" disabled={running !== undefined}>
Run quick quality check
</button>
<button
type="button"
disabled={running !== undefined}
onClick={() => onRunMinimality(options("full", true))}
>
Run full bounded minimality
</button>
{running !== undefined && (
<button type="button" onClick={onCancel}>
Cancel analysis
</button>
)}
</div>
</form>
{running !== undefined && (
<p
className="setter-quality__running status-line"
role="status"
aria-live="polite"
>
{running === "quick"
? "Quick quality check running locally…"
: "Full bounded minimality analysis running locally…"}
</p>
)}
{error !== undefined && (
<p className="setter-quality__error error-callout" role="alert">
{error}
</p>
)}
{result !== undefined && (
<QualityResults
size={size}
result={result}
onFocusCells={onFocusCells}
onFocusItem={onFocusItem}
/>
)}
</section>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+60
View File
@@ -0,0 +1,60 @@
import type { FogConstraint, NormalizedPuzzle } from "../domain";
/**
* Resolve display-only Fog of War visibility without changing puzzle rules.
* A trusted correct entry and every given act as reveal sources in addition to
* the authored lights.
*/
export function foggedCellsForPuzzle(
puzzle: NormalizedPuzzle,
values: readonly number[],
): ReadonlySet<number> {
const fogConstraints = puzzle.constraints.filter(
(constraint): constraint is FogConstraint => constraint.type === "fog",
);
if (fogConstraints.length === 0) return new Set<number>();
const correctEntries = values.flatMap((value, cell) =>
value !== 0 && puzzle.solution?.[cell] === value ? [cell] : [],
);
const givens = puzzle.givens.flatMap((value, cell) =>
value === 0 ? [] : [cell],
);
const visible = new Set<number>();
for (const constraint of fogConstraints) {
const radius = constraint.revealRadius ?? 1;
const sources = new Set([
...constraint.lights,
...givens,
...correctEntries,
]);
for (const source of sources) {
const row = Math.floor(source / puzzle.size);
const column = source % puzzle.size;
for (let rowOffset = -radius; rowOffset <= radius; rowOffset += 1) {
for (
let columnOffset = -radius;
columnOffset <= radius;
columnOffset += 1
) {
const targetRow = row + rowOffset;
const targetColumn = column + columnOffset;
if (
targetRow >= 0 &&
targetRow < puzzle.size &&
targetColumn >= 0 &&
targetColumn < puzzle.size
) {
visible.add(targetRow * puzzle.size + targetColumn);
}
}
}
}
}
return new Set(
Array.from({ length: puzzle.size * puzzle.size }, (_, cell) => cell).filter(
(cell) => !visible.has(cell),
),
);
}
+223
View File
@@ -0,0 +1,223 @@
import type { LogicalStep, LogicalTechnique } from "../solver";
import { symbolFor } from "../state/session";
export const GUIDED_HINT_STAGES = [
"focus",
"technique",
"reasoning",
"preview",
] as const;
export type GuidedHintStage = (typeof GUIDED_HINT_STAGES)[number];
export interface GuidedHintCellSets {
readonly focusCells: readonly number[];
readonly placementCells: readonly number[];
readonly eliminationCells: readonly number[];
readonly affectedCells: readonly number[];
}
export interface GuidedHintOverlay {
readonly focusCells: readonly number[];
readonly placementCells: readonly number[];
readonly eliminationCells: readonly number[];
}
const TECHNIQUE_DESCRIPTIONS: Partial<Record<LogicalTechnique, string>> = {
"naked-single": "A cell has only one legal candidate left.",
"hidden-single": "A digit has only one possible position in a house.",
"naked-pair":
"Two cells reserve the same two candidates, excluding them elsewhere in their house.",
"naked-triple":
"Three cells reserve three candidates, excluding them elsewhere in their house.",
"naked-quad":
"Four cells reserve four candidates, excluding them elsewhere in their house.",
"hidden-pair": "Two digits can occur in only the same two cells of a house.",
"hidden-triple":
"Three digits can occur in only the same three cells of a house.",
"hidden-quad":
"Four digits can occur in only the same four cells of a house.",
pointing:
"A candidate confined to one line inside a region can be removed farther along that line.",
claiming:
"A candidate confined to one region along a line can be removed from the rest of that region.",
"x-wing":
"Two matching rows or columns lock a candidate into two opposite positions.",
swordfish:
"Three matching rows or columns lock a candidate into three crossing lines.",
"xy-wing":
"Three linked bivalue cells force a shared candidate out of cells that see both wings.",
"xyz-wing":
"A three-candidate pivot and two wings force their shared candidate elsewhere.",
"killer-cage":
"A cage's remaining sum and legal combinations restrict its unsolved cells.",
};
function uniqueCells(cells: readonly number[]): number[] {
return [...new Set(cells)].filter(
(cell) => Number.isInteger(cell) && cell >= 0,
);
}
function naturalList(items: readonly string[]): string {
if (items.length <= 1) return items[0] ?? "";
if (items.length === 2) return `${items[0]} and ${items[1]}`;
return `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
}
export function guidedHintStageIndex(stage: GuidedHintStage): number {
return GUIDED_HINT_STAGES.indexOf(stage);
}
export function nextGuidedHintStage(
stage: GuidedHintStage,
): GuidedHintStage | undefined {
return GUIDED_HINT_STAGES[guidedHintStageIndex(stage) + 1];
}
export function isGuidedHintStageRevealed(
current: GuidedHintStage,
stage: GuidedHintStage,
): boolean {
return guidedHintStageIndex(current) >= guidedHintStageIndex(stage);
}
export function logicalTechniqueName(technique: LogicalTechnique): string {
return technique
.split("-")
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join(" ");
}
export function logicalTechniqueDescription(
technique: LogicalTechnique,
): string {
return (
TECHNIQUE_DESCRIPTIONS[technique] ??
`This ${logicalTechniqueName(technique).toLowerCase()} pattern creates a logical deduction.`
);
}
export function guidedHintCellName(cell: number, size: number): string {
return `r${String(Math.floor(cell / size) + 1)}c${String((cell % size) + 1)}`;
}
export function deriveGuidedHintCellSets(
step: LogicalStep,
): GuidedHintCellSets {
const focusCells = uniqueCells(step.focusCells);
const placementCells = uniqueCells(
step.placements.map((placement) => placement.cell),
);
const eliminationCells = uniqueCells(
step.eliminations.map((elimination) => elimination.cell),
);
return {
focusCells,
placementCells,
eliminationCells,
affectedCells: uniqueCells([...placementCells, ...eliminationCells]),
};
}
/**
* A guided step is safe to disclose only when every premise and every effect
* is currently visible. This deliberately includes focus cells: an otherwise
* harmless-looking technique name or explanation can reveal a hidden clue.
*/
export function guidedHintStepIsVisible(
step: LogicalStep,
hiddenCells: ReadonlySet<number>,
): boolean {
const { focusCells, placementCells, eliminationCells } =
deriveGuidedHintCellSets(step);
return [...focusCells, ...placementCells, ...eliminationCells].every(
(cell) => !hiddenCells.has(cell),
);
}
/**
* Returns only the cells that may be visualised at the current disclosure
* stage. In particular, effect cells stay absent until the preview stage.
*/
export function guidedHintOverlay(
step: LogicalStep,
stage: GuidedHintStage,
): GuidedHintOverlay {
const cells = deriveGuidedHintCellSets(step);
return {
focusCells: cells.focusCells,
placementCells: stage === "preview" ? cells.placementCells : [],
eliminationCells: stage === "preview" ? cells.eliminationCells : [],
};
}
export function guidedHintFocusSummary(
step: LogicalStep,
size: number,
): string {
const { focusCells, affectedCells } = deriveGuidedHintCellSets(step);
const cells = focusCells.length > 0 ? focusCells : affectedCells;
if (cells.length === 0) return "Review the current candidate grid.";
if (cells.length === 1) {
return `Look closely at ${guidedHintCellName(cells[0]!, size)}.`;
}
const rows = new Set(cells.map((cell) => Math.floor(cell / size)));
if (rows.size === 1) {
return `Look across row ${String((Math.floor(cells[0]! / size) || 0) + 1)}.`;
}
const columns = new Set(cells.map((cell) => cell % size));
if (columns.size === 1) {
return `Look down column ${String(((cells[0] ?? 0) % size) + 1)}.`;
}
const boxSize = Math.sqrt(size);
if (Number.isInteger(boxSize)) {
const boxes = new Set(
cells.map((cell) => {
const row = Math.floor(cell / size);
const column = cell % size;
return (
Math.floor(row / boxSize) * boxSize + Math.floor(column / boxSize)
);
}),
);
if (boxes.size === 1) {
return `Look within box ${String((boxes.values().next().value as number) + 1)}.`;
}
}
if (cells.length <= 4) {
return `Compare ${naturalList(cells.map((cell) => guidedHintCellName(cell, size)))}.`;
}
return `Compare the ${String(cells.length)} highlighted cells.`;
}
export function guidedHintEffectItems(
step: LogicalStep,
size: number,
): readonly string[] {
const placements = step.placements.map(
({ cell, value }) =>
`Place ${symbolFor(value, size)} in ${guidedHintCellName(cell, size)}.`,
);
const eliminations = step.eliminations.map(({ cell, values }) => {
const symbols = values.map((value) => symbolFor(value, size));
const object =
symbols.length === 1
? symbols[0]
: naturalList(symbols.map((symbol) => String(symbol)));
return `Remove ${object} from ${guidedHintCellName(cell, size)}.`;
});
return [...placements, ...eliminations];
}
export function guidedHintEffectSummary(
step: LogicalStep,
size: number,
): string {
const effects = guidedHintEffectItems(step, size);
if (effects.length === 0) return "This deduction does not change the board.";
return effects.join(" ");
}
+35 -62
View File
@@ -1,12 +1,11 @@
import { constraintCells } from "./constraintRegistry";
import { cellColumn, cellRow, orthogonalNeighbours } from "./geometry";
import type {
CellId,
NormalizedPuzzle,
OutsideClueSide,
VariantConstraint,
} from "./types";
import type { CellId, NormalizedPuzzle } from "./types";
export type UnitKind = "row" | "column" | "region" | "diagonal";
export { outsideLineCells } from "./geometry";
export type UnitKind =
"row" | "column" | "region" | "diagonal" | "disjoint-group" | "extra-region";
export interface SudokuUnit {
readonly kind: UnitKind;
@@ -30,63 +29,18 @@ function addPeerPair(peers: Set<CellId>[], a: CellId, b: CellId): void {
peers[b]?.add(a);
}
/** Cells seen from an outside clue, ordered from the clue into the grid. */
export function outsideLineCells(
size: number,
side: OutsideClueSide,
index: number,
function disjointGroupCells(
puzzle: NormalizedPuzzle,
position: number,
): CellId[] {
return Array.from({ length: size }, (_, offset) => {
switch (side) {
case "top":
return offset * size + index;
case "right":
return index * size + size - offset - 1;
case "bottom":
return (size - offset - 1) * size + index;
case "left":
return index * size + offset;
}
const regions = Array.from({ length: puzzle.size }, () => [] as CellId[]);
puzzle.regions.forEach((region, cell) => regions[region]?.push(cell));
return regions.flatMap((cells) => {
const cell = cells[position];
return cell === undefined ? [] : [cell];
});
}
function cellsForConstraint(
size: number,
constraint: VariantConstraint,
): readonly CellId[] {
switch (constraint.type) {
case "diagonal":
return Array.from({ length: size }, (_, index) =>
constraint.direction === "main"
? index * size + index
: index * size + size - index - 1,
);
case "anti-knight":
case "anti-king":
case "non-consecutive":
return Array.from({ length: size * size }, (_, cell) => cell);
case "killer-cage":
case "thermo":
case "renban":
case "palindrome":
return constraint.cells;
case "arrow":
return [...constraint.bulb, ...constraint.line];
case "kropki":
case "xv":
return [constraint.a, constraint.b];
case "inequality":
return [constraint.lesser, constraint.greater];
case "x-sum":
case "skyscraper":
return outsideLineCells(size, constraint.side, constraint.index);
case "quadruple":
return constraint.cells;
case "maximum":
return [constraint.cell, ...orthogonalNeighbours(size, constraint.cell)];
}
}
export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle {
const { size } = puzzle;
const count = size * size;
@@ -115,9 +69,28 @@ export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle {
units.push({
kind: "diagonal",
index: constraint.direction === "main" ? 0 : 1,
cells: cellsForConstraint(size, constraint),
cells: constraintCells(size, constraint),
});
}
if (puzzle.constraints.some(({ type }) => type === "disjoint-groups")) {
for (let position = 0; position < size; position += 1) {
units.push({
kind: "disjoint-group",
index: position,
cells: disjointGroupCells(puzzle, position),
});
}
}
let extraRegionIndex = 0;
for (const constraint of puzzle.constraints) {
if (constraint.type !== "extra-region") continue;
units.push({
kind: "extra-region",
index: extraRegionIndex,
cells: constraint.cells,
});
extraRegionIndex += 1;
}
const peers = Array.from({ length: count }, () => new Set<CellId>());
const unitsByCell = Array.from({ length: count }, () => [] as number[]);
@@ -130,7 +103,7 @@ export function compilePuzzle(puzzle: NormalizedPuzzle): CompiledPuzzle {
const constraintsByCell = Array.from({ length: count }, () => [] as number[]);
puzzle.constraints.forEach((constraint, constraintIndex) => {
for (const cell of new Set(cellsForConstraint(size, constraint))) {
for (const cell of constraintCells(size, constraint)) {
constraintsByCell[cell]?.push(constraintIndex);
}
if (
+482
View File
@@ -0,0 +1,482 @@
import {
correspondingBoxPositionCells,
littleKillerCells,
orthogonalNeighbours,
outsideLineCells,
} from "./geometry";
import type { CellId, VariantConstraint } from "./types";
export type ConstraintType = VariantConstraint["type"];
export type ConstraintCategory =
"global" | "region" | "line" | "adjacency" | "outside" | "cell";
export type ConstraintFieldKind =
| "discriminator"
| "boolean"
| "integer"
| "enum"
| "cell"
| "cells"
| "integers"
| "outside-side";
export interface ConstraintFieldMetadata {
readonly key: string;
readonly kind: ConstraintFieldKind;
readonly required: boolean;
}
export interface ConstraintRegistryEntry<
Type extends ConstraintType = ConstraintType,
> {
readonly type: Type;
readonly label: string;
readonly category: ConstraintCategory;
readonly negatable: boolean;
readonly fields: readonly ConstraintFieldMetadata[];
readonly cells: (
size: number,
constraint: Extract<VariantConstraint, { readonly type: Type }>,
) => readonly CellId[];
}
type ConstraintRegistry = {
readonly [Type in ConstraintType]: ConstraintRegistryEntry<Type>;
};
const typeField: ConstraintFieldMetadata = {
key: "type",
kind: "discriminator",
required: true,
};
const negatedField: ConstraintFieldMetadata = {
key: "negated",
kind: "boolean",
required: false,
};
const allCells = (size: number): CellId[] =>
Array.from({ length: size * size }, (_, cell) => cell);
const cellField = (key = "cell"): ConstraintFieldMetadata => ({
key,
kind: "cell",
required: true,
});
const cellsField = (key = "cells"): ConstraintFieldMetadata => ({
key,
kind: "cells",
required: true,
});
const integerField = (key: string): ConstraintFieldMetadata => ({
key,
kind: "integer",
required: true,
});
const enumField = (key: string): ConstraintFieldMetadata => ({
key,
kind: "enum",
required: true,
});
const outsideFields = (
valueField: string,
): readonly ConstraintFieldMetadata[] => [
typeField,
{ key: "side", kind: "outside-side", required: true },
integerField("index"),
integerField(valueField),
negatedField,
];
function entry<Type extends ConstraintType>(
value: ConstraintRegistryEntry<Type>,
): ConstraintRegistryEntry<Type> {
return value;
}
export const CONSTRAINT_REGISTRY = {
diagonal: entry({
type: "diagonal",
label: "Diagonal",
category: "global",
negatable: false,
fields: [typeField, enumField("direction")],
cells: (size, constraint) =>
Array.from({ length: size }, (_, index) =>
constraint.direction === "main"
? index * size + index
: index * size + size - index - 1,
),
}),
"anti-knight": entry({
type: "anti-knight",
label: "Anti-knight",
category: "global",
negatable: false,
fields: [typeField],
cells: (size) => allCells(size),
}),
"anti-king": entry({
type: "anti-king",
label: "Anti-king",
category: "global",
negatable: false,
fields: [typeField],
cells: (size) => allCells(size),
}),
"non-consecutive": entry({
type: "non-consecutive",
label: "Non-consecutive",
category: "global",
negatable: false,
fields: [typeField],
cells: (size) => allCells(size),
}),
"disjoint-groups": entry({
type: "disjoint-groups",
label: "Disjoint groups",
category: "global",
negatable: false,
fields: [typeField],
cells: (size) => allCells(size),
}),
"killer-cage": entry({
type: "killer-cage",
label: "Killer cage",
category: "region",
negatable: true,
fields: [
typeField,
cellsField(),
integerField("sum"),
{ key: "noRepeat", kind: "boolean", required: false },
negatedField,
],
cells: (_size, constraint) => constraint.cells,
}),
thermo: entry({
type: "thermo",
label: "Thermometer",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
arrow: entry({
type: "arrow",
label: "Arrow",
category: "line",
negatable: true,
fields: [typeField, cellsField("bulb"), cellsField("line"), negatedField],
cells: (_size, constraint) => [...constraint.bulb, ...constraint.line],
}),
kropki: entry({
type: "kropki",
label: "Kropki dot",
category: "adjacency",
negatable: true,
fields: [
typeField,
cellField("a"),
cellField("b"),
enumField("kind"),
negatedField,
],
cells: (_size, constraint) => [constraint.a, constraint.b],
}),
xv: entry({
type: "xv",
label: "XV pair",
category: "adjacency",
negatable: true,
fields: [
typeField,
cellField("a"),
cellField("b"),
integerField("total"),
negatedField,
],
cells: (_size, constraint) => [constraint.a, constraint.b],
}),
inequality: entry({
type: "inequality",
label: "Inequality",
category: "adjacency",
negatable: true,
fields: [
typeField,
cellField("lesser"),
cellField("greater"),
negatedField,
],
cells: (_size, constraint) => [constraint.lesser, constraint.greater],
}),
renban: entry({
type: "renban",
label: "Renban line",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
palindrome: entry({
type: "palindrome",
label: "Palindrome line",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
"x-sum": entry({
type: "x-sum",
label: "X-sum",
category: "outside",
negatable: true,
fields: outsideFields("sum"),
cells: (size, constraint) =>
outsideLineCells(size, constraint.side, constraint.index),
}),
skyscraper: entry({
type: "skyscraper",
label: "Skyscraper",
category: "outside",
negatable: true,
fields: outsideFields("count"),
cells: (size, constraint) =>
outsideLineCells(size, constraint.side, constraint.index),
}),
quadruple: entry({
type: "quadruple",
label: "Quadruple",
category: "cell",
negatable: true,
fields: [
typeField,
cellsField(),
{ key: "digits", kind: "integers", required: true },
negatedField,
],
cells: (_size, constraint) => constraint.cells,
}),
maximum: entry({
type: "maximum",
label: "Maximum cell",
category: "cell",
negatable: true,
fields: [typeField, cellField(), negatedField],
cells: (size, constraint) => [
constraint.cell,
...orthogonalNeighbours(size, constraint.cell),
],
}),
minimum: entry({
type: "minimum",
label: "Minimum cell",
category: "cell",
negatable: true,
fields: [typeField, cellField(), negatedField],
cells: (size, constraint) => [
constraint.cell,
...orthogonalNeighbours(size, constraint.cell),
],
}),
odd: entry({
type: "odd",
label: "Odd cell",
category: "cell",
negatable: true,
fields: [typeField, cellField(), negatedField],
cells: (_size, constraint) => [constraint.cell],
}),
even: entry({
type: "even",
label: "Even cell",
category: "cell",
negatable: true,
fields: [typeField, cellField(), negatedField],
cells: (_size, constraint) => [constraint.cell],
}),
"little-killer": entry({
type: "little-killer",
label: "Little killer",
category: "outside",
negatable: true,
fields: [
typeField,
{ key: "side", kind: "outside-side", required: true },
integerField("index"),
enumField("direction"),
integerField("sum"),
negatedField,
],
cells: (size, constraint) =>
littleKillerCells(
size,
constraint.side,
constraint.index,
constraint.direction,
),
}),
sandwich: entry({
type: "sandwich",
label: "Sandwich sum",
category: "outside",
negatable: true,
fields: outsideFields("sum"),
cells: (size, constraint) =>
outsideLineCells(size, constraint.side, constraint.index),
}),
"between-line": entry({
type: "between-line",
label: "Between line",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
"german-whisper": entry({
type: "german-whisper",
label: "German whisper",
category: "line",
negatable: true,
fields: [
typeField,
cellsField(),
{ key: "minimumDifference", kind: "integer", required: false },
negatedField,
],
cells: (_size, constraint) => constraint.cells,
}),
"region-sum-line": entry({
type: "region-sum-line",
label: "Region-sum line",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
clone: entry({
type: "clone",
label: "Clone regions",
category: "region",
negatable: true,
fields: [typeField, cellsField(), cellsField("cloneCells"), negatedField],
cells: (_size, constraint) => [
...constraint.cells,
...constraint.cloneCells,
],
}),
"extra-region": entry({
type: "extra-region",
label: "Extra region",
category: "region",
negatable: false,
fields: [typeField, cellsField()],
cells: (_size, constraint) => constraint.cells,
}),
"modular-line": entry({
type: "modular-line",
label: "Modular line",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
"entropic-line": entry({
type: "entropic-line",
label: "Entropic line",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
"zipper-line": entry({
type: "zipper-line",
label: "Zipper line",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
"double-arrow": entry({
type: "double-arrow",
label: "Double arrow",
category: "line",
negatable: true,
fields: [typeField, cellsField(), negatedField],
cells: (_size, constraint) => constraint.cells,
}),
indexer: entry({
type: "indexer",
label: "Indexer",
category: "cell",
negatable: true,
fields: [typeField, enumField("kind"), cellField(), negatedField],
cells: (size, constraint) => {
const row = Math.floor(constraint.cell / size);
const column = constraint.cell % size;
if (constraint.kind === "row") {
return Array.from(
{ length: size },
(_, targetRow) => targetRow * size + column,
);
}
if (constraint.kind === "column") {
return Array.from(
{ length: size },
(_, targetColumn) => row * size + targetColumn,
);
}
return correspondingBoxPositionCells(size, constraint.cell);
},
}),
fog: entry({
type: "fog",
label: "Fog of war",
category: "global",
negatable: false,
fields: [
typeField,
cellsField("lights"),
{ key: "revealRadius", kind: "integer", required: false },
],
cells: (_size, constraint) => constraint.lights,
}),
} satisfies ConstraintRegistry;
export const CONSTRAINT_TYPES = Object.freeze(
Object.keys(CONSTRAINT_REGISTRY) as ConstraintType[],
);
export function isConstraintType(value: string): value is ConstraintType {
return Object.hasOwn(CONSTRAINT_REGISTRY, value);
}
export function constraintMetadata<Type extends ConstraintType>(
type: Type,
): ConstraintRegistryEntry<Type> {
return CONSTRAINT_REGISTRY[type] as unknown as ConstraintRegistryEntry<Type>;
}
export function constraintLabel(type: string): string {
if (isConstraintType(type)) return CONSTRAINT_REGISTRY[type].label;
return type
.split("-")
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join(" ");
}
export function constraintAllowedFields(
type: ConstraintType,
): ReadonlySet<string> {
return new Set(CONSTRAINT_REGISTRY[type].fields.map(({ key }) => key));
}
export function constraintCells(
size: number,
constraint: VariantConstraint,
): readonly CellId[] {
const resolver = CONSTRAINT_REGISTRY[constraint.type].cells as (
size: number,
constraint: never,
) => readonly CellId[];
return [...new Set(resolver(size, constraint as never))];
}
+143 -7
View File
@@ -3,6 +3,8 @@ import {
MAX_PUZZLE_SIZE,
MIN_PUZZLE_SIZE,
type CellId,
type LittleKillerDirection,
type OutsideClueSide,
type PuzzleDefinition,
} from "./types";
@@ -46,15 +48,17 @@ export function assertCell(size: number, cell: CellId): void {
}
}
/**
* Builds conventional rectangular regions. For non-composite sizes this falls
* back to 1 x size regions, which is still a valid Latin-square topology.
*/
export function classicRegions(
export interface ClassicBoxDimensions {
readonly rows: number;
readonly columns: number;
readonly boxesPerRow: number;
}
export function classicBoxDimensions(
size: number,
boxRows?: number,
boxColumns?: number,
): number[] {
): ClassicBoxDimensions {
assertSize(size);
let rows = boxRows;
let columns = boxColumns;
@@ -84,8 +88,24 @@ export function classicRegions(
"Box rows and columns must be positive factors whose product is size.",
);
}
return { rows, columns, boxesPerRow: size / columns };
}
/**
* Builds conventional rectangular regions. For non-composite sizes this falls
* back to 1 x size regions, which is still a valid Latin-square topology.
*/
export function classicRegions(
size: number,
boxRows?: number,
boxColumns?: number,
): number[] {
const { rows, columns, boxesPerRow } = classicBoxDimensions(
size,
boxRows,
boxColumns,
);
const regions = new Array<number>(size * size);
const boxesPerRow = size / columns;
for (let row = 0; row < size; row += 1) {
for (let column = 0; column < size; column += 1) {
regions[row * size + column] =
@@ -95,6 +115,52 @@ export function classicRegions(
return regions;
}
export function classicBoxIndex(size: number, cell: CellId): number {
assertCell(size, cell);
const { rows, columns, boxesPerRow } = classicBoxDimensions(size);
return (
Math.floor(cellRow(size, cell) / rows) * boxesPerRow +
Math.floor(cellColumn(size, cell) / columns)
);
}
export function classicBoxPosition(size: number, cell: CellId): number {
assertCell(size, cell);
const { rows, columns } = classicBoxDimensions(size);
return (
(cellRow(size, cell) % rows) * columns + (cellColumn(size, cell) % columns)
);
}
export function classicBoxCell(
size: number,
boxIndex: number,
position: number,
): CellId {
const { rows, columns, boxesPerRow } = classicBoxDimensions(size);
if (!Number.isInteger(boxIndex) || boxIndex < 0 || boxIndex >= size) {
throw new RangeError("Box index is outside the grid.");
}
if (!Number.isInteger(position) || position < 0 || position >= size) {
throw new RangeError("Box position is outside the grid.");
}
const boxRow = Math.floor(boxIndex / boxesPerRow);
const boxColumn = boxIndex % boxesPerRow;
const row = boxRow * rows + Math.floor(position / columns);
const column = boxColumn * columns + (position % columns);
return cellId(size, row, column);
}
export function correspondingBoxPositionCells(
size: number,
cell: CellId,
): CellId[] {
const position = classicBoxPosition(size, cell);
return Array.from({ length: size }, (_, box) =>
classicBoxCell(size, box, position),
);
}
export function createEmptyPuzzle(
size = 9,
options: {
@@ -127,6 +193,76 @@ export function orthogonalNeighbours(size: number, cell: CellId): CellId[] {
return result;
}
/** Cells seen from an outside clue, ordered from the clue into the grid. */
export function outsideLineCells(
size: number,
side: OutsideClueSide,
index: number,
): CellId[] {
assertSize(size);
if (!Number.isInteger(index) || index < 0 || index >= size) return [];
return Array.from({ length: size }, (_, offset) => {
switch (side) {
case "top":
return offset * size + index;
case "right":
return index * size + size - offset - 1;
case "bottom":
return (size - offset - 1) * size + index;
case "left":
return index * size + offset;
}
});
}
export function littleKillerDirectionEntersGrid(
side: OutsideClueSide,
direction: LittleKillerDirection,
): boolean {
switch (side) {
case "top":
return direction === "down-left" || direction === "down-right";
case "right":
return direction === "down-left" || direction === "up-left";
case "bottom":
return direction === "up-left" || direction === "up-right";
case "left":
return direction === "down-right" || direction === "up-right";
}
}
/** Diagonal cells crossed by a little-killer clue, from its outside origin. */
export function littleKillerCells(
size: number,
side: OutsideClueSide,
index: number,
direction: LittleKillerDirection,
): CellId[] {
assertSize(size);
if (!Number.isInteger(index) || index < 0 || index >= size) return [];
const [rowStep, columnStep] = (() => {
switch (direction) {
case "down-right":
return [1, 1] as const;
case "down-left":
return [1, -1] as const;
case "up-right":
return [-1, 1] as const;
case "up-left":
return [-1, -1] as const;
}
})();
let row = side === "top" ? 0 : side === "bottom" ? size - 1 : index;
let column = side === "left" ? 0 : side === "right" ? size - 1 : index;
const cells: CellId[] = [];
while (row >= 0 && row < size && column >= 0 && column < size) {
cells.push(row * size + column);
row += rowStep;
column += columnStep;
}
return cells;
}
/** True when four cells meet at one internal grid intersection. */
export function cellsFormQuadruple(
size: number,
+1
View File
@@ -1,4 +1,5 @@
export * from "./compile";
export * from "./constraintRegistry";
export * from "./geometry";
export * from "./rules";
export * from "./types";
+536 -82
View File
@@ -1,9 +1,14 @@
import { compilePuzzle, type CompiledPuzzle } from "./compile";
import { constraintCells } from "./constraintRegistry";
import {
compilePuzzle,
classicBoxCell,
classicBoxIndex,
classicBoxPosition,
classicRegions,
littleKillerCells,
orthogonalNeighbours,
outsideLineCells,
type CompiledPuzzle,
} from "./compile";
import { orthogonalNeighbours } from "./geometry";
} from "./geometry";
import type {
CellId,
NormalizedPuzzle,
@@ -255,33 +260,382 @@ function quadrupleCanMeet(
return missing <= blanks;
}
function maximumCanMeet(
function extremumCanMeet(
cell: CellId,
values: readonly number[],
size: number,
kind: "maximum" | "minimum",
): boolean {
const maximum = values[cell] ?? 0;
const extremum = values[cell] ?? 0;
const neighbours = orthogonalNeighbours(size, cell).map(
(neighbour) => values[neighbour] ?? 0,
);
if (maximum === 0) {
return neighbours.every((value) => value === 0 || value < size);
if (extremum === 0) {
return neighbours.every((value) =>
value === 0 ? true : kind === "maximum" ? value < size : value > 1,
);
}
return neighbours.every((value) =>
value === 0 ? maximum > 1 : value < maximum,
return neighbours.every((value) => {
if (value === 0) return kind === "maximum" ? extremum > 1 : extremum < size;
return kind === "maximum" ? value < extremum : value > extremum;
});
}
function littleKillerCanMeet(
values: readonly number[],
cells: readonly CellId[],
size: number,
target: number,
): boolean {
const [minimum, maximum] = sumsCanMeet(values, cells, size);
return target >= minimum && target <= maximum;
}
function sandwichPossibleSums(
values: readonly number[],
cells: readonly CellId[],
size: number,
): ReadonlySet<number> {
const line = valuesAt(values, cells);
const fixed = line.filter((value) => value !== 0);
if (
fixed.some((value) => value < 1 || value > size) ||
new Set(fixed).size !== fixed.length
) {
return new Set();
}
const fixedOne = line.indexOf(1);
const fixedMaximum = line.indexOf(size);
const possible = new Set<number>();
for (let one = 0; one < size; one += 1) {
if (fixedOne >= 0 && one !== fixedOne) continue;
if (line[one] !== 0 && line[one] !== 1) continue;
for (let maximum = 0; maximum < size; maximum += 1) {
if (maximum === one) continue;
if (fixedMaximum >= 0 && maximum !== fixedMaximum) continue;
if (line[maximum] !== 0 && line[maximum] !== size) continue;
const start = Math.min(one, maximum) + 1;
const end = Math.max(one, maximum);
let assignedSum = 0;
let blanks = 0;
for (let position = start; position < end; position += 1) {
const value = line[position] ?? 0;
if (value === 0) blanks += 1;
else assignedSum += value;
}
const reserved = new Set(fixed);
reserved.add(1);
reserved.add(size);
const available = Array.from(
{ length: Math.max(0, size - 2) },
(_, index) => index + 2,
).filter((digit) => !reserved.has(digit));
const maximumExtra = available.reduce((sum, digit) => sum + digit, 0);
for (let extra = 0; extra <= maximumExtra; extra += 1) {
if (canChooseDistinctSum(available, blanks, extra)) {
possible.add(assignedSum + extra);
}
}
}
}
return possible;
}
function betweenLineCanMeet(
cells: readonly CellId[],
values: readonly number[],
size: number,
negated: boolean,
): boolean {
const firstFixed = values[cells[0] ?? -1] ?? 0;
const lastFixed = values[cells.at(-1) ?? -1] ?? 0;
const choices = (fixed: number): readonly number[] =>
fixed === 0
? Array.from({ length: size }, (_, index) => index + 1)
: [fixed];
for (const first of choices(firstFixed)) {
for (const last of choices(lastFixed)) {
const low = Math.min(first, last);
const high = Math.max(first, last);
if (high - low < 2) {
if (negated) return true;
continue;
}
let positivePossible = true;
let negativePossible = false;
for (const cell of cells.slice(1, -1)) {
const value = values[cell] ?? 0;
if (value === 0) {
negativePossible = true;
continue;
}
if (value <= low || value >= high) {
positivePossible = false;
negativePossible = true;
}
}
if (negated ? negativePossible : positivePossible) return true;
}
}
return false;
}
function germanWhisperCanMeet(
cells: readonly CellId[],
values: readonly number[],
size: number,
minimumDifference: number,
requireViolation: boolean,
): boolean {
const choices = (cell: CellId): readonly number[] => {
const fixed = values[cell] ?? 0;
return fixed === 0
? Array.from({ length: size }, (_, index) => index + 1)
: [fixed];
};
let states = new Set(choices(cells[0] ?? -1).map((digit) => `${digit}:0`));
for (let position = 1; position < cells.length; position += 1) {
const next = new Set<string>();
for (const state of states) {
const [previousText, violationText] = state.split(":");
const previous = Number(previousText);
const violated = violationText === "1";
for (const digit of choices(cells[position] ?? -1)) {
const pairViolates = Math.abs(previous - digit) < minimumDifference;
if (!requireViolation && pairViolates) continue;
next.add(`${digit}:${violated || pairViolates ? "1" : "0"}`);
}
}
states = next;
if (states.size === 0) return false;
}
return requireViolation
? [...states].some((state) => state.endsWith(":1"))
: states.size > 0;
}
function regionLineSegments(
cells: readonly CellId[],
regions: readonly number[],
): readonly (readonly CellId[])[] {
const segments: CellId[][] = [];
for (const cell of cells) {
const previous = segments.at(-1);
const previousCell = previous?.at(-1);
if (
previous === undefined ||
previousCell === undefined ||
regions[previousCell] !== regions[cell]
) {
segments.push([cell]);
} else {
previous.push(cell);
}
}
return segments;
}
function regionSumRanges(
cells: readonly CellId[],
values: readonly number[],
regions: readonly number[],
size: number,
): readonly (readonly [number, number])[] {
return regionLineSegments(cells, regions).map((segment) =>
sumsCanMeet(values, segment, size),
);
}
function regionSumLineCanMeet(
cells: readonly CellId[],
values: readonly number[],
regions: readonly number[],
size: number,
negated: boolean,
): boolean {
const ranges = regionSumRanges(cells, values, regions, size);
if (ranges.length < 2) return !negated;
if (negated) {
const first = ranges[0]!;
return (
first[0] !== first[1] ||
ranges
.slice(1)
.some((range) => range[0] !== range[1] || range[0] !== first[0])
);
}
const minimum = Math.max(...ranges.map((range) => range[0]));
const maximum = Math.min(...ranges.map((range) => range[1]));
return minimum <= maximum;
}
function cloneCanMeet(
source: readonly CellId[],
clone: readonly CellId[],
values: readonly number[],
negated: boolean,
): boolean {
let hasUnknown = false;
for (let index = 0; index < source.length; index += 1) {
const first = values[source[index] ?? -1] ?? 0;
const second = values[clone[index] ?? -1] ?? 0;
if (first === 0 || second === 0) hasUnknown = true;
else if (first !== second) return negated;
}
return negated ? hasUnknown : true;
}
const THREE_CLASS_PERMUTATIONS = [
[0, 1, 2],
[0, 2, 1],
[1, 0, 2],
[1, 2, 0],
[2, 0, 1],
[2, 1, 0],
] as const;
function threeClassLineCanMeet(
cells: readonly CellId[],
values: readonly number[],
classify: (value: number) => 0 | 1 | 2,
negated: boolean,
): boolean {
if (negated && cells.some((cell) => (values[cell] ?? 0) === 0)) {
// Every class has at least one digit on validated grids. A blank can
// therefore be chosen to violate one window; peer rules can only narrow
// this, so retaining the state is a sound over-approximation.
return true;
}
const positive = THREE_CLASS_PERMUTATIONS.some((classes) =>
cells.every((cell, position) => {
const value = values[cell] ?? 0;
return value === 0 || classify(value) === classes[position % 3];
}),
);
return negated ? !positive : positive;
}
function zipperLineCanMeet(
cells: readonly CellId[],
values: readonly number[],
size: number,
negated: boolean,
): boolean {
const middle = Math.floor(cells.length / 2);
const fixedCentre = values[cells[middle] ?? -1] ?? 0;
const centres =
fixedCentre === 0
? Array.from({ length: size }, (_, index) => index + 1)
: [fixedCentre];
for (const centre of centres) {
let positivePossible = true;
let negativePossible = false;
for (let offset = 1; offset <= middle; offset += 1) {
const left = values[cells[middle - offset] ?? -1] ?? 0;
const right = values[cells[middle + offset] ?? -1] ?? 0;
if (left !== 0 && right !== 0) {
if (left + right !== centre) {
positivePossible = false;
negativePossible = true;
}
continue;
}
negativePossible = true;
if (left === 0 && right === 0) {
if (centre < 2 || centre > size * 2) positivePossible = false;
} else {
const fixed = left === 0 ? right : left;
const missing = centre - fixed;
if (missing < 1 || missing > size) positivePossible = false;
}
}
if (negated ? negativePossible : positivePossible) return true;
}
return false;
}
function doubleArrowCanMeet(
cells: readonly CellId[],
values: readonly number[],
size: number,
negated: boolean,
): boolean {
const endpoints = [cells[0]!, cells.at(-1)!];
const interior = cells.slice(1, -1);
const endpointRange = sumsCanMeet(values, endpoints, size);
const interiorRange = sumsCanMeet(values, interior, size);
if (negated) {
const complete = cells.every((cell) => (values[cell] ?? 0) !== 0);
if (!complete) {
// Each blank occurs on only one side of the equation and has at least
// four possible raw digits. Keeping it cannot reject a valid completion.
return true;
}
return endpointRange[0] !== interiorRange[0];
}
return (
endpointRange[0] <= interiorRange[1] && interiorRange[0] <= endpointRange[1]
);
}
function indexerTarget(
kind: "row" | "column" | "box",
cell: CellId,
value: number,
size: number,
): readonly [target: CellId, required: number] {
const row = Math.floor(cell / size);
const column = cell % size;
if (kind === "row") return [(value - 1) * size + column, row + 1];
if (kind === "column") return [row * size + value - 1, column + 1];
return [
classicBoxCell(size, value - 1, classicBoxPosition(size, cell)),
classicBoxIndex(size, cell) + 1,
];
}
function indexerCanMeet(
constraint: Extract<VariantConstraint, { readonly type: "indexer" }>,
values: readonly number[],
size: number,
negated: boolean,
): boolean {
const fixed = values[constraint.cell] ?? 0;
const candidates =
fixed === 0
? Array.from({ length: size }, (_, index) => index + 1)
: [fixed];
for (const value of candidates) {
const [target, required] = indexerTarget(
constraint.kind,
constraint.cell,
value,
size,
);
const targetValue =
target === constraint.cell ? value : (values[target] ?? 0);
if (!negated && (targetValue === 0 || targetValue === required))
return true;
if (negated && (targetValue === 0 || targetValue !== required)) return true;
}
return false;
}
function positiveConstraintIsFeasible(
constraint: VariantConstraint,
values: readonly number[],
size: number,
regions: readonly number[],
): boolean {
switch (constraint.type) {
case "diagonal":
case "anti-knight":
case "anti-king":
case "disjoint-groups":
case "extra-region":
return true; // Equality conflicts are represented in compiled peers/units.
case "fog":
return true; // Fog is presentation state and never changes solutions.
case "non-consecutive": {
for (let cell = 0; cell < size * size; cell += 1) {
const value = values[cell] ?? 0;
@@ -400,7 +754,82 @@ function positiveConstraintIsFeasible(
case "quadruple":
return quadrupleCanMeet(constraint, values);
case "maximum":
return maximumCanMeet(constraint.cell, values, size);
return extremumCanMeet(constraint.cell, values, size, "maximum");
case "minimum":
return extremumCanMeet(constraint.cell, values, size, "minimum");
case "odd": {
const value = values[constraint.cell] ?? 0;
return value === 0 || value % 2 === 1;
}
case "even": {
const value = values[constraint.cell] ?? 0;
return value === 0 || value % 2 === 0;
}
case "little-killer":
return littleKillerCanMeet(
values,
littleKillerCells(
size,
constraint.side,
constraint.index,
constraint.direction,
),
size,
constraint.sum,
);
case "sandwich":
return sandwichPossibleSums(
values,
outsideLineCells(size, constraint.side, constraint.index),
size,
).has(constraint.sum);
case "between-line":
return betweenLineCanMeet(constraint.cells, values, size, false);
case "german-whisper":
return germanWhisperCanMeet(
constraint.cells,
values,
size,
constraint.minimumDifference ?? Math.ceil(size / 2),
false,
);
case "region-sum-line":
return regionSumLineCanMeet(
constraint.cells,
values,
regions,
size,
false,
);
case "clone":
return cloneCanMeet(
constraint.cells,
constraint.cloneCells,
values,
false,
);
case "modular-line":
return threeClassLineCanMeet(
constraint.cells,
values,
(value) => (value % 3) as 0 | 1 | 2,
false,
);
case "entropic-line": {
const bandSize = size / 3;
return threeClassLineCanMeet(
constraint.cells,
values,
(value) => Math.floor((value - 1) / bandSize) as 0 | 1 | 2,
false,
);
}
case "zipper-line":
return zipperLineCanMeet(constraint.cells, values, size, false);
case "double-arrow":
return doubleArrowCanMeet(constraint.cells, values, size, false);
case "indexer":
return indexerCanMeet(constraint, values, size, false);
}
}
@@ -408,31 +837,8 @@ function completedCells(
constraint: VariantConstraint,
size: number,
): readonly CellId[] | undefined {
switch (constraint.type) {
case "diagonal":
case "anti-knight":
case "anti-king":
case "non-consecutive":
return undefined;
case "killer-cage":
case "thermo":
case "renban":
case "palindrome":
case "quadruple":
return constraint.cells;
case "arrow":
return [...constraint.bulb, ...constraint.line];
case "kropki":
case "xv":
return [constraint.a, constraint.b];
case "inequality":
return [constraint.lesser, constraint.greater];
case "x-sum":
case "skyscraper":
return outsideLineCells(size, constraint.side, constraint.index);
case "maximum":
return [constraint.cell, ...orthogonalNeighbours(size, constraint.cell)];
}
if (!("negated" in constraint)) return undefined;
return constraintCells(size, constraint);
}
/**
@@ -446,6 +852,7 @@ function negatedConstraintIsFeasible(
constraint: VariantConstraint,
values: readonly number[],
size: number,
regions: readonly number[],
): boolean {
if (constraint.type === "x-sum") {
const line = valuesAt(
@@ -476,15 +883,89 @@ function negatedConstraintIsFeasible(
}
if (constraint.type === "maximum") {
const maximum = values[constraint.cell] ?? 0;
if (maximum === 0) return true;
const extremum = values[constraint.cell] ?? 0;
if (extremum === 0) return true;
const neighbours = orthogonalNeighbours(size, constraint.cell).map(
(cell) => values[cell] ?? 0,
);
if (neighbours.some((value) => value !== 0 && value >= maximum)) {
if (neighbours.some((value) => value !== 0 && value >= extremum)) {
return true;
}
return !(maximum === size || neighbours.every((value) => value !== 0));
return !(extremum === size || neighbours.every((value) => value !== 0));
}
if (constraint.type === "minimum") {
const extremum = values[constraint.cell] ?? 0;
if (extremum === 0) return true;
const neighbours = orthogonalNeighbours(size, constraint.cell).map(
(cell) => values[cell] ?? 0,
);
if (neighbours.some((value) => value !== 0 && value <= extremum)) {
return true;
}
return !(extremum === 1 || neighbours.every((value) => value !== 0));
}
if (constraint.type === "sandwich") {
const sums = sandwichPossibleSums(
values,
outsideLineCells(size, constraint.side, constraint.index),
size,
);
return [...sums].some((sum) => sum !== constraint.sum);
}
if (constraint.type === "between-line") {
return betweenLineCanMeet(constraint.cells, values, size, true);
}
if (constraint.type === "german-whisper") {
return germanWhisperCanMeet(
constraint.cells,
values,
size,
constraint.minimumDifference ?? Math.ceil(size / 2),
true,
);
}
if (constraint.type === "region-sum-line") {
return regionSumLineCanMeet(constraint.cells, values, regions, size, true);
}
if (constraint.type === "clone") {
return cloneCanMeet(constraint.cells, constraint.cloneCells, values, true);
}
if (constraint.type === "modular-line") {
return threeClassLineCanMeet(
constraint.cells,
values,
(value) => (value % 3) as 0 | 1 | 2,
true,
);
}
if (constraint.type === "entropic-line") {
const bandSize = size / 3;
return threeClassLineCanMeet(
constraint.cells,
values,
(value) => Math.floor((value - 1) / bandSize) as 0 | 1 | 2,
true,
);
}
if (constraint.type === "zipper-line") {
return zipperLineCanMeet(constraint.cells, values, size, true);
}
if (constraint.type === "double-arrow") {
return doubleArrowCanMeet(constraint.cells, values, size, true);
}
if (constraint.type === "indexer") {
return indexerCanMeet(constraint, values, size, true);
}
if (constraint.type === "palindrome") {
@@ -511,17 +992,19 @@ function negatedConstraintIsFeasible(
) {
return true;
}
return !positiveConstraintIsFeasible(constraint, values, size);
return !positiveConstraintIsFeasible(constraint, values, size, regions);
}
export function constraintIsFeasible(
constraint: VariantConstraint,
values: readonly number[],
size: number,
regions: readonly number[] = classicRegions(size),
): boolean {
const negated = "negated" in constraint && constraint.negated === true;
if (!negated) return positiveConstraintIsFeasible(constraint, values, size);
return negatedConstraintIsFeasible(constraint, values, size);
if (!negated)
return positiveConstraintIsFeasible(constraint, values, size, regions);
return negatedConstraintIsFeasible(constraint, values, size, regions);
}
function asCompiled(
@@ -550,7 +1033,7 @@ export function canPlaceValue(
const constraint = compiled.puzzle.constraints[index];
if (
constraint !== undefined &&
!constraintIsFeasible(constraint, next, size)
!constraintIsFeasible(constraint, next, size, compiled.puzzle.regions)
)
return false;
}
@@ -646,44 +1129,15 @@ export function findConflicts(
}
});
compiled.puzzle.constraints.forEach((constraint, constraintIndex) => {
if (!constraintIsFeasible(constraint, board, compiled.puzzle.size)) {
const cells = (() => {
switch (constraint.type) {
case "diagonal":
case "anti-knight":
case "anti-king":
case "non-consecutive":
return Array.from(
{ length: compiled.puzzle.size * compiled.puzzle.size },
(_, cell) => cell,
);
case "killer-cage":
case "thermo":
case "renban":
case "palindrome":
case "quadruple":
return constraint.cells;
case "arrow":
return [...constraint.bulb, ...constraint.line];
case "kropki":
case "xv":
return [constraint.a, constraint.b];
case "inequality":
return [constraint.lesser, constraint.greater];
case "x-sum":
case "skyscraper":
return outsideLineCells(
compiled.puzzle.size,
constraint.side,
constraint.index,
);
case "maximum":
return [
constraint.cell,
...orthogonalNeighbours(compiled.puzzle.size, constraint.cell),
];
}
})();
if (
!constraintIsFeasible(
constraint,
board,
compiled.puzzle.size,
compiled.puzzle.regions,
)
) {
const cells = constraintCells(compiled.puzzle.size, constraint);
conflicts.push({
kind: "constraint",
cells,
+145 -1
View File
@@ -23,6 +23,11 @@ export interface NonConsecutiveConstraint {
readonly type: "non-consecutive";
}
/** Corresponding positions in every standard box form an additional house. */
export interface DisjointGroupsConstraint {
readonly type: "disjoint-groups";
}
export interface KillerCageConstraint {
readonly type: "killer-cage";
readonly cells: readonly CellId[];
@@ -122,11 +127,134 @@ export interface MaximumConstraint {
readonly negated?: boolean;
}
/** The marked cell is less than each orthogonally adjacent cell. */
export interface MinimumConstraint {
readonly type: "minimum";
readonly cell: CellId;
readonly negated?: boolean;
}
/** The marked cell contains an odd digit. */
export interface OddConstraint {
readonly type: "odd";
readonly cell: CellId;
readonly negated?: boolean;
}
/** The marked cell contains an even digit. */
export interface EvenConstraint {
readonly type: "even";
readonly cell: CellId;
readonly negated?: boolean;
}
export type LittleKillerDirection =
"down-right" | "down-left" | "up-right" | "up-left";
/** An outside sum along a diagonal entering the grid from one edge. */
export interface LittleKillerConstraint {
readonly type: "little-killer";
readonly side: OutsideClueSide;
readonly index: number;
readonly direction: LittleKillerDirection;
readonly sum: number;
readonly negated?: boolean;
}
/** Sum of the digits strictly between 1 and N on an outside line. */
export interface SandwichConstraint {
readonly type: "sandwich";
readonly side: OutsideClueSide;
readonly index: number;
readonly sum: number;
readonly negated?: boolean;
}
/** Interior line digits lie strictly between the two endpoint values. */
export interface BetweenLineConstraint {
readonly type: "between-line";
readonly cells: readonly CellId[];
readonly negated?: boolean;
}
/** Adjacent line digits differ by at least the configured amount. */
export interface GermanWhisperConstraint {
readonly type: "german-whisper";
readonly cells: readonly CellId[];
/** Defaults to ceil(size / 2). */
readonly minimumDifference?: number;
readonly negated?: boolean;
}
/** Every contiguous segment in a region has the same sum. */
export interface RegionSumLineConstraint {
readonly type: "region-sum-line";
readonly cells: readonly CellId[];
readonly negated?: boolean;
}
/** Ordered cells in the two regions contain pairwise equal digits. */
export interface CloneConstraint {
readonly type: "clone";
readonly cells: readonly CellId[];
readonly cloneCells: readonly CellId[];
readonly negated?: boolean;
}
/** An additional all-different house containing exactly N cells. */
export interface ExtraRegionConstraint {
readonly type: "extra-region";
readonly cells: readonly CellId[];
}
/** Every three consecutive cells contain all three digit residues modulo 3. */
export interface ModularLineConstraint {
readonly type: "modular-line";
readonly cells: readonly CellId[];
readonly negated?: boolean;
}
/** Every three consecutive cells contain one digit from each equal value band. */
export interface EntropicLineConstraint {
readonly type: "entropic-line";
readonly cells: readonly CellId[];
readonly negated?: boolean;
}
/** Equidistant pairs sum to the digit in the line's centre cell. */
export interface ZipperLineConstraint {
readonly type: "zipper-line";
readonly cells: readonly CellId[];
readonly negated?: boolean;
}
/** The two endpoint digits sum to all interior digits combined. */
export interface DoubleArrowConstraint {
readonly type: "double-arrow";
readonly cells: readonly CellId[];
readonly negated?: boolean;
}
export interface IndexerConstraint {
readonly type: "indexer";
readonly kind: "row" | "column" | "box";
readonly cell: CellId;
readonly negated?: boolean;
}
/** Canonical reveal seed data; fog never changes Sudoku solution semantics. */
export interface FogConstraint {
readonly type: "fog";
readonly lights: readonly CellId[];
readonly revealRadius?: 0 | 1;
}
export type VariantConstraint =
| DiagonalConstraint
| AntiKnightConstraint
| AntiKingConstraint
| NonConsecutiveConstraint
| DisjointGroupsConstraint
| KillerCageConstraint
| ThermoConstraint
| ArrowConstraint
@@ -138,7 +266,23 @@ export type VariantConstraint =
| XSumConstraint
| SkyscraperConstraint
| QuadrupleConstraint
| MaximumConstraint;
| MaximumConstraint
| MinimumConstraint
| OddConstraint
| EvenConstraint
| LittleKillerConstraint
| SandwichConstraint
| BetweenLineConstraint
| GermanWhisperConstraint
| RegionSumLineConstraint
| CloneConstraint
| ExtraRegionConstraint
| ModularLineConstraint
| EntropicLineConstraint
| ZipperLineConstraint
| DoubleArrowConstraint
| IndexerConstraint
| FogConstraint;
export interface PuzzleDefinition {
readonly version: 1;
+432 -25
View File
@@ -1,11 +1,22 @@
import { cellsFormQuadruple, classicRegions } from "./geometry";
import {
cellsFormQuadruple,
classicRegions,
littleKillerCells,
littleKillerDirectionEntersGrid,
} from "./geometry";
import { compilePuzzle } from "./compile";
import {
constraintAllowedFields,
isConstraintType,
} from "./constraintRegistry";
import { findConflicts } from "./rules";
import {
MAX_PUZZLE_SIZE,
MIN_PUZZLE_SIZE,
PuzzleValidationError,
type LittleKillerDirection,
type NormalizedPuzzle,
type OutsideClueSide,
type PuzzleDefinition,
type ValidationIssue,
type ValidationResult,
@@ -30,28 +41,8 @@ const ROOT_KEYS = new Set([
"solution",
]);
const CONSTRAINT_KEYS: Readonly<
Record<VariantConstraint["type"], ReadonlySet<string>>
> = {
diagonal: new Set(["type", "direction"]),
"anti-knight": new Set(["type"]),
"anti-king": new Set(["type"]),
"non-consecutive": new Set(["type"]),
"killer-cage": new Set(["type", "cells", "sum", "noRepeat", "negated"]),
thermo: new Set(["type", "cells", "negated"]),
arrow: new Set(["type", "bulb", "line", "negated"]),
kropki: new Set(["type", "a", "b", "kind", "negated"]),
xv: new Set(["type", "a", "b", "total", "negated"]),
inequality: new Set(["type", "lesser", "greater", "negated"]),
renban: new Set(["type", "cells", "negated"]),
palindrome: new Set(["type", "cells", "negated"]),
"x-sum": new Set(["type", "side", "index", "sum", "negated"]),
skyscraper: new Set(["type", "side", "index", "count", "negated"]),
quadruple: new Set(["type", "cells", "digits", "negated"]),
maximum: new Set(["type", "cell", "negated"]),
};
const reachableXSumCache = new Map<number, ReadonlySet<number>>();
const reachableSandwichCache = new Map<number, ReadonlySet<number>>();
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -182,6 +173,30 @@ function reachableXSumTotals(size: number): ReadonlySet<number> {
return reachable;
}
function reachableSandwichTotals(size: number): ReadonlySet<number> {
const cached = reachableSandwichCache.get(size);
if (cached !== undefined) return cached;
const reachable = new Set<number>([0]);
for (let digit = 2; digit < size; digit += 1) {
for (const sum of [...reachable]) reachable.add(sum + digit);
}
reachableSandwichCache.set(size, reachable);
return reachable;
}
function sameRegionPartition(
first: readonly number[],
second: readonly number[],
): boolean {
if (first.length !== second.length) return false;
for (let a = 0; a < first.length; a += 1) {
for (let b = a + 1; b < first.length; b += 1) {
if ((first[a] === first[b]) !== (second[a] === second[b])) return false;
}
}
return true;
}
function validateConstraint(
value: unknown,
index: number,
@@ -193,12 +208,12 @@ function validateConstraint(
add(issues, path, "must be a constraint object with a type");
return;
}
const type = value.type as VariantConstraint["type"];
const allowed = CONSTRAINT_KEYS[type];
if (allowed === undefined) {
if (!isConstraintType(value.type)) {
add(issues, `${path}.type`, "is not a supported constraint type");
return;
}
const type = value.type;
const allowed = constraintAllowedFields(type);
for (const key of Object.keys(value)) {
if (!allowed.has(key))
add(issues, `${path}.${key}`, "is not a recognized field");
@@ -220,6 +235,7 @@ function validateConstraint(
case "anti-knight":
case "anti-king":
case "non-consecutive":
case "disjoint-groups":
break;
case "killer-cage": {
const cellsValid = validateCells(
@@ -410,8 +426,258 @@ function validateConstraint(
break;
}
case "maximum":
case "minimum":
case "odd":
case "even":
validateCell(value.cell, `${path}.cell`, cellCount, issues);
break;
case "little-killer": {
validateOutsideClue(value, path, size, issues);
const directionValid =
value.direction === "down-right" ||
value.direction === "down-left" ||
value.direction === "up-right" ||
value.direction === "up-left";
if (!directionValid) {
add(
issues,
`${path}.direction`,
'must be "down-right", "down-left", "up-right" or "up-left"',
);
} else if (
(value.side === "top" ||
value.side === "right" ||
value.side === "bottom" ||
value.side === "left") &&
!littleKillerDirectionEntersGrid(
value.side,
value.direction as LittleKillerDirection,
)
) {
add(issues, `${path}.direction`, "must point into the grid");
}
const indexValid =
Number.isInteger(value.index) &&
(value.index as number) >= 0 &&
(value.index as number) < size;
const sideValid =
value.side === "top" ||
value.side === "right" ||
value.side === "bottom" ||
value.side === "left";
const cells =
directionValid && indexValid && sideValid
? littleKillerCells(
size,
value.side as OutsideClueSide,
value.index as number,
value.direction as LittleKillerDirection,
)
: [];
if (cells.length === 1) {
add(
issues,
path,
"little-killer diagonal must cross at least two cells",
);
}
if (!Number.isInteger(value.sum)) {
add(issues, `${path}.sum`, "must be an integer");
} else if (cells.length >= 2) {
const minimum = value.negated === true ? 1 : cells.length;
const maximum =
value.negated === true
? maximumFalseClueValue(size)
: cells.length * size;
if (
(value.sum as number) < minimum ||
(value.sum as number) > maximum
) {
add(
issues,
`${path}.sum`,
value.negated === true
? `must be a bounded positive integer (1 to ${maximum})`
: `must be reachable (${minimum} to ${maximum})`,
);
}
}
break;
}
case "sandwich": {
validateOutsideClue(value, path, size, issues);
const valid = validateIntegerRange(
value.sum,
`${path}.sum`,
0,
value.negated === true
? maximumFalseClueValue(size)
: (size * (size + 1)) / 2 - size - 1,
issues,
);
if (
valid &&
value.negated !== true &&
!reachableSandwichTotals(size).has(value.sum as number)
) {
add(
issues,
`${path}.sum`,
"cannot be formed by digits between 1 and the maximum digit",
);
}
break;
}
case "between-line":
validateCells(
value.cells,
`${path}.cells`,
cellCount,
3,
cellCount,
issues,
);
break;
case "german-whisper":
validateCells(
value.cells,
`${path}.cells`,
cellCount,
2,
cellCount,
issues,
);
if (value.minimumDifference !== undefined) {
validateIntegerRange(
value.minimumDifference,
`${path}.minimumDifference`,
1,
size - 1,
issues,
);
}
break;
case "region-sum-line":
validateCells(
value.cells,
`${path}.cells`,
cellCount,
2,
cellCount,
issues,
);
break;
case "clone": {
const sourceValid = validateCells(
value.cells,
`${path}.cells`,
cellCount,
1,
cellCount,
issues,
);
const cloneValid = validateCells(
value.cloneCells,
`${path}.cloneCells`,
cellCount,
1,
cellCount,
issues,
);
if (sourceValid && cloneValid) {
const source = value.cells as readonly number[];
const clone = value.cloneCells as readonly number[];
if (source.length !== clone.length) {
add(issues, path, "clone cell lists must have equal length");
}
}
break;
}
case "extra-region":
validateCells(
value.cells,
`${path}.cells`,
cellCount,
size,
size,
issues,
);
break;
case "modular-line":
validateCells(
value.cells,
`${path}.cells`,
cellCount,
3,
cellCount,
issues,
);
break;
case "entropic-line":
validateCells(
value.cells,
`${path}.cells`,
cellCount,
3,
cellCount,
issues,
);
if (size % 3 !== 0) {
add(issues, path, "entropic lines require a grid size divisible by 3");
}
break;
case "zipper-line":
if (
validateCells(
value.cells,
`${path}.cells`,
cellCount,
3,
cellCount,
issues,
) &&
(value.cells as readonly number[]).length % 2 === 0
) {
add(issues, `${path}.cells`, "must contain an odd number of cells");
}
break;
case "double-arrow":
validateCells(
value.cells,
`${path}.cells`,
cellCount,
3,
cellCount,
issues,
);
break;
case "indexer":
validateCell(value.cell, `${path}.cell`, cellCount, issues);
if (
value.kind !== "row" &&
value.kind !== "column" &&
value.kind !== "box"
) {
add(issues, `${path}.kind`, 'must be "row", "column" or "box"');
}
break;
case "fog":
validateCells(
value.lights,
`${path}.lights`,
cellCount,
1,
cellCount,
issues,
);
if (
value.revealRadius !== undefined &&
value.revealRadius !== 0 &&
value.revealRadius !== 1
) {
add(issues, `${path}.revealRadius`, "must be 0 or 1");
}
break;
}
}
@@ -448,6 +714,7 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
case "anti-knight":
case "anti-king":
case "non-consecutive":
case "disjoint-groups":
return { type: constraint.type };
case "killer-cage":
return {
@@ -464,6 +731,12 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
case "thermo":
case "renban":
case "palindrome":
case "between-line":
case "region-sum-line":
case "modular-line":
case "entropic-line":
case "zipper-line":
case "double-arrow":
return {
type: constraint.type,
cells: [...constraint.cells],
@@ -539,6 +812,9 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
: { negated: constraint.negated }),
};
case "maximum":
case "minimum":
case "odd":
case "even":
return {
type: constraint.type,
cell: constraint.cell,
@@ -546,6 +822,66 @@ function cloneConstraint(constraint: VariantConstraint): VariantConstraint {
? {}
: { negated: constraint.negated }),
};
case "little-killer":
return {
type: constraint.type,
side: constraint.side,
index: constraint.index,
direction: constraint.direction,
sum: constraint.sum,
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
};
case "sandwich":
return {
type: constraint.type,
side: constraint.side,
index: constraint.index,
sum: constraint.sum,
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
};
case "german-whisper":
return {
type: constraint.type,
cells: [...constraint.cells],
...(constraint.minimumDifference === undefined
? {}
: { minimumDifference: constraint.minimumDifference }),
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
};
case "clone":
return {
type: constraint.type,
cells: [...constraint.cells],
cloneCells: [...constraint.cloneCells],
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
};
case "extra-region":
return { type: constraint.type, cells: [...constraint.cells] };
case "indexer":
return {
type: constraint.type,
kind: constraint.kind,
cell: constraint.cell,
...(constraint.negated === undefined
? {}
: { negated: constraint.negated }),
};
case "fog":
return {
type: constraint.type,
lights: [...constraint.lights],
...(constraint.revealRadius === undefined
? {}
: { revealRadius: constraint.revealRadius }),
};
}
}
@@ -654,6 +990,77 @@ export function validatePuzzle(puzzle: unknown): ValidationResult {
.forEach((constraint, index) => {
validateConstraint(constraint, index, n, issues);
});
if (regionsValid) {
const actualRegions =
puzzle.regions === undefined
? classicRegions(n)
: (puzzle.regions as readonly number[]);
if (!sameRegionPartition(actualRegions, classicRegions(n))) {
puzzle.constraints.forEach((constraint, index) => {
if (!isRecord(constraint)) return;
if (constraint.type === "disjoint-groups") {
add(
issues,
`constraints[${index}]`,
"disjoint groups require the standard rectangular box layout",
);
}
if (constraint.type === "indexer" && constraint.kind === "box") {
add(
issues,
`constraints[${index}]`,
"box indexers require the standard rectangular box layout",
);
}
});
}
puzzle.constraints.forEach((constraint, index) => {
if (
!isRecord(constraint) ||
constraint.type !== "region-sum-line" ||
!Array.isArray(constraint.cells) ||
constraint.cells.length < 2 ||
constraint.cells.some(
(cell) =>
!Number.isInteger(cell) ||
(cell as number) < 0 ||
(cell as number) >= n * n,
)
) {
return;
}
const cells = constraint.cells as readonly number[];
let segmentCount = 1;
for (let position = 1; position < cells.length; position += 1) {
if (
actualRegions[cells[position]!] !==
actualRegions[cells[position - 1]!]
) {
segmentCount += 1;
}
}
if (segmentCount < 2) {
add(
issues,
`constraints[${index}].cells`,
"region-sum line must cross at least one region boundary",
);
}
});
}
puzzle.constraints.forEach((constraint, index) => {
if (
isRecord(constraint) &&
constraint.type === "fog" &&
puzzle.solution === undefined
) {
add(
issues,
`constraints[${index}]`,
"fog requires a complete trusted puzzle solution",
);
}
});
}
validateText(puzzle.id, "id", MAX_SHORT_TEXT, issues);
validateText(puzzle.title, "title", MAX_SHORT_TEXT, issues);
+409
View File
@@ -0,0 +1,409 @@
import { littleKillerCells } from "../domain/geometry";
import type {
PortableConstraint,
SafeVisualAnchor,
SafeVisualPrimitive,
SafeVisualStyle,
SudokuDocument,
} from "./types";
const lineStyle = (
stroke: string,
strokeWidth: number,
opacity = 1,
): SafeVisualStyle => ({ stroke, fill: "transparent", strokeWidth, opacity });
const cell = (cellIndex: number): SafeVisualAnchor => ({
kind: "cell",
cell: cellIndex,
});
const coordinate = (x: number, y: number): SafeVisualAnchor => ({
kind: "coordinate",
x,
y,
});
function cellPoint(size: number, cellIndex: number) {
return {
x: (cellIndex % size) + 0.5,
y: Math.floor(cellIndex / size) + 0.5,
};
}
function average(size: number, cells: readonly number[]): SafeVisualAnchor {
const points = cells.map((entry) => cellPoint(size, entry));
return coordinate(
points.reduce((sum, point) => sum + point.x, 0) / points.length,
points.reduce((sum, point) => sum + point.y, 0) / points.length,
);
}
function midpoint(size: number, a: number, b: number): SafeVisualAnchor {
return average(size, [a, b]);
}
function outsideAnchor(
size: number,
side: "top" | "right" | "bottom" | "left",
index: number,
): SafeVisualAnchor {
switch (side) {
case "top":
return coordinate(index + 0.5, -0.45);
case "right":
return coordinate(size + 0.45, index + 0.5);
case "bottom":
return coordinate(index + 0.5, size + 0.45);
case "left":
return coordinate(-0.45, index + 0.5);
}
}
function polyline(
cells: readonly number[],
style: SafeVisualStyle,
layer: "underlay" | "overlay" = "underlay",
): SafeVisualPrimitive {
return { type: "polyline", layer, points: cells.map(cell), style };
}
function text(
position: SafeVisualAnchor,
value: string,
fontSize = 0.34,
): SafeVisualPrimitive {
return {
type: "text",
layer: "overlay",
position,
text: value,
style: { fill: "#172033", fontSize, opacity: 1 },
};
}
function renderConstraint(
constraint: PortableConstraint,
size: number,
): SafeVisualPrimitive[] {
switch (constraint.type) {
case "anti-knight":
case "anti-king":
case "non-consecutive":
case "disjoint-groups":
case "fog":
return [];
case "diagonal":
return [
{
type: "line",
layer: "underlay",
start: coordinate(constraint.direction === "main" ? 0 : size, 0),
end: coordinate(constraint.direction === "main" ? size : 0, size),
style: lineStyle("#34bbe6", 0.04),
},
];
case "killer-cage":
// SCL has a native cage representation; it is emitted separately.
return [];
case "thermo":
return [
polyline(constraint.cells, lineStyle("#cfcfcf", 0.32)),
{
type: "circle",
layer: "underlay",
center: cell(constraint.cells[0]!),
radius: 0.42,
style: {
fill: "#cfcfcf",
stroke: "#cfcfcf",
strokeWidth: 0.03,
},
},
];
case "arrow": {
const start = constraint.bulb.at(-1)!;
return [
{
type: "circle",
layer: "underlay",
center: average(size, constraint.bulb),
radius: Math.max(0.38, Math.sqrt(constraint.bulb.length) * 0.3),
style: {
fill: "#ffffff",
stroke: "#a1a1a1",
strokeWidth: 0.07,
},
},
polyline([start, ...constraint.line], lineStyle("#a1a1a1", 0.07)),
];
}
case "kropki":
return [
{
type: "circle",
layer: "overlay",
center: midpoint(size, constraint.a, constraint.b),
radius: 0.12,
style: {
fill: constraint.kind === "black" ? "#000000" : "#ffffff",
stroke: "#000000",
strokeWidth: 0.025,
},
},
];
case "xv": {
const position = midpoint(size, constraint.a, constraint.b);
return [
{
type: "circle",
layer: "overlay",
center: position,
radius: 0.2,
style: { fill: "#ffffff", stroke: "#ffffff", strokeWidth: 0.01 },
},
text(position, constraint.total === 5 ? "V" : "X", 0.3),
];
}
case "inequality": {
const lesser = cellPoint(size, constraint.lesser);
const greater = cellPoint(size, constraint.greater);
const center = {
x: (lesser.x + greater.x) / 2,
y: (lesser.y + greater.y) / 2,
};
const dx = greater.x - lesser.x;
const dy = greater.y - lesser.y;
const distance = Math.hypot(dx, dy) || 1;
const ux = dx / distance;
const uy = dy / distance;
const px = -uy;
const py = ux;
return [
{
type: "polyline",
layer: "overlay",
points: [
coordinate(
center.x + ux * 0.18 + px * 0.16,
center.y + uy * 0.18 + py * 0.16,
),
coordinate(center.x - ux * 0.18, center.y - uy * 0.18),
coordinate(
center.x + ux * 0.18 - px * 0.16,
center.y + uy * 0.18 - py * 0.16,
),
],
style: lineStyle("#172033", 0.05),
},
];
}
case "renban":
return [polyline(constraint.cells, lineStyle("#b55b8c", 0.2, 0.72))];
case "palindrome":
return [
polyline(constraint.cells, lineStyle("#cfcfcf", 0.2)),
...constraint.cells.map((entry): SafeVisualPrimitive => ({
type: "circle",
layer: "underlay",
center: cell(entry),
radius: 0.13,
style: { fill: "#d8dbe1", stroke: "transparent" },
})),
];
case "x-sum":
return [
text(
outsideAnchor(size, constraint.side, constraint.index),
`Σ ${String(constraint.sum)}`,
),
];
case "skyscraper":
return [
text(
outsideAnchor(size, constraint.side, constraint.index),
`${String(constraint.count)}`,
),
];
case "quadruple": {
const position = average(size, constraint.cells);
return [
{
type: "circle",
layer: "overlay",
center: position,
radius: 0.29,
style: { fill: "#ffffff", stroke: "#596273", strokeWidth: 0.03 },
},
text(position, constraint.digits.join(""), 0.23),
];
}
case "maximum":
return [text(cell(constraint.cell), "◆", 0.34)];
case "minimum":
return [text(cell(constraint.cell), "◇", 0.34)];
case "odd":
return [
{
type: "circle",
layer: "underlay",
center: cell(constraint.cell),
radius: 0.19,
style: { fill: "#cfcfcf", stroke: "transparent" },
},
];
case "even":
return [
{
type: "rectangle",
layer: "underlay",
center: cell(constraint.cell),
width: 0.38,
height: 0.38,
style: { fill: "#cfcfcf", stroke: "transparent" },
},
];
case "little-killer": {
const clue = outsideAnchor(size, constraint.side, constraint.index);
const first = littleKillerCells(
size,
constraint.side,
constraint.index,
constraint.direction,
)[0];
return [
text(clue, String(constraint.sum)),
...(first === undefined
? []
: [
{
type: "line" as const,
layer: "overlay" as const,
start: clue,
end: cell(first),
style: lineStyle("#172033", 0.03),
},
]),
];
}
case "sandwich":
return [
text(
outsideAnchor(size, constraint.side, constraint.index),
`1⋯N ${String(constraint.sum)}`,
),
];
case "between-line":
return [
polyline(constraint.cells, lineStyle("#7e8796", 0.07)),
...[constraint.cells[0]!, constraint.cells.at(-1)!].map(
(entry): SafeVisualPrimitive => ({
type: "circle",
layer: "underlay",
center: cell(entry),
radius: 0.27,
style: { fill: "#ffffff", stroke: "#7e8796", strokeWidth: 0.06 },
}),
),
];
case "german-whisper":
return [polyline(constraint.cells, lineStyle("#4d9b69", 0.14))];
case "region-sum-line":
return [polyline(constraint.cells, lineStyle("#4a94a3", 0.1))];
case "clone":
return [...constraint.cells, ...constraint.cloneCells].map(
(entry): SafeVisualPrimitive => ({
type: "rectangle",
layer: "underlay",
center: cell(entry),
width: 0.88,
height: 0.88,
style: {
fill: "#6d62b533",
stroke: "#6d62b5",
strokeWidth: 0.025,
},
}),
);
case "extra-region":
return constraint.cells.map((entry): SafeVisualPrimitive => ({
type: "rectangle",
layer: "underlay",
center: cell(entry),
width: 0.9,
height: 0.9,
style: {
fill: "transparent",
stroke: "#6d62b5",
strokeWidth: 0.04,
},
}));
case "modular-line":
return [polyline(constraint.cells, lineStyle("#167f8f", 0.14))];
case "entropic-line":
return [polyline(constraint.cells, lineStyle("#d47742", 0.14))];
case "zipper-line":
return [
polyline(constraint.cells, lineStyle("#7b60ad", 0.1)),
{
type: "circle",
layer: "underlay",
center: cell(
constraint.cells[Math.floor(constraint.cells.length / 2)]!,
),
radius: 0.22,
style: { fill: "#ffffff", stroke: "#7b60ad", strokeWidth: 0.06 },
},
];
case "double-arrow":
return [
polyline(constraint.cells, lineStyle("#596273", 0.07)),
...[constraint.cells[0]!, constraint.cells.at(-1)!].map(
(entry): SafeVisualPrimitive => ({
type: "circle",
layer: "underlay",
center: cell(entry),
radius: 0.22,
style: { fill: "#ffffff", stroke: "#596273", strokeWidth: 0.05 },
}),
),
];
case "indexer":
return [
{
type: "rectangle",
layer: "overlay",
center: cell(constraint.cell),
width: 0.48,
height: 0.48,
cornerRadius: 0.1,
style: {
fill: "#ffffff",
stroke:
constraint.kind === "row"
? "#287fba"
: constraint.kind === "column"
? "#b94c50"
: "#438b58",
strokeWidth: 0.03,
},
},
text(
cell(constraint.cell),
constraint.kind === "row"
? "R"
: constraint.kind === "column"
? "C"
: "B",
0.24,
),
];
}
}
/** Canonical, inert visual equivalents for every shape-based domain clue. */
export function canonicalConstraintVisuals(
document: Pick<SudokuDocument, "size" | "constraints">,
): SafeVisualPrimitive[] {
return document.constraints.flatMap((constraint) =>
renderConstraint(constraint, document.size),
);
}
+232 -3
View File
@@ -1,9 +1,16 @@
import { cellsFormQuadruple } from "../domain/geometry";
import { normalizePortableAidMemoire } from "../state/aidMemoire";
import {
SafeVisualValidationError,
normalizeSafeVisualPrimitives,
normalizeScalarMetadata,
normalizeSourceIdentity,
} from "./safeVisuals";
import {
SUDOKU_DOCUMENT_SCHEMA,
SUDOKU_DOCUMENT_VERSION,
cloneConstraint,
cloneVisualPrimitive,
type PortableAidMemoire,
type PortableConstraint,
type SudokuDocument,
@@ -15,6 +22,30 @@ export const MAX_BOARD_SIZE = 16;
export const MAX_CONSTRAINTS = 5_000;
const MAX_TEXT_LENGTH = 20_000;
const MAX_RULES = 1_000;
const DOCUMENT_FIELDS = new Set([
"schema",
"version",
"size",
"givens",
"values",
"cornerMarks",
"centerMarks",
"candidates",
"colors",
"elapsedMs",
"aidMemoire",
"solution",
"regions",
"constraints",
"title",
"author",
"rules",
"globalRules",
"id",
"visuals",
"source",
"metadata",
]);
export class SudokuFormatError extends Error {
readonly code: string;
@@ -176,6 +207,7 @@ function parseConstraint(
case "anti-knight":
case "anti-king":
case "non-consecutive":
case "disjoint-groups":
return { type: value.type };
case "killer-cage": {
const cageCells = cells(value.cells, "killer-cage.cells", cellCount);
@@ -202,11 +234,119 @@ function parseConstraint(
case "thermo":
case "renban":
case "palindrome":
case "region-sum-line":
return {
type: value.type,
cells: cells(value.cells, `${value.type}.cells`, cellCount, 2),
...cluePolarity(value, value.type),
};
case "between-line":
return {
type: "between-line",
cells: cells(value.cells, "between-line.cells", cellCount, 3),
...cluePolarity(value, "between-line"),
};
case "modular-line":
case "entropic-line":
case "double-arrow":
return {
type: value.type,
cells: cells(value.cells, `${value.type}.cells`, cellCount, 3),
...cluePolarity(value, value.type),
};
case "zipper-line": {
const lineCells = cells(value.cells, "zipper-line.cells", cellCount, 3);
if (lineCells.length % 2 === 0) {
return fail(
"INVALID_CELLS",
"zipper-line.cells must contain an odd number of cells.",
);
}
return {
type: "zipper-line",
cells: lineCells,
...cluePolarity(value, "zipper-line"),
};
}
case "german-whisper":
return {
type: "german-whisper",
cells: cells(value.cells, "german-whisper.cells", cellCount, 2),
...(value.minimumDifference === undefined
? {}
: {
minimumDifference: integer(
value.minimumDifference,
"german-whisper.minimumDifference",
1,
size - 1,
),
}),
...cluePolarity(value, "german-whisper"),
};
case "clone": {
const original = cells(value.cells, "clone.cells", cellCount);
const cloned = cells(value.cloneCells, "clone.cloneCells", cellCount);
if (original.length !== cloned.length) {
return fail(
"INVALID_CELLS",
"clone.cells and clone.cloneCells must have equal lengths.",
);
}
return {
type: "clone",
cells: original,
cloneCells: cloned,
...cluePolarity(value, "clone"),
};
}
case "extra-region": {
const regionCells = cells(value.cells, "extra-region.cells", cellCount);
if (regionCells.length !== size) {
return fail(
"INVALID_CELLS",
`extra-region.cells must contain exactly ${size} cells.`,
);
}
return { type: "extra-region", cells: regionCells };
}
case "indexer": {
if (
value.kind !== "row" &&
value.kind !== "column" &&
value.kind !== "box"
) {
return fail(
"INVALID_CONSTRAINT",
"indexer.kind must be row, column, or box.",
);
}
return {
type: "indexer",
kind: value.kind,
cell: cell(value.cell, "indexer.cell", cellCount),
...cluePolarity(value, "indexer"),
};
}
case "fog": {
if (
value.revealRadius !== undefined &&
value.revealRadius !== 0 &&
value.revealRadius !== 1
) {
return fail(
"INVALID_CONSTRAINT",
"fog.revealRadius must be zero or one.",
);
}
return {
type: "fog",
lights: cells(value.lights, "fog.lights", cellCount),
...(value.revealRadius === undefined
? {}
: { revealRadius: value.revealRadius }),
};
}
case "arrow":
return {
type: "arrow",
@@ -322,11 +462,57 @@ function parseConstraint(
};
}
case "maximum":
case "minimum":
case "odd":
case "even":
return {
type: "maximum",
cell: cell(value.cell, "maximum.cell", cellCount),
...cluePolarity(value, "maximum"),
type: value.type,
cell: cell(value.cell, `${value.type}.cell`, cellCount),
...cluePolarity(value, value.type),
};
case "little-killer": {
const direction = value.direction;
if (
direction !== "down-right" &&
direction !== "down-left" &&
direction !== "up-right" &&
direction !== "up-left"
) {
return fail(
"INVALID_CONSTRAINT",
"A little-killer direction must point diagonally into the grid.",
);
}
const polarity = cluePolarity(value, "little-killer");
return {
type: "little-killer",
side: outsideSide(value.side),
index: integer(value.index, "little-killer.index", 0, size - 1),
direction,
sum: integer(
value.sum,
"little-killer.sum",
1,
polarity.negated === true ? size ** 4 : size ** 3,
),
...polarity,
};
}
case "sandwich": {
const polarity = cluePolarity(value, "sandwich");
return {
type: "sandwich",
side: outsideSide(value.side),
index: integer(value.index, "sandwich.index", 0, size - 1),
sum: integer(
value.sum,
"sandwich.sum",
0,
polarity.negated === true ? size ** 4 : size ** 3,
),
...polarity,
};
}
default:
return fail(
"UNSUPPORTED_CONSTRAINT",
@@ -354,6 +540,15 @@ function textList(value: unknown, label: string): string[] | undefined {
export function normalizeSudokuDocument(value: unknown): SudokuDocument {
if (!isRecord(value))
return fail("INVALID_DOCUMENT", "The puzzle document must be an object.");
const unknownField = Object.keys(value).find(
(field) => !DOCUMENT_FIELDS.has(field),
);
if (unknownField !== undefined) {
return fail(
"UNSUPPORTED_DOCUMENT_FIELD",
`The puzzle document field “${unknownField}” is not supported. Raw code, markup and custom fields are never imported.`,
);
}
if (value.schema !== SUDOKU_DOCUMENT_SCHEMA) {
return fail("INVALID_SCHEMA", `Expected schema ${SUDOKU_DOCUMENT_SCHEMA}.`);
}
@@ -458,11 +653,33 @@ export function normalizeSudokuDocument(value: unknown): SudokuDocument {
const constraints = value.constraints.map((constraint) =>
parseConstraint(constraint, cellCount),
);
if (
constraints.some(({ type }) => type === "fog") &&
solution === undefined
) {
return fail(
"INVALID_CONSTRAINT",
"Fog of War requires a complete trusted solution for correct reveals.",
);
}
const title = optionalText(value.title, "title");
const author = optionalText(value.author, "author");
const id = optionalText(value.id, "id");
const rules = textList(value.rules, "rules");
const globalRules = textList(value.globalRules, "globalRules");
let visuals;
let source;
let metadata;
try {
visuals = normalizeSafeVisualPrimitives(value.visuals, size);
source = normalizeSourceIdentity(value.source);
metadata = normalizeScalarMetadata(value.metadata);
} catch (error) {
if (error instanceof SafeVisualValidationError) {
return fail("INVALID_SOURCE_EXTRAS", error.message);
}
throw error;
}
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
@@ -484,6 +701,9 @@ export function normalizeSudokuDocument(value: unknown): SudokuDocument {
...(id === undefined ? {} : { id }),
...(rules === undefined ? {} : { rules }),
...(globalRules === undefined ? {} : { globalRules }),
...(visuals === undefined ? {} : { visuals }),
...(source === undefined ? {} : { source }),
...(metadata === undefined ? {} : { metadata }),
};
}
@@ -561,5 +781,14 @@ export function cloneSudokuDocument(value: SudokuDocument): SudokuDocument {
...(normalized.globalRules === undefined
? {}
: { globalRules: [...normalized.globalRules] }),
...(normalized.visuals === undefined
? {}
: { visuals: normalized.visuals.map(cloneVisualPrimitive) }),
...(normalized.source === undefined
? {}
: { source: { ...normalized.source } }),
...(normalized.metadata === undefined
? {}
: { metadata: { ...normalized.metadata } }),
};
}
+747 -38
View File
@@ -11,10 +11,18 @@ import {
SudokuFormatError,
} from "./document";
import { UnsupportedPuzzleConstructsError } from "./interoperability";
import {
SafeVisualValidationError,
normalizeSafeVisualColor,
normalizeSafeVisualPrimitives,
} from "./safeVisuals";
import {
SUDOKU_DOCUMENT_SCHEMA,
SUDOKU_DOCUMENT_VERSION,
type PortableConstraint,
type SafeVisualAnchor,
type SafeVisualPrimitive,
type ScalarMetadataValue,
type SudokuDocument,
} from "./types";
@@ -33,6 +41,7 @@ const SUPPORTED_ROOT_FIELDS = new Set([
"antiking",
"antikingsmove",
"nonconsecutive",
"disjointgroups",
"killercage",
"thermometer",
"arrow",
@@ -44,48 +53,45 @@ const SUPPORTED_ROOT_FIELDS = new Set([
"skyscraper",
"quadruple",
"maximum",
"minimum",
"even",
"odd",
"littlekillersum",
"sandwichsum",
"extraregion",
"clone",
"betweenline",
"whispers",
"regionsumline",
"entropicline",
"modularline",
"zipperline",
"doublearrow",
"rowindexer",
"columnindexer",
"boxindexer",
"fogofwar",
"foglight",
"renban",
"palindrome",
"disabledlogic",
"truecandidatesoptions",
"successMessage",
"successmessage",
"id",
"line",
"rectangle",
"circle",
"text",
]);
const UNSUPPORTED_RULE_FIELDS: Readonly<Record<string, string>> = {
disjointgroups: "disjoint groups",
littlekillersum: "little killer sums",
sandwichsum: "sandwich sums",
even: "even cells",
odd: "odd cells",
extraregion: "extra regions",
clone: "clone regions",
betweenline: "between lines",
minimum: "minimum cells",
whispers: "whisper lines",
regionsumline: "region-sum lines",
entropicline: "entropic lines",
modularline: "modular lines",
zipperline: "zipper lines",
nabner: "Nabner lines",
doublearrow: "double arrows",
lockout: "lockout lines",
rowindexer: "row indexers",
columnindexer: "column indexers",
boxindexer: "box indexers",
fogofwar: "fog of war",
foglight: "fog lights",
cage: "generic cages",
negative: "negative constraints",
};
const DECORATION_FIELDS: Readonly<Record<string, string>> = {
line: "decorative lines",
rectangle: "rectangles",
circle: "circles",
text: "text decorations",
};
export class NetworkPuzzleIdError extends SudokuFormatError {
readonly puzzleId: string;
@@ -124,16 +130,7 @@ function assertSupportedRootFields(value: JsonRecord): void {
constructs.push(unsupported);
continue;
}
const decoration = DECORATION_FIELDS[field];
if (decoration !== undefined && present(raw)) {
constructs.push(decoration);
continue;
}
if (
!SUPPORTED_ROOT_FIELDS.has(field) &&
unsupported === undefined &&
decoration === undefined
) {
if (!SUPPORTED_ROOT_FIELDS.has(field) && unsupported === undefined) {
constructs.push(`Unknown fpuzzles field “${field}`);
}
}
@@ -307,6 +304,337 @@ function numeric(
return parsed as number;
}
function finiteNumber(
value: unknown,
label: string,
minimum: number,
maximum: number,
fallback?: number,
): number {
if (value === undefined && fallback !== undefined) return fallback;
const parsed =
typeof value === "string" && value.trim() !== "" ? Number(value) : value;
if (
typeof parsed !== "number" ||
!Number.isFinite(parsed) ||
parsed < minimum ||
parsed > maximum
) {
return fail("INVALID_FPUZZLES", `${label} is outside the supported range.`);
}
return parsed;
}
function visualColor(value: unknown, label: string, fallback: string): string {
try {
return normalizeSafeVisualColor(value ?? fallback, label);
} catch (error) {
if (error instanceof SafeVisualValidationError) {
return fail("UNSAFE_VISUAL_STYLE", error.message);
}
throw error;
}
}
function assertVisualFields(
value: JsonRecord,
allowed: ReadonlySet<string>,
label: string,
): void {
const unknown = Object.keys(value).find((field) => !allowed.has(field));
if (unknown !== undefined) {
return fail(
"UNSAFE_VISUAL_CONSTRUCT",
`${label}.${unknown} is not an allowlisted decorative field. Raw paths, styles and custom code are not imported.`,
);
}
}
function fpVisualAnchor(
value: unknown,
size: number,
label: string,
): SafeVisualAnchor {
if (typeof value !== "string") {
return fail("INVALID_FPUZZLES", `${label} must be an RnCn position.`);
}
const match = /^R(-?\d+(?:\.\d+)?)C(-?\d+(?:\.\d+)?)$/iu.exec(value.trim());
if (match === null) {
return fail("INVALID_FPUZZLES", `${label} is not an RnCn position.`);
}
const row = Number(match[1]);
const column = Number(match[2]);
if (
!Number.isFinite(row) ||
!Number.isFinite(column) ||
row < -3.5 ||
row > size + 4.5 ||
column < -3.5 ||
column > size + 4.5
) {
return fail("INVALID_FPUZZLES", `${label} is outside the visual canvas.`);
}
if (
Number.isInteger(row) &&
Number.isInteger(column) &&
row >= 1 &&
row <= size &&
column >= 1 &&
column <= size
) {
return { kind: "cell", cell: (row - 1) * size + column - 1 };
}
return { kind: "coordinate", x: column - 0.5, y: row - 0.5 };
}
function fpVisualCenter(
value: JsonRecord,
size: number,
label: string,
): SafeVisualAnchor {
if (value.cell !== undefined)
return fpVisualAnchor(value.cell, size, `${label}.cell`);
if (!Array.isArray(value.cells) || value.cells.length === 0) {
return fail("INVALID_FPUZZLES", `${label} needs a cell or cells anchor.`);
}
if (value.cells.length > size * size) {
return fail("LIMIT_EXCEEDED", `${label}.cells contains too many anchors.`);
}
const anchors = value.cells.map((entry, index) =>
fpVisualAnchor(entry, size, `${label}.cells[${String(index)}]`),
);
if (anchors.length === 1) return anchors[0]!;
const coordinates = anchors.map((anchor) =>
anchor.kind === "coordinate"
? anchor
: {
x: (anchor.cell % size) + 0.5 + (anchor.offsetX ?? 0),
y: Math.floor(anchor.cell / size) + 0.5 + (anchor.offsetY ?? 0),
},
);
return {
kind: "coordinate",
x:
coordinates.reduce((sum, anchor) => sum + anchor.x, 0) /
coordinates.length,
y:
coordinates.reduce((sum, anchor) => sum + anchor.y, 0) /
coordinates.length,
};
}
function parseFpuzzlesVisuals(
value: JsonRecord,
size: number,
): SafeVisualPrimitive[] {
const output: SafeVisualPrimitive[] = [];
const lineFields = new Set([
"lines",
"outlineC",
"width",
"opacity",
"isLLConstraint",
"fromConstraint",
]);
for (const [index, item] of objects(value.line).entries()) {
assertVisualFields(item, lineFields, `line[${String(index)}]`);
if (!Array.isArray(item.lines) || item.lines.length > size * size) {
return fail("INVALID_FPUZZLES", "line.lines must be a bounded array.");
}
for (const [lineIndex, rawLine] of item.lines.entries()) {
if (
!Array.isArray(rawLine) ||
rawLine.length < 2 ||
rawLine.length > size * size
) {
return fail(
"INVALID_FPUZZLES",
"A decorative line needs bounded points.",
);
}
output.push({
type: "polyline",
layer: "overlay",
points: rawLine.map((entry, pointIndex) =>
fpVisualAnchor(
entry,
size,
`line[${String(index)}].lines[${String(lineIndex)}][${String(pointIndex)}]`,
),
),
style: {
stroke: visualColor(item.outlineC, "line.outlineC", "#000000"),
fill: "transparent",
strokeWidth: finiteNumber(item.width, "line.width", 0, 4, 0.05),
opacity: finiteNumber(item.opacity, "line.opacity", 0, 1, 1),
},
});
}
}
const shapeFields = new Set([
"cell",
"cells",
"baseC",
"outlineC",
"fontC",
"width",
"height",
"angle",
"value",
"opacity",
"isLLConstraint",
"fromConstraint",
]);
for (const kind of ["rectangle", "circle"] as const) {
for (const [index, item] of objects(value[kind]).entries()) {
const label = `${kind}[${String(index)}]`;
assertVisualFields(item, shapeFields, label);
if (item.angle !== undefined && Number(item.angle) !== 0) {
return fail(
"UNSAFE_VISUAL_CONSTRUCT",
`${label}.angle cannot be represented by the safe visual model.`,
);
}
const center = fpVisualCenter(item, size, label);
const width = finiteNumber(
item.width,
`${label}.width`,
0.01,
size + 8,
1,
);
const height = finiteNumber(
item.height,
`${label}.height`,
0.01,
size + 8,
1,
);
const style = {
stroke: visualColor(item.outlineC, `${label}.outlineC`, "transparent"),
fill: visualColor(item.baseC, `${label}.baseC`, "transparent"),
strokeWidth: 0.02,
opacity: finiteNumber(item.opacity, `${label}.opacity`, 0, 1, 1),
};
output.push(
kind === "circle"
? width === height
? {
type: "circle",
layer: "overlay",
center,
radius: width / 2,
style,
}
: {
type: "ellipse",
layer: "overlay",
center,
radiusX: width / 2,
radiusY: height / 2,
style,
}
: {
type: "rectangle",
layer: "overlay",
center,
width,
height,
style,
},
);
if (item.value !== undefined && String(item.value).length > 0) {
output.push({
type: "text",
layer: "overlay",
position: center,
text: String(item.value),
style: {
fill: visualColor(item.fontC, `${label}.fontC`, "#000000"),
fontSize: 0.5,
opacity: style.opacity,
},
});
}
}
}
const textFields = new Set([
"cell",
"cells",
"value",
"fontC",
"size",
"angle",
"opacity",
]);
for (const [index, item] of objects(value.text).entries()) {
const label = `text[${String(index)}]`;
assertVisualFields(item, textFields, label);
if (item.angle !== undefined && Number(item.angle) !== 0) {
return fail(
"UNSAFE_VISUAL_CONSTRUCT",
`${label}.angle cannot be represented by the safe visual model.`,
);
}
if (item.value === undefined) continue;
output.push({
type: "text",
layer: "overlay",
position: fpVisualCenter(item, size, label),
text: String(item.value),
style: {
fill: visualColor(item.fontC, `${label}.fontC`, "#000000"),
fontSize: 0.5 * finiteNumber(item.size, `${label}.size`, 0.1, 16, 1),
opacity: finiteNumber(item.opacity, `${label}.opacity`, 0, 1, 1),
},
});
}
try {
return normalizeSafeVisualPrimitives(output, size) ?? [];
} catch (error) {
if (error instanceof SafeVisualValidationError) {
return fail("INVALID_FPUZZLES_VISUAL", error.message);
}
throw error;
}
}
function fpuzzlesScalarMetadata(
value: JsonRecord,
): Record<string, ScalarMetadataValue> {
const output: Record<string, ScalarMetadataValue> = {};
for (const key of [
"successMessage",
"successmessage",
"disabledlogic",
"truecandidatesoptions",
] as const) {
const raw = value[key];
if (raw === undefined) continue;
if (
raw !== null &&
typeof raw !== "string" &&
typeof raw !== "number" &&
typeof raw !== "boolean"
) {
return fail(
"UNSUPPORTED_FPUZZLES",
`${key} is not scalar metadata and cannot be retained safely.`,
);
}
if (typeof raw === "number" && !Number.isFinite(raw)) {
return fail("INVALID_FPUZZLES", `${key} must be finite.`);
}
if (typeof raw === "string" && raw.length > 8_192) {
return fail("LIMIT_EXCEEDED", `${key} is too long.`);
}
output[key] = raw;
}
return output;
}
function readGrid(value: unknown, size: number): JsonRecord[] {
if (!Array.isArray(value))
return fail("INVALID_FPUZZLES", "fpuzzles.grid must be an array.");
@@ -341,7 +669,17 @@ function addLineConstraints(
output: PortableConstraint[],
source: unknown,
size: number,
type: "thermo" | "renban" | "palindrome",
type:
| "thermo"
| "renban"
| "palindrome"
| "between-line"
| "german-whisper"
| "region-sum-line"
| "modular-line"
| "entropic-line"
| "zipper-line"
| "double-arrow",
): void {
for (const item of objects(source)) {
for (const line of lines(item.lines ?? item.cells, size))
@@ -349,6 +687,50 @@ function addLineConstraints(
}
}
function littleKillerDirection(value: unknown) {
if (typeof value !== "string") {
return fail(
"INVALID_FPUZZLES",
"A little-killer direction must be UL, UR, DL, or DR.",
);
}
const normalized = value.replaceAll(/[^a-z]/giu, "").toUpperCase();
switch (normalized) {
case "UL":
case "UPLEFT":
return "up-left" as const;
case "UR":
case "UPRIGHT":
return "up-right" as const;
case "DL":
case "DOWNLEFT":
return "down-left" as const;
case "DR":
case "DOWNRIGHT":
return "down-right" as const;
default:
return fail(
"INVALID_FPUZZLES",
"A little-killer direction must be UL, UR, DL, or DR.",
);
}
}
function fpLittleKillerDirection(
value: "up-left" | "up-right" | "down-left" | "down-right",
): "UL" | "UR" | "DL" | "DR" {
switch (value) {
case "up-left":
return "UL";
case "up-right":
return "UR";
case "down-left":
return "DL";
case "down-right":
return "DR";
}
}
function parseRules(value: unknown): string[] | undefined {
if (value === undefined || value === "") return undefined;
if (typeof value === "string") return [value];
@@ -429,6 +811,8 @@ export function parseFpuzzles(value: unknown): SudokuDocument {
constraints.push({ type: "anti-king" });
if (value.nonconsecutive === true)
constraints.push({ type: "non-consecutive" });
if (value.disjointgroups === true)
constraints.push({ type: "disjoint-groups" });
for (const cage of objects(value.killercage)) {
if (cage.value === undefined || cage.value === "") {
@@ -449,6 +833,25 @@ export function parseFpuzzles(value: unknown): SudokuDocument {
addLineConstraints(constraints, value.thermometer, size, "thermo");
addLineConstraints(constraints, value.renban, size, "renban");
addLineConstraints(constraints, value.palindrome, size, "palindrome");
addLineConstraints(constraints, value.betweenline, size, "between-line");
addLineConstraints(constraints, value.regionsumline, size, "region-sum-line");
addLineConstraints(constraints, value.modularline, size, "modular-line");
addLineConstraints(constraints, value.entropicline, size, "entropic-line");
addLineConstraints(constraints, value.zipperline, size, "zipper-line");
addLineConstraints(constraints, value.doublearrow, size, "double-arrow");
for (const whisper of objects(value.whispers)) {
const minimumDifference =
whisper.value === undefined || whisper.value === ""
? undefined
: numeric(whisper.value, "whispers.value", 1, size - 1);
for (const line of lines(whisper.lines ?? whisper.cells, size)) {
constraints.push({
type: "german-whisper",
cells: line,
...(minimumDifference === undefined ? {} : { minimumDifference }),
});
}
}
for (const arrow of objects(value.arrow)) {
const bulb = fpCells(arrow.cells, size);
@@ -576,6 +979,95 @@ export function parseFpuzzles(value: unknown): SudokuDocument {
cell: cellIndexFromAddress(clue.cell, size),
});
}
for (const clue of objects(value.minimum)) {
constraints.push({
type: "minimum",
cell: cellIndexFromAddress(clue.cell, size),
});
}
for (const clue of objects(value.odd)) {
constraints.push({
type: "odd",
cell: cellIndexFromAddress(clue.cell, size),
});
}
for (const clue of objects(value.even)) {
constraints.push({
type: "even",
cell: cellIndexFromAddress(clue.cell, size),
});
}
for (const clue of objects(value.littlekillersum)) {
constraints.push({
type: "little-killer",
...outsideClueFromAddress(clue.cell, size),
direction: littleKillerDirection(clue.direction),
sum: numeric(clue.value, "littlekillersum.value", 1, size ** 3),
});
}
for (const clue of objects(value.sandwichsum)) {
constraints.push({
type: "sandwich",
...outsideClueFromAddress(clue.cell, size),
sum: numeric(clue.value, "sandwichsum.value", 0, size ** 3),
});
}
for (const clue of objects(value.extraregion)) {
constraints.push({
type: "extra-region",
cells: fpCells(clue.cells, size),
});
}
for (const clue of objects(value.clone)) {
constraints.push({
type: "clone",
cells: fpCells(clue.cells, size),
cloneCells: fpCells(clue.cloneCells, size),
});
}
for (const kind of ["row", "column", "box"] as const) {
const field = `${kind}indexer`;
for (const clue of objects(value[field])) {
const rawCells = clue.cells;
if (Array.isArray(rawCells)) {
for (const indexedCell of fpCells(rawCells, size)) {
constraints.push({ type: "indexer", kind, cell: indexedCell });
}
} else {
constraints.push({
type: "indexer",
kind,
cell: cellIndexFromAddress(clue.cell, size),
});
}
}
}
if (value.fogofwar === true || present(value.foglight)) {
const lights: number[] = [];
for (const clue of objects(value.foglight)) {
if (Array.isArray(clue.cells)) lights.push(...fpCells(clue.cells, size));
else lights.push(cellIndexFromAddress(clue.cell, size));
}
if (lights.length === 0) {
for (let cell = 0; cell < givens.length; cell += 1) {
if ((givens[cell] ?? 0) !== 0) lights.push(cell);
}
}
if (lights.length === 0) {
return fail(
"INVALID_FPUZZLES",
"Fog of War requires at least one fog light or given cell.",
);
}
if (solution === undefined) {
return fail(
"INVALID_FPUZZLES",
"Fog of War requires an embedded solution for safe local reveals.",
);
}
constraints.push({ type: "fog", lights: [...new Set(lights)] });
}
const regions = readRegions(grid, size);
const rules = parseRules(value.ruleset);
@@ -585,6 +1077,14 @@ export function parseFpuzzles(value: unknown): SudokuDocument {
typeof value.author === "string"
? value.author.slice(0, 20_000)
: undefined;
const visuals = parseFpuzzlesVisuals(value, size);
const metadata = fpuzzlesScalarMetadata(value);
const sourceId =
value.id === undefined
? undefined
: typeof value.id === "string" && value.id.length <= 512
? value.id
: fail("INVALID_FPUZZLES", "fpuzzles.id must be bounded text.");
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
@@ -600,6 +1100,13 @@ export function parseFpuzzles(value: unknown): SudokuDocument {
...(rules === undefined ? {} : { rules }),
...(title === undefined ? {} : { title }),
...(author === undefined ? {} : { author }),
...(sourceId === undefined ? {} : { id: sourceId }),
...(visuals.length === 0 ? {} : { visuals }),
source: {
format: "fpuzzles",
...(sourceId === undefined ? {} : { id: sourceId }),
},
...(Object.keys(metadata).length === 0 ? {} : { metadata }),
};
}
@@ -607,6 +1114,109 @@ function constraintCells(cells: readonly number[], size: number): string[] {
return cells.map((cell) => addressFromCellIndex(cell, size));
}
function fpAddressFromVisualAnchor(
anchor: SafeVisualAnchor,
size: number,
): string {
if (anchor.kind === "cell") {
if ((anchor.offsetX ?? 0) !== 0 || (anchor.offsetY ?? 0) !== 0) {
return fail(
"UNSUPPORTED_FPUZZLES_VISUAL",
"f-puzzles cannot preserve an offset visual anchor.",
);
}
return addressFromCellIndex(anchor.cell, size);
}
const row = anchor.y + 0.5;
const column = anchor.x + 0.5;
if (
!Number.isInteger(row) ||
!Number.isInteger(column) ||
row < 1 ||
row > size ||
column < 1 ||
column > size
) {
return fail(
"UNSUPPORTED_FPUZZLES_VISUAL",
"f-puzzles cannot preserve a free-coordinate visual anchor.",
);
}
return `R${String(row)}C${String(column)}`;
}
function exportFpuzzlesVisual(
visual: SafeVisualPrimitive,
size: number,
): readonly [
field: "line" | "rectangle" | "circle" | "text",
value: JsonRecord,
] {
if (visual.layer !== "overlay") {
return fail(
"UNSUPPORTED_FPUZZLES_VISUAL",
"f-puzzles cannot preserve explicit underlay ordering.",
);
}
const style = visual.style ?? {};
switch (visual.type) {
case "line":
case "polyline": {
const anchors =
visual.type === "line" ? [visual.start, visual.end] : visual.points;
return [
"line",
{
lines: [
anchors.map((anchor) => fpAddressFromVisualAnchor(anchor, size)),
],
outlineC: style.stroke ?? "#000000",
width: style.strokeWidth ?? 0.05,
...(style.opacity === undefined ? {} : { opacity: style.opacity }),
},
];
}
case "rectangle":
return [
"rectangle",
{
cells: [fpAddressFromVisualAnchor(visual.center, size)],
width: visual.width,
height: visual.height,
baseC: style.fill ?? "transparent",
outlineC: style.stroke ?? "transparent",
...(style.opacity === undefined ? {} : { opacity: style.opacity }),
},
];
case "ellipse":
case "circle":
return [
"circle",
{
cells: [fpAddressFromVisualAnchor(visual.center, size)],
width:
visual.type === "circle" ? visual.radius * 2 : visual.radiusX * 2,
height:
visual.type === "circle" ? visual.radius * 2 : visual.radiusY * 2,
baseC: style.fill ?? "transparent",
outlineC: style.stroke ?? "transparent",
...(style.opacity === undefined ? {} : { opacity: style.opacity }),
},
];
case "text":
return [
"text",
{
cells: [fpAddressFromVisualAnchor(visual.position, size)],
value: visual.text,
fontC: style.fill ?? "#000000",
size: (style.fontSize ?? 0.5) / 0.5,
...(style.opacity === undefined ? {} : { opacity: style.opacity }),
},
];
}
}
export function exportFpuzzles(document: SudokuDocument): JsonRecord {
const { size } = document;
const output: JsonRecord = {
@@ -672,6 +1282,9 @@ export function exportFpuzzles(document: SudokuDocument): JsonRecord {
case "non-consecutive":
output.nonconsecutive = true;
break;
case "disjoint-groups":
output.disjointgroups = true;
break;
case "killer-cage":
append("killercage", {
cells: constraintCells(constraint.cells, size),
@@ -692,6 +1305,31 @@ export function exportFpuzzles(document: SudokuDocument): JsonRecord {
lines: [constraintCells(constraint.cells, size)],
});
break;
case "between-line":
case "region-sum-line":
case "modular-line":
case "entropic-line":
case "zipper-line":
case "double-arrow": {
const field =
constraint.type === "between-line"
? "betweenline"
: constraint.type === "region-sum-line"
? "regionsumline"
: constraint.type.replace("-", "");
append(field, {
lines: [constraintCells(constraint.cells, size)],
});
break;
}
case "german-whisper":
append("whispers", {
lines: [constraintCells(constraint.cells, size)],
...(constraint.minimumDifference === undefined
? {}
: { value: String(constraint.minimumDifference) }),
});
break;
case "arrow":
append("arrow", {
cells: constraintCells(constraint.bulb, size),
@@ -764,9 +1402,80 @@ export function exportFpuzzles(document: SudokuDocument): JsonRecord {
cell: addressFromCellIndex(constraint.cell, size),
});
break;
case "minimum":
case "odd":
case "even":
append(constraint.type, {
cell: addressFromCellIndex(constraint.cell, size),
});
break;
case "little-killer":
append("littlekillersum", {
cell: addressFromOutsideClue(
constraint.side,
numeric(constraint.index, "littlekillersum.index", 0, size - 1),
size,
),
direction: fpLittleKillerDirection(constraint.direction),
value: String(constraint.sum),
});
break;
case "sandwich":
append("sandwichsum", {
cell: addressFromOutsideClue(
constraint.side,
numeric(constraint.index, "sandwichsum.index", 0, size - 1),
size,
),
value: String(constraint.sum),
});
break;
case "clone":
append("clone", {
cells: constraintCells(constraint.cells, size),
cloneCells: constraintCells(constraint.cloneCells, size),
});
break;
case "extra-region":
append("extraregion", {
cells: constraintCells(constraint.cells, size),
});
break;
case "indexer":
append(`${constraint.kind}indexer`, {
cell: addressFromCellIndex(constraint.cell, size),
});
break;
case "fog":
output.fogofwar = true;
append("foglight", {
cells: constraintCells(constraint.lights, size),
});
break;
}
}
for (const visual of document.visuals ?? []) {
const [field, value] = exportFpuzzlesVisual(visual, size);
append(field, value);
}
for (const [key, value] of Object.entries(document.metadata ?? {})) {
if (
key === "successMessage" ||
key === "successmessage" ||
key === "disabledlogic" ||
key === "truecandidatesoptions"
) {
output[key] = value;
}
}
if (
document.source?.format === "fpuzzles" &&
document.source.id !== undefined
) {
output.id = document.source.id;
}
if ((document.globalRules?.length ?? 0) > 0) {
return fail(
"UNSUPPORTED_FPUZZLES",
+1
View File
@@ -69,6 +69,7 @@ export function parsePlainGrid(
size,
givens,
constraints: [],
source: { format: "plain-grid" },
...(options.title === undefined ? {} : { title: options.title }),
...(options.author === undefined ? {} : { author: options.author }),
};
+45 -60
View File
@@ -9,6 +9,7 @@ import {
parseFpuzzles,
} from "./fpuzzles";
import {
puzzleImportResult,
type PuzzleImportResult,
RemotePuzzleReferenceError,
} from "./interoperability";
@@ -88,61 +89,45 @@ export async function importPuzzle(
throw new RemotePuzzleReferenceError("The pasted URL");
}
if (url?.kind === "penpa") {
return {
document: await importPenpa(url.value),
format: "penpa",
label: "Penpa+",
};
return puzzleImportResult(await importPenpa(url.value), "penpa", "Penpa+");
}
if (url?.kind === "sudokupad") {
return {
document: importSudokuPad(url.value),
format: "sudokupad",
label: "SudokuPad/CTC",
};
return puzzleImportResult(
importSudokuPad(url.value),
"sudokupad",
"SudokuPad/CTC",
);
}
if (url?.kind === "fpuzzles") {
return {
document: importFpuzzles(url.value),
format: "fpuzzles",
label: "f-puzzles",
};
return puzzleImportResult(
importFpuzzles(url.value),
"fpuzzles",
"f-puzzles",
);
}
if (trimmed.startsWith("#sudoku=") || trimmed.includes("#sudoku=")) {
return {
document: decodePuzzleHash(trimmed),
format: "sudoku-tools",
label: "Sudoku Tools share link",
};
return puzzleImportResult(
decodePuzzleHash(trimmed),
"sudoku-tools",
"Sudoku Tools share link",
);
}
if (/^(?:ctc|scl)/iu.test(trimmed)) {
return {
document: importSudokuPad(trimmed),
format: "sudokupad",
label: "SudokuPad/CTC",
};
return puzzleImportResult(
importSudokuPad(trimmed),
"sudokupad",
"SudokuPad/CTC",
);
}
if (/^penpa:/iu.test(trimmed) || /^[?#]?(?:m=[^&]+&)?p=/iu.test(trimmed)) {
return {
document: await importPenpa(trimmed),
format: "penpa",
label: "Penpa+",
};
return puzzleImportResult(await importPenpa(trimmed), "penpa", "Penpa+");
}
if (/^(?:square|sudoku),[^\r\n]+[\r\n]/iu.test(trimmed)) {
return {
document: parsePenpaText(trimmed),
format: "penpa",
label: "Penpa+ text",
};
return puzzleImportResult(parsePenpaText(trimmed), "penpa", "Penpa+ text");
}
if (/^fpuzzles/iu.test(trimmed)) {
return {
document: importFpuzzles(trimmed),
format: "fpuzzles",
label: "f-puzzles",
};
return puzzleImportResult(importFpuzzles(trimmed), "fpuzzles", "f-puzzles");
}
if (trimmed.startsWith("{")) {
if (new TextEncoder().encode(trimmed).byteLength > MAX_DOCUMENT_BYTES) {
@@ -162,28 +147,28 @@ export async function importPuzzle(
);
}
if (isRecord(parsed) && "schema" in parsed) {
return {
document: parseSudokuDocument(trimmed),
format: "sudoku-tools",
label: "Sudoku Tools JSON",
};
return puzzleImportResult(
parseSudokuDocument(trimmed),
"sudoku-tools",
"Sudoku Tools JSON",
);
}
if (isRecord(parsed) && "cells" in parsed && !("grid" in parsed)) {
return {
document: parseSudokuPadPuzzle(parsed),
format: "sudokupad",
label: "SudokuPad/CTC JSON",
};
return puzzleImportResult(
parseSudokuPadPuzzle(parsed),
"sudokupad",
"SudokuPad/CTC JSON",
);
}
return {
document: parseFpuzzles(parsed),
format: "fpuzzles",
label: "f-puzzles JSON",
};
return puzzleImportResult(
parseFpuzzles(parsed),
"fpuzzles",
"f-puzzles JSON",
);
}
return {
document: parsePlainGrid(trimmed),
format: "plain-grid",
label: "plain grid",
};
return puzzleImportResult(
parsePlainGrid(trimmed),
"plain-grid",
"plain grid",
);
}
+2
View File
@@ -1,9 +1,11 @@
export * from "./constraintVisuals";
export * from "./document";
export * from "./fpuzzles";
export * from "./grid";
export * from "./import";
export * from "./interoperability";
export * from "./penpa";
export * from "./safeVisuals";
export * from "./share";
export * from "./sudokupad";
export * from "./types";
+119 -2
View File
@@ -1,12 +1,129 @@
import { SudokuFormatError } from "./document";
import type { SudokuDocument, SudokuSourceFormat } from "./types";
export type PuzzleSourceFormat =
"sudoku-tools" | "plain-grid" | "fpuzzles" | "sudokupad" | "penpa";
export type PuzzleSourceFormat = SudokuSourceFormat;
export interface PuzzleMappingEntry {
readonly key: string;
readonly label: string;
readonly count: number;
}
export interface PuzzleImportMappingPreview {
readonly mappedSemantics: readonly PuzzleMappingEntry[];
readonly preservedVisuals: readonly PuzzleMappingEntry[];
readonly preservedMetadata: readonly PuzzleMappingEntry[];
readonly warnings: readonly string[];
}
export interface PuzzleImportResult<T> {
readonly document: T;
readonly format: PuzzleSourceFormat;
readonly label: string;
readonly preview: PuzzleImportMappingPreview;
}
function readableType(value: string): string {
return value
.split("-")
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
.join(" ");
}
export function buildPuzzleImportPreview(
document: SudokuDocument,
extraWarnings: readonly string[] = [],
): PuzzleImportMappingPreview {
const semanticCounts = new Map<string, number>();
for (const constraint of document.constraints) {
semanticCounts.set(
constraint.type,
(semanticCounts.get(constraint.type) ?? 0) + 1,
);
}
const givenCount = document.givens.filter((value) => value !== 0).length;
const mappedSemantics: PuzzleMappingEntry[] = [
{ key: "givens", label: "Given digits", count: givenCount },
...[...semanticCounts].map(([key, count]) => ({
key,
label: readableType(key),
count,
})),
];
if (document.regions !== undefined) {
mappedSemantics.push({ key: "regions", label: "Region map", count: 1 });
}
const visualCounts = new Map<string, number>();
for (const visual of document.visuals ?? []) {
const key = `${visual.layer}:${visual.type}`;
visualCounts.set(key, (visualCounts.get(key) ?? 0) + 1);
}
const preservedVisuals = [...visualCounts].map(([key, count]) => {
const [layer = "overlay", type = key] = key.split(":");
return {
key,
label: `${readableType(type)} ${layer}`,
count,
};
});
const preservedMetadata: PuzzleMappingEntry[] = [];
if (document.source !== undefined) {
preservedMetadata.push({
key: "source",
label: `Source identity (${document.source.format})`,
count: 1,
});
}
for (const key of Object.keys(document.metadata ?? {})) {
preservedMetadata.push({ key, label: key, count: 1 });
}
for (const [key, present] of [
["title", document.title !== undefined],
["author", document.author !== undefined],
["rules", (document.rules?.length ?? 0) > 0],
["progress", document.values !== undefined],
] as const) {
if (present)
preservedMetadata.push({ key, label: readableType(key), count: 1 });
}
const warnings = [...extraWarnings];
if (preservedVisuals.length > 0) {
warnings.push(
"Imported drawings are preserved as inert visuals; a visual clue is not solver-enforced unless it also appears under mapped semantics.",
);
}
if (Object.keys(document.metadata ?? {}).length > 0) {
warnings.push(
"Uninterpreted scalar metadata is retained for round-trip export but does not change puzzle rules.",
);
}
return {
mappedSemantics,
preservedVisuals,
preservedMetadata,
warnings: [...new Set(warnings)],
};
}
export function puzzleImportResult(
document: SudokuDocument,
format: PuzzleSourceFormat,
label: string,
warnings: readonly string[] = [],
): PuzzleImportResult<SudokuDocument> {
const sourced =
document.source === undefined
? { ...document, source: { format } as const }
: document;
return {
document: sourced,
format,
label,
preview: buildPuzzleImportPreview(sourced, warnings),
};
}
export class UnsupportedPuzzleConstructsError extends SudokuFormatError {
+1
View File
@@ -452,6 +452,7 @@ export function parsePenpaText(compressedText: string): SudokuDocument {
givens,
values,
constraints,
source: { format: "penpa" },
...(title === undefined || title === "" ? {} : { title }),
...(author === undefined || author === "" ? {} : { author }),
...(rules === undefined ? {} : { rules: [rules] }),
+407
View File
@@ -0,0 +1,407 @@
import type {
SafeVisualAnchor,
SafeVisualPrimitive,
SafeVisualStyle,
ScalarMetadata,
ScalarMetadataValue,
SudokuSourceFormat,
SudokuSourceIdentity,
} from "./types";
export const MAX_VISUAL_PRIMITIVES = 2_000;
export const MAX_VISUAL_POINTS = 20_000;
export const MAX_VISUAL_TEXT_LENGTH = 4_096;
export const MAX_SCALAR_METADATA_FIELDS = 128;
export const MAX_SCALAR_METADATA_KEY_LENGTH = 96;
export const MAX_SCALAR_METADATA_TEXT_LENGTH = 8_192;
const SOURCE_FORMATS = new Set<SudokuSourceFormat>([
"sudoku-tools",
"plain-grid",
"fpuzzles",
"sudokupad",
"penpa",
]);
const EXECUTABLE_KEY =
/^(?:on[a-z]+|script|javascript|html|svg|css|style|src|href|url|code|customcode|customstyle)$/iu;
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/iu;
const NAMED_COLORS: Readonly<Record<string, string>> = {
black: "#000000",
white: "#ffffff",
transparent: "transparent",
none: "transparent",
};
export class SafeVisualValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "SafeVisualValidationError";
}
}
function fail(message: string): never {
throw new SafeVisualValidationError(message);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function exactFields(
value: Record<string, unknown>,
allowed: ReadonlySet<string>,
label: string,
): void {
const invalid = Object.keys(value).find((key) => !allowed.has(key));
if (invalid !== undefined) {
fail(`${label}.${invalid} is not an allowlisted visual property.`);
}
}
function finite(
value: unknown,
label: string,
minimum: number,
maximum: number,
): number {
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
value < minimum ||
value > maximum
) {
return fail(
`${label} must be a finite number from ${minimum} to ${maximum}.`,
);
}
return value;
}
/** Accept only inert hexadecimal tokens (or transparent), never CSS syntax. */
export function normalizeSafeVisualColor(
value: unknown,
label = "visual colour",
): string {
if (typeof value !== "string")
return fail(`${label} must be a colour token.`);
const trimmed = value.trim().toLowerCase();
const named = NAMED_COLORS[trimmed];
if (named !== undefined) return named;
if (!HEX_COLOR.test(trimmed)) {
return fail(
`${label} must be a hexadecimal colour or transparent; CSS expressions are not accepted.`,
);
}
if (trimmed.length === 4 || trimmed.length === 5) {
return `#${[...trimmed.slice(1)].map((digit) => digit + digit).join("")}`;
}
return trimmed;
}
function normalizeAnchor(
value: unknown,
size: number,
label: string,
): SafeVisualAnchor {
if (!isRecord(value)) return fail(`${label} must be a visual anchor.`);
if (value.kind === "coordinate") {
exactFields(value, new Set(["kind", "x", "y"]), label);
return {
kind: "coordinate",
x: finite(value.x, `${label}.x`, -4, size + 4),
y: finite(value.y, `${label}.y`, -4, size + 4),
};
}
if (value.kind === "cell") {
exactFields(value, new Set(["kind", "cell", "offsetX", "offsetY"]), label);
if (
!Number.isInteger(value.cell) ||
(value.cell as number) < 0 ||
(value.cell as number) >= size * size
) {
return fail(`${label}.cell is outside the grid.`);
}
return {
kind: "cell",
cell: value.cell as number,
...(value.offsetX === undefined
? {}
: { offsetX: finite(value.offsetX, `${label}.offsetX`, -4, 4) }),
...(value.offsetY === undefined
? {}
: { offsetY: finite(value.offsetY, `${label}.offsetY`, -4, 4) }),
};
}
return fail(`${label}.kind must be coordinate or cell.`);
}
function normalizeStyle(
value: unknown,
label: string,
): SafeVisualStyle | undefined {
if (value === undefined) return undefined;
if (!isRecord(value)) return fail(`${label} must be a visual style object.`);
exactFields(
value,
new Set(["stroke", "fill", "strokeWidth", "opacity", "fontSize"]),
label,
);
return {
...(value.stroke === undefined
? {}
: { stroke: normalizeSafeVisualColor(value.stroke, `${label}.stroke`) }),
...(value.fill === undefined
? {}
: { fill: normalizeSafeVisualColor(value.fill, `${label}.fill`) }),
...(value.strokeWidth === undefined
? {}
: {
strokeWidth: finite(value.strokeWidth, `${label}.strokeWidth`, 0, 4),
}),
...(value.opacity === undefined
? {}
: { opacity: finite(value.opacity, `${label}.opacity`, 0, 1) }),
...(value.fontSize === undefined
? {}
: { fontSize: finite(value.fontSize, `${label}.fontSize`, 0.05, 8) }),
};
}
function layer(value: unknown, label: string): "underlay" | "overlay" {
if (value !== "underlay" && value !== "overlay") {
return fail(`${label} must be underlay or overlay.`);
}
return value;
}
function primitive(
value: unknown,
size: number,
index: number,
): SafeVisualPrimitive {
const label = `visuals[${String(index)}]`;
if (!isRecord(value) || typeof value.type !== "string") {
return fail(`${label} must be a typed visual primitive.`);
}
const common = {
layer: layer(value.layer, `${label}.layer`),
...(value.style === undefined
? {}
: { style: normalizeStyle(value.style, `${label}.style`)! }),
} as const;
switch (value.type) {
case "line":
exactFields(
value,
new Set(["type", "layer", "style", "start", "end"]),
label,
);
return {
type: "line",
...common,
start: normalizeAnchor(value.start, size, `${label}.start`),
end: normalizeAnchor(value.end, size, `${label}.end`),
};
case "polyline": {
exactFields(
value,
new Set(["type", "layer", "style", "points", "closed"]),
label,
);
if (
!Array.isArray(value.points) ||
value.points.length < 2 ||
value.points.length > MAX_VISUAL_POINTS
) {
return fail(
`${label}.points must contain 2 to ${MAX_VISUAL_POINTS} anchors.`,
);
}
if (value.closed !== undefined && typeof value.closed !== "boolean") {
return fail(`${label}.closed must be true or false.`);
}
return {
type: "polyline",
...common,
points: value.points.map((point, pointIndex) =>
normalizeAnchor(
point,
size,
`${label}.points[${String(pointIndex)}]`,
),
),
...(value.closed === true ? { closed: true } : {}),
};
}
case "rectangle":
exactFields(
value,
new Set([
"type",
"layer",
"style",
"center",
"width",
"height",
"cornerRadius",
]),
label,
);
return {
type: "rectangle",
...common,
center: normalizeAnchor(value.center, size, `${label}.center`),
width: finite(value.width, `${label}.width`, 0.01, size + 8),
height: finite(value.height, `${label}.height`, 0.01, size + 8),
...(value.cornerRadius === undefined
? {}
: {
cornerRadius: finite(
value.cornerRadius,
`${label}.cornerRadius`,
0,
size + 8,
),
}),
};
case "ellipse":
exactFields(
value,
new Set(["type", "layer", "style", "center", "radiusX", "radiusY"]),
label,
);
return {
type: "ellipse",
...common,
center: normalizeAnchor(value.center, size, `${label}.center`),
radiusX: finite(value.radiusX, `${label}.radiusX`, 0.005, size + 4),
radiusY: finite(value.radiusY, `${label}.radiusY`, 0.005, size + 4),
};
case "circle":
exactFields(
value,
new Set(["type", "layer", "style", "center", "radius"]),
label,
);
return {
type: "circle",
...common,
center: normalizeAnchor(value.center, size, `${label}.center`),
radius: finite(value.radius, `${label}.radius`, 0.005, size + 4),
};
case "text":
exactFields(
value,
new Set(["type", "layer", "style", "position", "text"]),
label,
);
if (
typeof value.text !== "string" ||
value.text.length > MAX_VISUAL_TEXT_LENGTH
) {
return fail(
`${label}.text must be text of at most ${MAX_VISUAL_TEXT_LENGTH} characters.`,
);
}
return {
type: "text",
...common,
position: normalizeAnchor(value.position, size, `${label}.position`),
text: value.text,
};
default:
return fail(`${label}.type is not an allowlisted visual primitive.`);
}
}
export function normalizeSafeVisualPrimitives(
value: unknown,
size: number,
): SafeVisualPrimitive[] | undefined {
if (value === undefined) return undefined;
if (!Array.isArray(value) || value.length > MAX_VISUAL_PRIMITIVES) {
return fail(
`visuals must contain at most ${MAX_VISUAL_PRIMITIVES} primitives.`,
);
}
const result = value.map((entry, index) => primitive(entry, size, index));
const pointCount = result.reduce((count, entry) => {
if (entry.type === "polyline") return count + entry.points.length;
if (entry.type === "line") return count + 2;
return count + 1;
}, 0);
if (pointCount > MAX_VISUAL_POINTS) {
return fail(`visuals contain more than ${MAX_VISUAL_POINTS} anchors.`);
}
return result;
}
export function normalizeSourceIdentity(
value: unknown,
): SudokuSourceIdentity | undefined {
if (value === undefined) return undefined;
if (!isRecord(value)) return fail("source must be an identity object.");
exactFields(value, new Set(["format", "id", "version"]), "source");
if (
typeof value.format !== "string" ||
!SOURCE_FORMATS.has(value.format as SudokuSourceFormat)
) {
return fail("source.format is not supported.");
}
const text = (entry: unknown, label: string): string | undefined => {
if (entry === undefined) return undefined;
if (typeof entry !== "string" || entry.length > 512) {
return fail(`${label} must be bounded text.`);
}
return entry;
};
const id = text(value.id, "source.id");
const version = text(value.version, "source.version");
return {
format: value.format as SudokuSourceFormat,
...(id === undefined ? {} : { id }),
...(version === undefined ? {} : { version }),
};
}
function scalarMetadataValue(
value: unknown,
label: string,
): ScalarMetadataValue {
if (value === null || typeof value === "boolean") return value;
if (typeof value === "number" && Number.isFinite(value)) return value;
if (
typeof value === "string" &&
value.length <= MAX_SCALAR_METADATA_TEXT_LENGTH
)
return value;
return fail(`${label} must be a bounded JSON scalar.`);
}
export function normalizeScalarMetadata(
value: unknown,
): ScalarMetadata | undefined {
if (value === undefined) return undefined;
if (!isRecord(value)) return fail("metadata must be a scalar object.");
const entries = Object.entries(value);
if (entries.length > MAX_SCALAR_METADATA_FIELDS) {
return fail(
`metadata must contain at most ${MAX_SCALAR_METADATA_FIELDS} fields.`,
);
}
const output: Record<string, ScalarMetadataValue> = {};
for (const [key, raw] of entries) {
if (
key.length === 0 ||
key.length > MAX_SCALAR_METADATA_KEY_LENGTH ||
EXECUTABLE_KEY.test(key) ||
key === "__proto__" ||
key === "prototype" ||
key === "constructor"
) {
return fail(`metadata key “${key}” is not accepted.`);
}
output[key] = scalarMetadataValue(raw, `metadata.${key}`);
}
return output;
}
+4 -1
View File
@@ -72,5 +72,8 @@ export function decodePuzzleHash(hashOrUrl: string): SudokuDocument {
"The share payload could not be decompressed.",
);
}
return parseSudokuDocument(json);
const document = parseSudokuDocument(json);
return document.source === undefined
? { ...document, source: { format: "sudoku-tools" } }
: document;
}
+812 -25
View File
@@ -1,3 +1,4 @@
import { compressToBase64 } from "lz-string";
import {
LzStringOutputLimitError,
decompressFromBase64OrUriComponentBounded,
@@ -7,16 +8,38 @@ import {
MAX_DOCUMENT_BYTES,
MIN_BOARD_SIZE,
SudokuFormatError,
normalizeSudokuDocument,
} from "./document";
import { canonicalConstraintVisuals } from "./constraintVisuals";
import { UnsupportedPuzzleConstructsError } from "./interoperability";
import {
SafeVisualValidationError,
normalizeSafeVisualColor,
normalizeSafeVisualPrimitives,
normalizeScalarMetadata,
} from "./safeVisuals";
import {
SUDOKU_DOCUMENT_SCHEMA,
SUDOKU_DOCUMENT_VERSION,
type PortableConstraint,
type SafeVisualAnchor,
type SafeVisualPrimitive,
type SafeVisualStyle,
type SudokuDocument,
} from "./types";
export const MAX_SUDOKUPAD_PAYLOAD_LENGTH = 262_144;
const SUDOKU_TOOLS_CONSTRAINTS_METADATA = "sudokuToolsConstraints";
const RESERVED_SEMANTIC_METADATA = new Set([
"title",
"author",
"rules",
"solution",
"antiknight",
"antiking",
"nonconsecutive",
SUDOKU_TOOLS_CONSTRAINTS_METADATA,
]);
type JsonRecord = Record<string, unknown>;
@@ -38,6 +61,8 @@ const ROOT_FIELDS = new Set([
"author",
"rules",
"solution",
"duration",
"foglight",
]);
const CELL_FIELDS = new Set(["value", "given", "pencilMarks", "centremarks"]);
@@ -58,6 +83,21 @@ function present(value: unknown): boolean {
return true;
}
function objects(value: unknown, maximum = 5_000): JsonRecord[] {
if (value === undefined) return [];
if (
!Array.isArray(value) ||
value.length > maximum ||
!value.every(isRecord)
) {
return fail(
"LIMIT_EXCEEDED",
"A SudokuPad collection is invalid or too large.",
);
}
return value;
}
function boundedText(value: unknown, label: string, maximum = 16_384) {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value !== "string" || value.length > maximum) {
@@ -90,6 +130,378 @@ function integer(
return parsed as number;
}
function finiteNumber(
value: unknown,
label: string,
minimum: number,
maximum: number,
fallback?: number,
): number {
if (value === undefined && fallback !== undefined) return fallback;
const parsed =
typeof value === "string" && value.trim() !== "" ? Number(value) : value;
if (
typeof parsed !== "number" ||
!Number.isFinite(parsed) ||
parsed < minimum ||
parsed > maximum
) {
return fail(
"INVALID_SUDOKUPAD",
`${label} must be a finite number from ${minimum} to ${maximum}.`,
);
}
return parsed;
}
function visualColor(value: unknown, label: string, fallback: string): string {
try {
return normalizeSafeVisualColor(value ?? fallback, label);
} catch (error) {
if (error instanceof SafeVisualValidationError) {
return fail("UNSAFE_VISUAL_STYLE", error.message);
}
throw error;
}
}
function visualPoint(
value: unknown,
size: number,
label: string,
): Extract<SafeVisualAnchor, { readonly kind: "coordinate" }> {
if (!Array.isArray(value) || value.length !== 2) {
return fail("INVALID_SUDOKUPAD", `${label} must be a [row, column] point.`);
}
return {
kind: "coordinate",
y: finiteNumber(value[0], `${label}[0]`, -4, size + 4),
x: finiteNumber(value[1], `${label}[1]`, -4, size + 4),
};
}
function visualLayer(
value: unknown,
fallback: "underlay" | "overlay",
label: string,
): "underlay" | "overlay" {
if (value === undefined || value === "arrows" || value === "cell-grids")
return fallback;
if (value === "underlay" || value === "overlay") return value;
return fail(
"UNSAFE_VISUAL_CONSTRUCT",
`${label} targets an unsupported layer.`,
);
}
function assertVisualFields(
value: JsonRecord,
allowed: ReadonlySet<string>,
label: string,
): void {
const unknown = Object.keys(value).find((field) => !allowed.has(field));
if (unknown !== undefined) {
return fail(
"UNSAFE_VISUAL_CONSTRUCT",
`${label}.${unknown} is not an allowlisted visual field. Raw SVG paths, styles and custom code are never imported.`,
);
}
}
function visualWaypoints(
value: unknown,
size: number,
label: string,
): Array<Extract<SafeVisualAnchor, { readonly kind: "coordinate" }>> {
if (
!Array.isArray(value) ||
value.length < 2 ||
value.length > size * size * 4
) {
return fail(
"INVALID_SUDOKUPAD",
`${label} must contain bounded waypoints.`,
);
}
return value.map((point, index) =>
visualPoint(point, size, `${label}[${String(index)}]`),
);
}
function parseSudokuPadVisuals(
root: JsonRecord,
size: number,
cellSize: number,
): SafeVisualPrimitive[] {
const output: SafeVisualPrimitive[] = [];
const lineFields = new Set([
"target",
"color",
"thickness",
"wayPoints",
"opacity",
]);
for (const [index, raw] of objects(root.lines).entries()) {
const label = `lines[${String(index)}]`;
assertVisualFields(raw, lineFields, label);
output.push({
type: "polyline",
layer: visualLayer(raw.target, "overlay", `${label}.target`),
points: visualWaypoints(raw.wayPoints, size, `${label}.wayPoints`),
style: {
stroke: visualColor(raw.color, `${label}.color`, "#000000"),
fill: "transparent",
strokeWidth:
finiteNumber(
raw.thickness,
`${label}.thickness`,
0,
cellSize * 4,
2,
) / cellSize,
opacity: finiteNumber(raw.opacity, `${label}.opacity`, 0, 1, 1),
},
});
}
const shapeFields = new Set([
"target",
"center",
"width",
"height",
"rounded",
"roundedRadius",
"borderColor",
"backgroundColor",
"borderSize",
"thickness",
"opacity",
"text",
"textColor",
"color",
"fontSize",
"textStroke",
"textAnchor",
"maxWidth",
"angle",
]);
for (const [rootField, fallbackLayer] of [
["underlays", "underlay"],
["overlays", "overlay"],
] as const) {
for (const [index, raw] of objects(root[rootField]).entries()) {
const label = `${rootField}[${String(index)}]`;
assertVisualFields(raw, shapeFields, label);
if (raw.angle !== undefined && Number(raw.angle) !== 0) {
return fail(
"UNSAFE_VISUAL_CONSTRUCT",
`${label}.angle cannot be represented by the safe visual model.`,
);
}
if (raw.textAnchor !== undefined && raw.textAnchor !== "middle") {
return fail(
"UNSAFE_VISUAL_CONSTRUCT",
`${label}.textAnchor cannot be represented without changing layout.`,
);
}
const center = visualPoint(raw.center, size, `${label}.center`);
const width = finiteNumber(
raw.width,
`${label}.width`,
0.01,
size + 8,
1,
);
const height = finiteNumber(
raw.height,
`${label}.height`,
0.01,
size + 8,
1,
);
const layer = visualLayer(raw.target, fallbackLayer, `${label}.target`);
const style: SafeVisualStyle = {
stroke: visualColor(
raw.borderColor,
`${label}.borderColor`,
"transparent",
),
fill: visualColor(
raw.backgroundColor,
`${label}.backgroundColor`,
"transparent",
),
strokeWidth:
finiteNumber(
raw.borderSize ?? raw.thickness,
`${label}.borderSize`,
0,
cellSize * 4,
0,
) / cellSize,
opacity: finiteNumber(raw.opacity, `${label}.opacity`, 0, 1, 1),
};
if (raw.rounded === true && width === height) {
output.push({
type: "circle",
layer,
center,
radius: width / 2,
style,
});
} else if (raw.rounded === true) {
output.push({
type: "ellipse",
layer,
center,
radiusX: width / 2,
radiusY: height / 2,
style,
});
} else {
output.push({
type: "rectangle",
layer,
center,
width,
height,
...(raw.roundedRadius === undefined
? {}
: {
cornerRadius:
finiteNumber(
raw.roundedRadius,
`${label}.roundedRadius`,
0,
cellSize * (size + 8),
) / cellSize,
}),
style,
});
}
if (raw.text !== undefined) {
output.push({
type: "text",
layer,
position: center,
text: String(raw.text),
style: {
fill: visualColor(
raw.textColor ?? raw.color,
`${label}.textColor`,
"#000000",
),
...(raw.textStroke === undefined
? {}
: {
stroke: visualColor(
raw.textStroke,
`${label}.textStroke`,
"transparent",
),
strokeWidth: 0.01,
}),
fontSize:
finiteNumber(
raw.fontSize,
`${label}.fontSize`,
1,
cellSize * 8,
cellSize * 0.5,
) / cellSize,
opacity: style.opacity,
},
});
}
}
}
const arrowFields = new Set([
"target",
"color",
"opacity",
"thickness",
"headLength",
"headStyle",
"headAngle",
"headIndent",
"wayPoints",
]);
for (const [index, raw] of objects(root.arrows).entries()) {
const label = `arrows[${String(index)}]`;
assertVisualFields(raw, arrowFields, label);
if (
raw.headStyle !== undefined &&
raw.headStyle !== "stroke" &&
raw.headStyle !== "fill"
) {
return fail(
"UNSAFE_VISUAL_CONSTRUCT",
`${label}.headStyle is unsupported.`,
);
}
const points = visualWaypoints(raw.wayPoints, size, `${label}.wayPoints`);
const visualStyle: SafeVisualStyle = {
stroke: visualColor(raw.color, `${label}.color`, "#000000"),
fill: "transparent",
strokeWidth:
finiteNumber(raw.thickness, `${label}.thickness`, 0, cellSize * 4, 2) /
cellSize,
opacity: finiteNumber(raw.opacity, `${label}.opacity`, 0, 1, 1),
};
const layer = visualLayer(raw.target, "overlay", `${label}.target`);
output.push({ type: "polyline", layer, points, style: visualStyle });
const before = points.at(-2)!;
const tip = points.at(-1)!;
const dx = tip.x - before.x;
const dy = tip.y - before.y;
const distance = Math.hypot(dx, dy);
if (distance === 0) {
return fail(
"INVALID_SUDOKUPAD",
`${label} has a zero-length final segment.`,
);
}
const length = finiteNumber(
raw.headLength,
`${label}.headLength`,
0.02,
4,
0.3,
);
const angle =
(finiteNumber(raw.headAngle, `${label}.headAngle`, 10, 170, 90) / 2) *
(Math.PI / 180);
const ux = -dx / distance;
const uy = -dy / distance;
const wing = (sign: -1 | 1): SafeVisualAnchor => ({
kind: "coordinate",
x: tip.x + (ux * Math.cos(angle) - sign * uy * Math.sin(angle)) * length,
y: tip.y + (uy * Math.cos(angle) + sign * ux * Math.sin(angle)) * length,
});
output.push({
type: "polyline",
layer,
points: [wing(-1), tip, wing(1)],
...(raw.headStyle === "fill" ? { closed: true } : {}),
style: {
...visualStyle,
...(raw.headStyle === "fill"
? { fill: visualStyle.stroke, strokeWidth: 0 }
: {}),
},
});
}
try {
return normalizeSafeVisualPrimitives(output, size) ?? [];
} catch (error) {
if (error instanceof SafeVisualValidationError) {
return fail("INVALID_SUDOKUPAD_VISUAL", error.message);
}
throw error;
}
}
function coordinate(value: unknown, size: number, label: string): number {
if (
!Array.isArray(value) ||
@@ -272,6 +684,44 @@ function parseCages(
return constraints;
}
function embeddedConstraints(
value: unknown,
size: number,
givens: readonly number[],
solution: readonly number[] | undefined,
): PortableConstraint[] | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string" || value.length > 131_072) {
return fail(
"INVALID_SUDOKUPAD",
`${SUDOKU_TOOLS_CONSTRAINTS_METADATA} must be bounded JSON text.`,
);
}
let parsed: unknown;
try {
parsed = JSON.parse(value) as unknown;
} catch {
return fail(
"INVALID_SUDOKUPAD",
`${SUDOKU_TOOLS_CONSTRAINTS_METADATA} is not valid JSON.`,
);
}
if (!Array.isArray(parsed)) {
return fail(
"INVALID_SUDOKUPAD",
`${SUDOKU_TOOLS_CONSTRAINTS_METADATA} must contain a constraint array.`,
);
}
return normalizeSudokuDocument({
schema: SUDOKU_DOCUMENT_SCHEMA,
version: SUDOKU_DOCUMENT_VERSION,
size,
givens,
constraints: parsed,
...(solution === undefined ? {} : { solution }),
}).constraints as PortableConstraint[];
}
/** Parse raw, already-decoded SudokuPad/CTC (often called SCL) JSON. */
export function parseSudokuPadPuzzle(value: unknown): SudokuDocument {
if (!isRecord(value)) {
@@ -282,9 +732,6 @@ export function parseSudokuPadPuzzle(value: unknown): SudokuDocument {
if (!ROOT_FIELDS.has(field))
unsupported.push(`unknown root field “${field}`);
}
for (const field of ["lines", "underlays", "overlays", "arrows"] as const) {
if (present(value[field])) unsupported.push(`visual ${field}`);
}
if (present(value.global)) unsupported.push("custom global rules");
if (isRecord(value.settings)) {
for (const key of Object.keys(value.settings)) {
@@ -306,6 +753,13 @@ export function parseSudokuPadPuzzle(value: unknown): SudokuDocument {
MIN_BOARD_SIZE,
MAX_BOARD_SIZE,
);
const cellSize = finiteNumber(
value.cellSize,
"SudokuPad cellSize",
16,
256,
50,
);
if (!value.cells.every((row) => Array.isArray(row) && row.length === size)) {
return fail(
"INVALID_SUDOKUPAD",
@@ -364,15 +818,47 @@ export function parseSudokuPadPuzzle(value: unknown): SudokuDocument {
}
const metadata = metadataFrom(value);
const constraints = parseCages(value.cages, size, metadata);
if (metadata.antiknight === true || metadata.antiknight === "true") {
constraints.push({ type: "anti-knight" });
}
if (metadata.antiking === true || metadata.antiking === "true") {
constraints.push({ type: "anti-king" });
}
if (metadata.nonconsecutive === true || metadata.nonconsecutive === "true") {
constraints.push({ type: "non-consecutive" });
const title = metaText(value, metadata, "title");
const author = metaText(value, metadata, "author");
const rules = metaText(value, metadata, "rules");
const solution = solutionDigits(metaText(value, metadata, "solution"), size);
const cageConstraints = parseCages(value.cages, size, metadata);
const transported = embeddedConstraints(
metadata[SUDOKU_TOOLS_CONSTRAINTS_METADATA],
size,
givens,
solution,
);
const constraints = transported ?? cageConstraints;
if (transported === undefined) {
if (metadata.antiknight === true || metadata.antiknight === "true") {
constraints.push({ type: "anti-knight" });
}
if (metadata.antiking === true || metadata.antiking === "true") {
constraints.push({ type: "anti-king" });
}
if (
metadata.nonconsecutive === true ||
metadata.nonconsecutive === "true"
) {
constraints.push({ type: "non-consecutive" });
}
if (value.foglight !== undefined) {
if (!Array.isArray(value.foglight) || value.foglight.length === 0) {
return fail("INVALID_SUDOKUPAD", "foglight must be a non-empty array.");
}
constraints.push({
type: "fog",
lights: [
...new Set(
value.foglight.map((entry, index) =>
coordinate(entry, size, `foglight[${String(index)}]`),
),
),
],
revealRadius: 1,
});
}
}
const knownMetadata = new Set([
"title",
@@ -382,23 +868,41 @@ export function parseSudokuPadPuzzle(value: unknown): SudokuDocument {
"antiknight",
"antiking",
"nonconsecutive",
SUDOKU_TOOLS_CONSTRAINTS_METADATA,
]);
const unknownMetadata = Object.keys(metadata).filter(
(key) => !knownMetadata.has(key),
);
if (unknownMetadata.length > 0) {
throw new UnsupportedPuzzleConstructsError(
"SudokuPad/CTC",
unknownMetadata.map((key) => `metadata “${key}`),
);
const rawScalarMetadata: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(metadata)) {
if (!knownMetadata.has(key)) rawScalarMetadata[key] = raw;
}
const title = metaText(value, metadata, "title");
const author = metaText(value, metadata, "author");
const rules = metaText(value, metadata, "rules");
const solution = solutionDigits(metaText(value, metadata, "solution"), size);
if (
isRecord(value.settings) &&
value.settings.conflictchecker !== undefined
) {
rawScalarMetadata["setting.conflictchecker"] =
value.settings.conflictchecker;
}
rawScalarMetadata.cellSize = cellSize;
let scalarMetadata;
try {
scalarMetadata = normalizeScalarMetadata(rawScalarMetadata);
} catch (error) {
if (error instanceof SafeVisualValidationError) {
return fail("UNSAFE_SUDOKUPAD_METADATA", error.message);
}
throw error;
}
const visuals = parseSudokuPadVisuals(value, size, cellSize);
const regions = parseRegions(value.regions, size);
const id = boundedText(value.id, "SudokuPad id", 256);
const elapsedMs =
value.duration === undefined
? undefined
: finiteNumber(
value.duration,
"SudokuPad duration",
0,
1_000_000_000_000,
);
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: SUDOKU_DOCUMENT_VERSION,
@@ -414,6 +918,13 @@ export function parseSudokuPadPuzzle(value: unknown): SudokuDocument {
...(rules === undefined ? {} : { rules: [rules.slice(0, 16_384)] }),
...(solution === undefined ? {} : { solution }),
...(id === undefined ? {} : { id }),
...(elapsedMs === undefined ? {} : { elapsedMs }),
...(visuals.length === 0 ? {} : { visuals }),
source: {
format: "sudokupad",
...(id === undefined ? {} : { id }),
},
...(scalarMetadata === undefined ? {} : { metadata: scalarMetadata }),
};
}
@@ -487,3 +998,279 @@ export function importSudokuPad(input: string): SudokuDocument {
}
return parseSudokuPadPuzzle(boundedJson(decodePayload(payload)));
}
function sclPoint(
anchor: SafeVisualAnchor,
size: number,
): readonly [number, number] {
if (anchor.kind === "coordinate") return [anchor.y, anchor.x];
return [
Math.floor(anchor.cell / size) + 0.5 + (anchor.offsetY ?? 0),
(anchor.cell % size) + 0.5 + (anchor.offsetX ?? 0),
];
}
interface SclVisualCollections {
readonly lines: JsonRecord[];
readonly underlays: JsonRecord[];
readonly overlays: JsonRecord[];
}
function appendSclVisual(
output: SclVisualCollections,
visual: SafeVisualPrimitive,
size: number,
cellSize: number,
): void {
const style = visual.style ?? {};
const target = visual.layer;
if (visual.type === "line" || visual.type === "polyline") {
const points =
visual.type === "line"
? [visual.start, visual.end]
: visual.closed
? [...visual.points, visual.points[0]!]
: visual.points;
output.lines.push({
target,
color: style.stroke ?? "#000000",
thickness: (style.strokeWidth ?? 0.03) * cellSize,
wayPoints: points.map((point) => sclPoint(point, size)),
...(style.opacity === undefined ? {} : { opacity: style.opacity }),
});
return;
}
const collection =
visual.layer === "underlay" ? output.underlays : output.overlays;
if (visual.type === "text") {
collection.push({
center: sclPoint(visual.position, size),
width: 0.25,
height: 0.25,
rounded: false,
backgroundColor: "transparent",
borderColor: "transparent",
color: style.fill ?? "#000000",
fontSize: (style.fontSize ?? 0.5) * cellSize,
text: visual.text,
...(style.opacity === undefined ? {} : { opacity: style.opacity }),
});
return;
}
const center = sclPoint(visual.center, size);
const base = {
center,
backgroundColor: style.fill ?? "transparent",
borderColor: style.stroke ?? "transparent",
borderSize: (style.strokeWidth ?? 0) * cellSize,
...(style.opacity === undefined ? {} : { opacity: style.opacity }),
};
if (visual.type === "rectangle") {
collection.push({
...base,
width: visual.width,
height: visual.height,
rounded: false,
...(visual.cornerRadius === undefined
? {}
: { roundedRadius: visual.cornerRadius * cellSize }),
});
} else if (visual.type === "circle") {
collection.push({
...base,
width: visual.radius * 2,
height: visual.radius * 2,
rounded: true,
});
} else {
collection.push({
...base,
width: visual.radiusX * 2,
height: visual.radiusY * 2,
rounded: true,
});
}
}
/** Build uncompressed, standards-shaped SudokuPad/CTC SCL JSON data. */
export function exportSudokuPadPuzzle(document: SudokuDocument): JsonRecord {
const normalized = normalizeSudokuDocument(document);
if ((normalized.globalRules?.length ?? 0) > 0) {
return fail(
"UNSUPPORTED_SUDOKUPAD",
"SudokuPad export cannot preserve free-form global rules safely.",
);
}
const negated = normalized.constraints.find(
(constraint) => "negated" in constraint && constraint.negated === true,
);
if (negated !== undefined) {
return fail(
"UNSUPPORTED_SUDOKUPAD",
"SudokuPad export cannot enforce individually false clues. Use Sudoku Tools JSON instead.",
);
}
const { size } = normalized;
const sourceId =
normalized.source?.format === "sudokupad"
? normalized.source.id
: undefined;
const outputId = sourceId ?? normalized.id;
const cellSize =
typeof normalized.metadata?.cellSize === "number" &&
normalized.metadata.cellSize >= 16 &&
normalized.metadata.cellSize <= 256
? normalized.metadata.cellSize
: 50;
const retainedMetadata = Object.fromEntries(
Object.entries(normalized.metadata ?? {}).filter(
([key]) => !RESERVED_SEMANTIC_METADATA.has(key),
),
);
const metadata: JsonRecord = {
...retainedMetadata,
...(normalized.title === undefined ? {} : { title: normalized.title }),
...(normalized.author === undefined ? {} : { author: normalized.author }),
...(normalized.rules === undefined
? {}
: { rules: normalized.rules.join("\n") }),
...(normalized.solution === undefined
? {}
: { solution: normalized.solution.join(",") }),
[SUDOKU_TOOLS_CONSTRAINTS_METADATA]: JSON.stringify(normalized.constraints),
};
delete metadata.cellSize;
delete metadata["setting.conflictchecker"];
if (normalized.constraints.some(({ type }) => type === "anti-knight"))
metadata.antiknight = true;
if (normalized.constraints.some(({ type }) => type === "anti-king"))
metadata.antiking = true;
if (normalized.constraints.some(({ type }) => type === "non-consecutive"))
metadata.nonconsecutive = true;
const visuals: SclVisualCollections = {
lines: [],
underlays: [],
overlays: [],
};
const renderedVisuals = [
...canonicalConstraintVisuals(normalized),
...(normalized.visuals ?? []),
];
for (const visual of renderedVisuals) {
appendSclVisual(visuals, visual, size, cellSize);
}
const cages = normalized.constraints
.filter(
(
constraint,
): constraint is Extract<
PortableConstraint,
{ readonly type: "killer-cage" }
> => constraint.type === "killer-cage",
)
.map((constraint) => ({
cells: constraint.cells.map((cell) => [
Math.floor(cell / size),
cell % size,
]),
value: String(constraint.sum),
sum: constraint.sum,
unique: constraint.noRepeat !== false,
}));
const fogLights = normalized.constraints
.filter(
(
constraint,
): constraint is Extract<PortableConstraint, { readonly type: "fog" }> =>
constraint.type === "fog",
)
.flatMap((constraint) => constraint.lights)
.map((cell) => [Math.floor(cell / size), cell % size]);
const regions =
normalized.regions === undefined
? undefined
: Array.from({ length: size }, (_, region) =>
normalized
.regions!.map((candidate, cell) =>
candidate === region
? ([Math.floor(cell / size), cell % size] as const)
: undefined,
)
.filter(
(entry): entry is readonly [number, number] =>
entry !== undefined,
),
);
const values = normalized.values ?? normalized.givens;
const output: JsonRecord = {
...(outputId === undefined ? {} : { id: outputId }),
cellSize,
cells: Array.from({ length: size }, (_, row) =>
Array.from({ length: size }, (_unused, column) => {
const index = row * size + column;
const given = normalized.givens[index] ?? 0;
const value = given || values[index] || 0;
return {
...(value === 0 ? {} : { value }),
...(value === 0 ? {} : { given: given !== 0 }),
...(normalized.cornerMarks?.[index]?.length
? { pencilMarks: [...normalized.cornerMarks[index]!] }
: {}),
...((normalized.centerMarks ?? normalized.candidates)?.[index]?.length
? {
centremarks: [
...(normalized.centerMarks ?? normalized.candidates)![index]!,
],
}
: {}),
};
}),
),
metadata,
settings: {
conflictchecker: normalized.metadata?.["setting.conflictchecker"] ?? true,
},
...(normalized.elapsedMs === undefined
? {}
: { duration: normalized.elapsedMs }),
...(regions === undefined ? {} : { regions }),
...(cages.length === 0 ? {} : { cages }),
...(visuals.lines.length === 0 ? {} : { lines: visuals.lines }),
...(visuals.underlays.length === 0 ? {} : { underlays: visuals.underlays }),
...(visuals.overlays.length === 0 ? {} : { overlays: visuals.overlays }),
...(fogLights.length === 0 ? {} : { foglight: fogLights }),
};
return output;
}
export function exportSudokuPadJson(
document: SudokuDocument,
pretty = false,
): string {
const json = JSON.stringify(
exportSudokuPadPuzzle(document),
null,
pretty ? 2 : undefined,
);
if (new TextEncoder().encode(json).byteLength > MAX_DOCUMENT_BYTES) {
return fail("LIMIT_EXCEEDED", "The exported SudokuPad JSON is too large.");
}
return json;
}
/** Self-contained `scl…` payload accepted by SudokuPad without a request. */
export function exportSudokuPadPayload(document: SudokuDocument): string {
const compressed = compressToBase64(exportSudokuPadJson(document));
const payload = `scl${encodeURIComponent(compressed)}`;
if (payload.length > MAX_SUDOKUPAD_PAYLOAD_LENGTH) {
return fail(
"LIMIT_EXCEEDED",
"The compressed SudokuPad payload is too large.",
);
}
return payload;
}
+261 -13
View File
@@ -11,6 +11,89 @@ export const SUDOKU_DOCUMENT_VERSION = 1 as const;
export type CellIndex = number;
export type OutsideSide = "top" | "right" | "bottom" | "left";
export type SudokuSourceFormat =
"sudoku-tools" | "plain-grid" | "fpuzzles" | "sudokupad" | "penpa";
/** Provenance only; it is never dereferenced or fetched by Sudoku Tools. */
export interface SudokuSourceIdentity {
readonly format: SudokuSourceFormat;
readonly id?: string;
readonly version?: string;
}
export type ScalarMetadataValue = string | number | boolean | null;
export type ScalarMetadata = Readonly<Record<string, ScalarMetadataValue>>;
/**
* Grid-relative anchor used by inert visual primitives. Coordinates have their
* origin at the grid's top-left corner; one unit equals one cell.
*/
export type SafeVisualAnchor =
| {
readonly kind: "coordinate";
readonly x: number;
readonly y: number;
}
| {
readonly kind: "cell";
readonly cell: CellIndex;
readonly offsetX?: number;
readonly offsetY?: number;
};
/** Only these inert presentation properties can cross an import boundary. */
export interface SafeVisualStyle {
/** A normalized hexadecimal colour or `transparent`. */
readonly stroke?: string;
/** A normalized hexadecimal colour or `transparent`. */
readonly fill?: string;
/** Grid-relative width, not a CSS value. */
readonly strokeWidth?: number;
readonly opacity?: number;
/** Grid-relative size, not a CSS value. */
readonly fontSize?: number;
}
interface SafeVisualBase {
readonly layer: "underlay" | "overlay";
readonly style?: SafeVisualStyle;
}
export type SafeVisualPrimitive =
| (SafeVisualBase & {
readonly type: "line";
readonly start: SafeVisualAnchor;
readonly end: SafeVisualAnchor;
})
| (SafeVisualBase & {
readonly type: "polyline";
readonly points: readonly SafeVisualAnchor[];
readonly closed?: boolean;
})
| (SafeVisualBase & {
readonly type: "rectangle";
readonly center: SafeVisualAnchor;
readonly width: number;
readonly height: number;
readonly cornerRadius?: number;
})
| (SafeVisualBase & {
readonly type: "ellipse";
readonly center: SafeVisualAnchor;
readonly radiusX: number;
readonly radiusY: number;
})
| (SafeVisualBase & {
readonly type: "circle";
readonly center: SafeVisualAnchor;
readonly radius: number;
})
| (SafeVisualBase & {
readonly type: "text";
readonly position: SafeVisualAnchor;
readonly text: string;
});
export interface CluePolarity {
/** When true, the completed clue must be false rather than true. */
readonly negated?: boolean;
@@ -21,6 +104,7 @@ export type PortableConstraint =
| { readonly type: "anti-knight" }
| { readonly type: "anti-king" }
| { readonly type: "non-consecutive" }
| { readonly type: "disjoint-groups" }
| ({
readonly type: "killer-cage";
readonly cells: readonly CellIndex[];
@@ -71,6 +155,59 @@ export type PortableConstraint =
readonly digits: readonly number[];
} & CluePolarity)
| ({ readonly type: "maximum"; readonly cell: CellIndex } & CluePolarity)
| ({ readonly type: "minimum"; readonly cell: CellIndex } & CluePolarity)
| ({ readonly type: "odd"; readonly cell: CellIndex } & CluePolarity)
| ({ readonly type: "even"; readonly cell: CellIndex } & CluePolarity)
| ({
readonly type: "little-killer";
readonly side: OutsideSide;
readonly index: number;
readonly direction: "down-right" | "down-left" | "up-right" | "up-left";
readonly sum: number;
} & CluePolarity)
| ({
readonly type: "sandwich";
readonly side: OutsideSide;
readonly index: number;
readonly sum: number;
} & CluePolarity)
| ({
readonly type: "between-line";
readonly cells: readonly CellIndex[];
} & CluePolarity)
| ({
readonly type: "german-whisper";
readonly cells: readonly CellIndex[];
readonly minimumDifference?: number;
} & CluePolarity)
| ({
readonly type: "region-sum-line";
readonly cells: readonly CellIndex[];
} & CluePolarity)
| ({
readonly type: "clone";
readonly cells: readonly CellIndex[];
readonly cloneCells: readonly CellIndex[];
} & CluePolarity)
| {
readonly type: "extra-region";
readonly cells: readonly CellIndex[];
}
| ({
readonly type:
"modular-line" | "entropic-line" | "zipper-line" | "double-arrow";
readonly cells: readonly CellIndex[];
} & CluePolarity)
| ({
readonly type: "indexer";
readonly kind: "row" | "column" | "box";
readonly cell: CellIndex;
} & CluePolarity)
| {
readonly type: "fog";
readonly lights: readonly CellIndex[];
readonly revealRadius?: 0 | 1;
}
| ({
readonly type: "renban";
readonly cells: readonly CellIndex[];
@@ -107,6 +244,12 @@ export interface SudokuDocument {
/** Named boolean/global rules which cannot be represented by a local shape. */
readonly globalRules?: readonly string[];
readonly id?: string;
/** Inert, bounded drawings retained without accepting source SVG or HTML. */
readonly visuals?: readonly SafeVisualPrimitive[];
/** Original local interchange identity; never used as a network location. */
readonly source?: SudokuSourceIdentity;
/** Uninterpreted bounded scalar source metadata. */
readonly metadata?: ScalarMetadata;
}
/** Minimal structural type accepted by the domain adapter. */
@@ -149,24 +292,113 @@ export function toDomainPuzzle(document: SudokuDocument): DomainPuzzleShape {
};
}
export function fromDomainPuzzle(puzzle: DomainPuzzleShape): SudokuDocument {
export type PreservedSudokuDocumentExtras = Pick<
SudokuDocument,
"visuals" | "source" | "metadata"
>;
/**
* Copy the source-only portion of a document before adapting it to the domain
* puzzle model. An undefined result lets callers avoid carrying empty state.
*/
export function extractPreservedDocumentExtras(
document: SudokuDocument,
): PreservedSudokuDocumentExtras | undefined {
if (
document.visuals === undefined &&
document.source === undefined &&
document.metadata === undefined
) {
return undefined;
}
return {
schema: SUDOKU_DOCUMENT_SCHEMA,
version: SUDOKU_DOCUMENT_VERSION,
size: puzzle.size,
givens: [...puzzle.givens],
constraints: (puzzle.constraints ?? []).map(cloneConstraint),
...(puzzle.regions === undefined ? {} : { regions: [...puzzle.regions] }),
...(puzzle.title === undefined ? {} : { title: puzzle.title }),
...(puzzle.author === undefined ? {} : { author: puzzle.author }),
...(puzzle.id === undefined ? {} : { id: puzzle.id }),
...(puzzle.rules === undefined ? {} : { rules: [puzzle.rules] }),
...(puzzle.solution === undefined
...(document.visuals === undefined
? {}
: { solution: [...puzzle.solution] }),
: { visuals: document.visuals.map(cloneVisualPrimitive) }),
...(document.source === undefined
? {}
: { source: { ...document.source } }),
...(document.metadata === undefined
? {}
: { metadata: { ...document.metadata } }),
};
}
export function cloneVisualPrimitive(
primitive: SafeVisualPrimitive,
): SafeVisualPrimitive {
const style =
primitive.style === undefined ? {} : { style: { ...primitive.style } };
switch (primitive.type) {
case "line":
return {
...primitive,
start: { ...primitive.start },
end: { ...primitive.end },
...style,
};
case "polyline":
return {
...primitive,
points: primitive.points.map((point) => ({ ...point })),
...style,
};
case "rectangle":
case "ellipse":
case "circle":
return { ...primitive, center: { ...primitive.center }, ...style };
case "text":
return { ...primitive, position: { ...primitive.position }, ...style };
}
}
/**
* Merge data which the domain model intentionally cannot carry. Callers keep
* ownership of the returned arrays and records.
*/
export function mergePreservedDocumentExtras(
document: SudokuDocument,
preserved?: Partial<PreservedSudokuDocumentExtras>,
): SudokuDocument {
if (preserved === undefined) return document;
return {
...document,
...(preserved.visuals === undefined
? {}
: { visuals: preserved.visuals.map(cloneVisualPrimitive) }),
...(preserved.source === undefined
? {}
: { source: { ...preserved.source } }),
...(preserved.metadata === undefined
? {}
: { metadata: { ...preserved.metadata } }),
};
}
export function fromDomainPuzzle(
puzzle: DomainPuzzleShape,
preserved?: Partial<PreservedSudokuDocumentExtras>,
): SudokuDocument {
return mergePreservedDocumentExtras(
{
schema: SUDOKU_DOCUMENT_SCHEMA,
version: SUDOKU_DOCUMENT_VERSION,
size: puzzle.size,
givens: [...puzzle.givens],
constraints: (puzzle.constraints ?? []).map(cloneConstraint),
...(puzzle.regions === undefined ? {} : { regions: [...puzzle.regions] }),
...(puzzle.title === undefined ? {} : { title: puzzle.title }),
...(puzzle.author === undefined ? {} : { author: puzzle.author }),
...(puzzle.id === undefined ? {} : { id: puzzle.id }),
...(puzzle.rules === undefined ? {} : { rules: [puzzle.rules] }),
...(puzzle.solution === undefined
? {}
: { solution: [...puzzle.solution] }),
},
preserved,
);
}
export function cloneConstraint(
constraint: PortableConstraint,
): PortableConstraint {
@@ -176,7 +408,23 @@ export function cloneConstraint(
case "thermo":
case "renban":
case "palindrome":
case "between-line":
case "german-whisper":
case "region-sum-line":
case "extra-region":
case "modular-line":
case "entropic-line":
case "zipper-line":
case "double-arrow":
return { ...constraint, cells: [...constraint.cells] };
case "fog":
return { ...constraint, lights: [...constraint.lights] };
case "clone":
return {
...constraint,
cells: [...constraint.cells],
cloneCells: [...constraint.cloneCells],
};
case "quadruple":
return {
...constraint,
+206 -9
View File
@@ -1,3 +1,4 @@
import { littleKillerCells } from "../domain/geometry";
import { normalizePuzzle } from "../domain/validation";
import type {
NormalizedPuzzle,
@@ -6,7 +7,12 @@ import type {
} from "../domain/types";
import { symbolFor } from "../state/session";
import { normalizeSudokuDocument, SudokuFormatError } from "./document";
import { toDomainPuzzle, type SudokuDocument } from "./types";
import {
toDomainPuzzle,
type SafeVisualAnchor,
type SafeVisualPrimitive,
type SudokuDocument,
} from "./types";
export interface VisualExportOptions {
readonly includeProgress?: boolean;
@@ -76,6 +82,73 @@ function linePoints(
.join(" ");
}
function safeVisualPoint(
size: number,
anchor: SafeVisualAnchor,
origin: BoardOrigin,
) {
if (anchor.kind === "coordinate") {
return {
x: origin.x + anchor.x * CELL_SIZE,
y: origin.y + anchor.y * CELL_SIZE,
};
}
const center = point(size, anchor.cell, origin);
return {
x: center.x + (anchor.offsetX ?? 0) * CELL_SIZE,
y: center.y + (anchor.offsetY ?? 0) * CELL_SIZE,
};
}
function renderSafeVisual(
visual: SafeVisualPrimitive,
size: number,
origin: BoardOrigin,
index: number,
): string {
const style = visual.style ?? {};
const attributes = [
`class="source-visual source-visual--${visual.type}"`,
`data-visual="${String(index)}"`,
`stroke="${escapeXml(style.stroke ?? "transparent")}"`,
`fill="${escapeXml(style.fill ?? "transparent")}"`,
...(style.strokeWidth === undefined
? []
: [`stroke-width="${number(style.strokeWidth * CELL_SIZE)}"`]),
...(style.opacity === undefined
? []
: [`opacity="${number(style.opacity)}"`]),
].join(" ");
if (visual.type === "line") {
const start = safeVisualPoint(size, visual.start, origin);
const end = safeVisualPoint(size, visual.end, origin);
return `<line ${attributes} x1="${number(start.x)}" y1="${number(start.y)}" x2="${number(end.x)}" y2="${number(end.y)}"/>`;
}
if (visual.type === "polyline") {
const points = visual.points
.map((anchor) => safeVisualPoint(size, anchor, origin))
.map(({ x, y }) => `${number(x)},${number(y)}`)
.join(" ");
return `<${visual.closed === true ? "polygon" : "polyline"} ${attributes} points="${points}"/>`;
}
if (visual.type === "rectangle") {
const center = safeVisualPoint(size, visual.center, origin);
const width = visual.width * CELL_SIZE;
const height = visual.height * CELL_SIZE;
return `<rect ${attributes} x="${number(center.x - width / 2)}" y="${number(center.y - height / 2)}" width="${number(width)}" height="${number(height)}"${visual.cornerRadius === undefined ? "" : ` rx="${number(visual.cornerRadius * CELL_SIZE)}"`}/>`;
}
if (visual.type === "ellipse") {
const center = safeVisualPoint(size, visual.center, origin);
return `<ellipse ${attributes} cx="${number(center.x)}" cy="${number(center.y)}" rx="${number(visual.radiusX * CELL_SIZE)}" ry="${number(visual.radiusY * CELL_SIZE)}"/>`;
}
if (visual.type === "circle") {
const center = safeVisualPoint(size, visual.center, origin);
return `<circle ${attributes} cx="${number(center.x)}" cy="${number(center.y)}" r="${number(visual.radius * CELL_SIZE)}"/>`;
}
const position = safeVisualPoint(size, visual.position, origin);
return `<text ${attributes} x="${number(position.x)}" y="${number(position.y)}"${style.fontSize === undefined ? "" : ` font-size="${number(style.fontSize * CELL_SIZE)}"`}>${escapeXml(visual.text)}</text>`;
}
function pathBoundary(
size: number,
cells: ReadonlySet<number>,
@@ -141,6 +214,8 @@ function globalRuleLabels(puzzle: NormalizedPuzzle): string[] {
labels.push("Anti-king");
if (puzzle.constraints.some(({ type }) => type === "non-consecutive"))
labels.push("Non-consecutive");
if (puzzle.constraints.some(({ type }) => type === "disjoint-groups"))
labels.push("Disjoint groups");
return labels;
}
@@ -157,10 +232,41 @@ function renderConstraint(
if (
constraint.type === "anti-knight" ||
constraint.type === "anti-king" ||
constraint.type === "non-consecutive"
constraint.type === "non-consecutive" ||
constraint.type === "disjoint-groups"
) {
return "";
}
if (
constraint.type === "modular-line" ||
constraint.type === "entropic-line"
) {
return `<g class="constraint ${constraint.type}${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/></g>`;
}
if (constraint.type === "zipper-line") {
const center = point(
size,
constraint.cells[Math.floor(constraint.cells.length / 2)]!,
origin,
);
return `<g class="constraint zipper-line${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/><circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.22)}"/></g>`;
}
if (constraint.type === "double-arrow") {
const first = point(size, constraint.cells[0]!, origin);
const last = point(size, constraint.cells.at(-1)!, origin);
return `<g class="constraint double-arrow${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/><circle cx="${number(first.x)}" cy="${number(first.y)}" r="${number(CELL_SIZE * 0.22)}"/><circle cx="${number(last.x)}" cy="${number(last.y)}" r="${number(CELL_SIZE * 0.22)}"/></g>`;
}
if (constraint.type === "indexer") {
const center = point(size, constraint.cell, origin);
const label =
constraint.kind === "row"
? "R"
: constraint.kind === "column"
? "C"
: "B";
return `<g class="constraint indexer indexer-${constraint.kind}${polarity}" data-constraint="${index}"><rect x="${number(center.x - CELL_SIZE * 0.24)}" y="${number(center.y - CELL_SIZE * 0.24)}" width="${number(CELL_SIZE * 0.48)}" height="${number(CELL_SIZE * 0.48)}" rx="${number(CELL_SIZE * 0.1)}"/><text x="${number(center.x)}" y="${number(center.y)}">${marker}${label}</text></g>`;
}
if (constraint.type === "fog") return "";
if (constraint.type === "diagonal") {
const startX =
origin.x + (constraint.direction === "main" ? 0 : size * CELL_SIZE);
@@ -173,6 +279,9 @@ function renderConstraint(
const label = point(size, first, origin);
return `<g class="constraint cage${polarity}" data-constraint="${index}"><path d="${pathBoundary(size, new Set(constraint.cells), origin, 7)}"/><text class="cage-label" x="${number(label.x - CELL_SIZE * 0.34)}" y="${number(label.y - CELL_SIZE * 0.27)}">${marker}${String(constraint.sum)}</text></g>`;
}
if (constraint.type === "extra-region") {
return `<g class="constraint extra-region" data-constraint="${index}"><path d="${pathBoundary(size, new Set(constraint.cells), origin, 4)}"/></g>`;
}
if (constraint.type === "thermo") {
const bulb = point(size, constraint.cells[0]!, origin);
return `<g class="constraint thermo${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/><circle cx="${number(bulb.x)}" cy="${number(bulb.y)}" r="${number(CELL_SIZE * 0.3)}"/>${negated ? `<text class="false-marker" x="${number(bulb.x)}" y="${number(bulb.y)}">≠</text>` : ""}</g>`;
@@ -188,7 +297,12 @@ function renderConstraint(
const maximumY = Math.max(...bulbPoints.map(({ y }) => y));
return `<g class="constraint arrow${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, path, origin)}"/><rect x="${number(minimumX - CELL_SIZE * 0.3)}" y="${number(minimumY - CELL_SIZE * 0.3)}" width="${number(maximumX - minimumX + CELL_SIZE * 0.6)}" height="${number(maximumY - minimumY + CELL_SIZE * 0.6)}" rx="${number(CELL_SIZE * 0.3)}"/><circle class="arrow-tip" cx="${number(tip.x)}" cy="${number(tip.y)}" r="${number(CELL_SIZE * 0.075)}"/>${negated ? `<text class="false-marker" x="${number(bulb.x)}" y="${number(bulb.y)}">≠</text>` : ""}</g>`;
}
if (constraint.type === "renban" || constraint.type === "palindrome") {
if (
constraint.type === "renban" ||
constraint.type === "palindrome" ||
constraint.type === "german-whisper" ||
constraint.type === "region-sum-line"
) {
return `<g class="constraint ${constraint.type}${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/>${
constraint.type === "palindrome"
? constraint.cells
@@ -200,12 +314,43 @@ function renderConstraint(
: ""
}</g>`;
}
if (constraint.type === "between-line") {
const first = point(size, constraint.cells[0]!, origin);
const last = point(size, constraint.cells.at(-1)!, origin);
return `<g class="constraint between-line${polarity}" data-constraint="${index}"><polyline points="${linePoints(size, constraint.cells, origin)}"/><circle cx="${number(first.x)}" cy="${number(first.y)}" r="${number(CELL_SIZE * 0.27)}"/><circle cx="${number(last.x)}" cy="${number(last.y)}" r="${number(CELL_SIZE * 0.27)}"/>${negated ? `<text class="false-marker" x="${number(first.x)}" y="${number(first.y)}">≠</text>` : ""}</g>`;
}
if (constraint.type === "clone") {
const original = new Set(constraint.cells);
const clone = new Set(constraint.cloneCells);
const connectors = constraint.cells
.map((cell, pairIndex) => {
const a = point(size, cell, origin);
const b = point(size, constraint.cloneCells[pairIndex]!, origin);
return `<line x1="${number(a.x)}" y1="${number(a.y)}" x2="${number(b.x)}" y2="${number(b.y)}"/>`;
})
.join("");
return `<g class="constraint clone${polarity}" data-constraint="${index}">${connectors}<path d="${pathBoundary(size, original, origin, 7)}"/><path d="${pathBoundary(size, clone, origin, 7)}"/>${negated ? `<text class="false-marker" x="${number(point(size, constraint.cells[0]!, origin).x)}" y="${number(point(size, constraint.cells[0]!, origin).y)}">≠</text>` : ""}</g>`;
}
if (constraint.type === "maximum") {
const center = point(size, constraint.cell, origin);
const offset = CELL_SIZE * 0.28;
const inner = CELL_SIZE * 0.14;
return `<g class="constraint maximum${polarity}" data-constraint="${index}"><circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.18)}"/><path d="M${number(center.x - inner)} ${number(center.y - inner)}L${number(center.x)} ${number(center.y - offset)}L${number(center.x + inner)} ${number(center.y - inner)}M${number(center.x - inner)} ${number(center.y + inner)}L${number(center.x)} ${number(center.y + offset)}L${number(center.x + inner)} ${number(center.y + inner)}M${number(center.x - inner)} ${number(center.y - inner)}L${number(center.x - offset)} ${number(center.y)}L${number(center.x - inner)} ${number(center.y + inner)}M${number(center.x + inner)} ${number(center.y - inner)}L${number(center.x + offset)} ${number(center.y)}L${number(center.x + inner)} ${number(center.y + inner)}"/></g>`;
}
if (constraint.type === "minimum") {
const center = point(size, constraint.cell, origin);
const offset = CELL_SIZE * 0.28;
const inner = CELL_SIZE * 0.14;
return `<g class="constraint minimum${polarity}" data-constraint="${index}"><circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.18)}"/><path d="M${number(center.x - offset)} ${number(center.y - offset)}L${number(center.x - inner)} ${number(center.y - inner)}M${number(center.x + offset)} ${number(center.y - offset)}L${number(center.x + inner)} ${number(center.y - inner)}M${number(center.x - offset)} ${number(center.y + offset)}L${number(center.x - inner)} ${number(center.y + inner)}M${number(center.x + offset)} ${number(center.y + offset)}L${number(center.x + inner)} ${number(center.y + inner)}"/>${negated ? `<text class="false-marker" x="${number(center.x)}" y="${number(center.y)}">≠</text>` : ""}</g>`;
}
if (constraint.type === "odd" || constraint.type === "even") {
const center = point(size, constraint.cell, origin);
const shape =
constraint.type === "odd"
? `<circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.19)}"/>`
: `<rect x="${number(center.x - CELL_SIZE * 0.19)}" y="${number(center.y - CELL_SIZE * 0.19)}" width="${number(CELL_SIZE * 0.38)}" height="${number(CELL_SIZE * 0.38)}" rx="${number(CELL_SIZE * 0.035)}"/>`;
return `<g class="constraint parity ${constraint.type}${polarity}" data-constraint="${index}">${shape}${negated ? `<text class="false-marker" x="${number(center.x)}" y="${number(center.y)}">≠</text>` : ""}</g>`;
}
if (constraint.type === "quadruple") {
const positions = constraint.cells.map((cell) => point(size, cell, origin));
const center = {
@@ -218,7 +363,11 @@ function renderConstraint(
};
return `<g class="constraint quadruple${polarity}" data-constraint="${index}"><circle cx="${number(center.x)}" cy="${number(center.y)}" r="${number(CELL_SIZE * 0.29)}"/><text x="${number(center.x)}" y="${number(center.y)}">${marker}${escapeXml(constraint.digits.map((digit) => symbolFor(digit, size)).join(""))}</text></g>`;
}
if (constraint.type === "x-sum" || constraint.type === "skyscraper") {
if (
constraint.type === "x-sum" ||
constraint.type === "skyscraper" ||
constraint.type === "sandwich"
) {
const position = outsidePoint(
size,
constraint.side,
@@ -226,9 +375,10 @@ function renderConstraint(
origin,
);
const value =
constraint.type === "x-sum" ? constraint.sum : constraint.count;
constraint.type === "skyscraper" ? constraint.count : constraint.sum;
const companionIndex = puzzle.constraints.findIndex(
(candidate) =>
constraint.type !== "sandwich" &&
candidate.type !== constraint.type &&
(candidate.type === "x-sum" || candidate.type === "skyscraper") &&
candidate.side === constraint.side &&
@@ -242,7 +392,36 @@ function renderConstraint(
if (combined) {
return `<g class="constraint outside combined${polarity}" data-constraint="${index}"><rect x="${number(position.x - 25)}" y="${number(position.y - 23)}" width="50" height="46" rx="8"/><text class="outside-kinds" x="${number(position.x)}" y="${number(position.y - 8)}">Σ · ▥</text><text class="outside-value" x="${number(position.x)}" y="${number(position.y + 10)}">${marker}${String(value)}</text></g>`;
}
return `<g class="constraint outside ${constraint.type}${polarity}" data-constraint="${index}"><rect x="${number(position.x - 25)}" y="${number(position.y - 18)}" width="50" height="36" rx="8"/><text x="${number(position.x)}" y="${number(position.y)}">${constraint.type === "x-sum" ? "Σ" : "▥"}${marker}${String(value)}</text></g>`;
const symbol =
constraint.type === "x-sum"
? "Σ"
: constraint.type === "skyscraper"
? "▥"
: "1⋯N ";
return `<g class="constraint outside ${constraint.type}${polarity}" data-constraint="${index}"><rect x="${number(position.x - 25)}" y="${number(position.y - 18)}" width="50" height="36" rx="8"/><text x="${number(position.x)}" y="${number(position.y)}">${symbol}${marker}${String(value)}</text></g>`;
}
if (constraint.type === "little-killer") {
const position = outsidePoint(
size,
constraint.side,
constraint.index,
origin,
);
const cells = littleKillerCells(
size,
constraint.side,
constraint.index,
constraint.direction,
);
const first = point(size, cells[0]!, origin);
return `<g class="constraint outside little-killer${polarity}" data-constraint="${index}"><text x="${number(position.x)}" y="${number(position.y)}">${marker}${String(constraint.sum)}</text><line x1="${number(position.x)}" y1="${number(position.y)}" x2="${number(first.x)}" y2="${number(first.y)}"/><circle class="arrow-tip" cx="${number(first.x)}" cy="${number(first.y)}" r="${number(CELL_SIZE * 0.06)}"/></g>`;
}
if (
constraint.type !== "kropki" &&
constraint.type !== "xv" &&
constraint.type !== "inequality"
) {
return "";
}
const a = point(
size,
@@ -329,7 +508,11 @@ export function renderPuzzleSvg(
const includeProgress = options.includeProgress ?? true;
const includeNotes = options.includeNotes ?? includeProgress;
const hasOutside = puzzle.constraints.some(
({ type }) => type === "x-sum" || type === "skyscraper",
({ type }) =>
type === "x-sum" ||
type === "skyscraper" ||
type === "little-killer" ||
type === "sandwich",
);
const sideMargin = hasOutside ? OUTSIDE_MARGIN : BOARD_MARGIN;
const boardSize = puzzle.size * CELL_SIZE;
@@ -384,6 +567,20 @@ export function renderPuzzleSvg(
renderConstraint(constraint, index, puzzle, origin),
)
.join("");
const sourceUnderlays = (normalizedDocument.visuals ?? [])
.map((visual, index) => ({ visual, index }))
.filter(({ visual }) => visual.layer === "underlay")
.map(({ visual, index }) =>
renderSafeVisual(visual, puzzle.size, origin, index),
)
.join("");
const sourceOverlays = (normalizedDocument.visuals ?? [])
.map((visual, index) => ({ visual, index }))
.filter(({ visual }) => visual.layer === "overlay")
.map(({ visual, index }) =>
renderSafeVisual(visual, puzzle.size, origin, index),
)
.join("");
const digits = Array.from({ length: cellCount }, (_, cell) => {
const value = puzzle.givens[cell] || values[cell] || 0;
const center = point(puzzle.size, cell, origin);
@@ -425,11 +622,11 @@ export function renderPuzzleSvg(
<title id="title">${escapeXml(title)}</title>
<desc id="description">${escapeXml(`${String(puzzle.size)} by ${String(puzzle.size)} Sudoku${includeProgress ? " with current progress" : ""}`)}</desc>
<style>
.background{fill:#fff}.heading{fill:#172033;font-family:system-ui,sans-serif}.title{font-size:28px;font-weight:750}.byline,.global-rules{fill:#5d6678;font-size:14px}.grid-lines{fill:none;stroke:#a5acb8;stroke-width:1}.region-boundaries{fill:none;stroke:#172033;stroke-width:3;stroke-linecap:square}.constraint{fill:none;stroke:#596273;stroke-width:6;stroke-linecap:round;stroke-linejoin:round}.constraint text{dominant-baseline:central;text-anchor:middle;fill:#172033;stroke:none;font-family:system-ui,sans-serif;font-weight:700}.constraint.negated{stroke:#c63f52;stroke-dasharray:10 7}.constraint.negated text{fill:#a5283b}.diagonal{stroke:#4da3d6;stroke-width:3}.cage{stroke-width:2.2;stroke-dasharray:6 5}.cage-label{font-size:14px;text-anchor:start!important}.thermo{stroke:#c3c7ce;stroke-width:20}.thermo circle{fill:#c3c7ce;stroke:none}.thermo.negated{stroke-width:13}.false-marker{font-size:22px}.arrow{stroke:#929aa7;stroke-width:5}.arrow rect{fill:#fff;stroke:#929aa7}.arrow-tip{fill:#929aa7;stroke:none}.renban{stroke:#b55b8c;stroke-width:14;opacity:.72}.palindrome{stroke:#9ba1ad;stroke-width:13}.palindrome circle{fill:#d8dbe1;stroke:none}.maximum circle{fill:#eef0f4;stroke:#596273;stroke-width:2}.maximum path{stroke-width:3}.quadruple circle{fill:#fff;stroke:#596273;stroke-width:2}.quadruple text{font-size:15px}.outside rect{fill:#fff;stroke:#788191;stroke-width:1.5}.outside text{font-size:17px}.outside .outside-kinds{font-size:12px}.outside .outside-value{font-size:16px}.kropki circle{stroke:#172033;stroke-width:2}.kropki.white circle{fill:#fff}.kropki.black circle{fill:#172033}.pair-false-marker{fill:#c63f52!important;font-size:14px}.xv circle{fill:#fff;stroke:#fff}.xv text{font-size:18px}.inequality{stroke:#172033;stroke-width:4}.inequality-tip{fill:#172033;stroke:none}.cell-value,.corner-note,.center-note{dominant-baseline:central;text-anchor:middle;font-family:system-ui,sans-serif}.cell-value{fill:#273c75;font-size:${number(CELL_SIZE * 0.58)}px}.cell-value.given{fill:#111827;font-weight:800}.cell-value.progress{font-weight:600}.corner-note{fill:#596273;font-size:${number(CELL_SIZE * 0.15)}px}.center-note{fill:#596273;font-size:${number(CELL_SIZE * 0.2)}px;letter-spacing:1px}
.background{fill:#fff}.heading{fill:#172033;font-family:system-ui,sans-serif}.title{font-size:28px;font-weight:750}.byline,.global-rules{fill:#5d6678;font-size:14px}.source-visual{stroke-linecap:round;stroke-linejoin:round}.source-visual--text{dominant-baseline:central;text-anchor:middle;font-family:system-ui,sans-serif}.grid-lines{fill:none;stroke:#a5acb8;stroke-width:1}.region-boundaries{fill:none;stroke:#172033;stroke-width:3;stroke-linecap:square}.constraint{fill:none;stroke:#596273;stroke-width:6;stroke-linecap:round;stroke-linejoin:round}.constraint text{dominant-baseline:central;text-anchor:middle;fill:#172033;stroke:none;font-family:system-ui,sans-serif;font-weight:700}.constraint.negated{stroke:#c63f52;stroke-dasharray:10 7}.constraint.negated text{fill:#a5283b}.diagonal{stroke:#4da3d6;stroke-width:3}.cage{stroke-width:2.2;stroke-dasharray:6 5}.cage-label{font-size:14px;text-anchor:start!important}.extra-region{stroke:#6d62b5;stroke-width:3;stroke-dasharray:10 5}.thermo{stroke:#c3c7ce;stroke-width:20}.thermo circle{fill:#c3c7ce;stroke:none}.thermo.negated{stroke-width:13}.false-marker{font-size:22px}.arrow{stroke:#929aa7;stroke-width:5}.arrow rect{fill:#fff;stroke:#929aa7}.arrow-tip{fill:#929aa7;stroke:none}.renban{stroke:#b55b8c;stroke-width:14;opacity:.72}.palindrome{stroke:#9ba1ad;stroke-width:13}.palindrome circle{fill:#d8dbe1;stroke:none}.german-whisper{stroke:#4d9b69;stroke-width:10}.region-sum-line{stroke:#4a94a3;stroke-width:7}.between-line{stroke:#7e8796;stroke-width:5}.between-line circle{fill:#fff;stroke:#7e8796;stroke-width:5}.modular-line{stroke:#167f8f;stroke-width:10;stroke-dasharray:18 7}.entropic-line{stroke:#d47742;stroke-width:10;stroke-dasharray:3 7}.zipper-line{stroke:#7b60ad;stroke-width:7}.zipper-line circle{fill:#fff;stroke:#7b60ad;stroke-width:5}.double-arrow{stroke:#596273;stroke-width:5}.double-arrow circle{fill:#fff;stroke:#596273;stroke-width:4}.indexer{stroke-width:2}.indexer rect{fill:#fff}.indexer-row{stroke:#287fba}.indexer-column{stroke:#b94c50}.indexer-box{stroke:#438b58}.indexer text{font-size:15px}.clone{stroke:#6d62b5;stroke-width:2.5;stroke-dasharray:8 5}.clone line{stroke-width:1.5;stroke-dasharray:4 5;opacity:.45}.maximum circle,.minimum circle{fill:#eef0f4;stroke:#596273;stroke-width:2}.maximum path,.minimum path{stroke-width:3}.parity{stroke:#596273;stroke-width:2}.parity circle,.parity rect{fill:#eef0f4}.quadruple circle{fill:#fff;stroke:#596273;stroke-width:2}.quadruple text{font-size:15px}.outside rect{fill:#fff;stroke:#788191;stroke-width:1.5}.outside text{font-size:17px}.outside .outside-kinds{font-size:12px}.outside .outside-value{font-size:16px}.little-killer line{stroke-width:2}.little-killer .arrow-tip{fill:#596273}.kropki circle{stroke:#172033;stroke-width:2}.kropki.white circle{fill:#fff}.kropki.black circle{fill:#172033}.pair-false-marker{fill:#c63f52!important;font-size:14px}.xv circle{fill:#fff;stroke:#fff}.xv text{font-size:18px}.inequality{stroke:#172033;stroke-width:4}.inequality-tip{fill:#172033;stroke:none}.cell-value,.corner-note,.center-note{dominant-baseline:central;text-anchor:middle;font-family:system-ui,sans-serif}.cell-value{fill:#273c75;font-size:${number(CELL_SIZE * 0.58)}px}.cell-value.given{fill:#111827;font-weight:800}.cell-value.progress{font-weight:600}.corner-note{fill:#596273;font-size:${number(CELL_SIZE * 0.15)}px}.center-note{fill:#596273;font-size:${number(CELL_SIZE * 0.2)}px;letter-spacing:1px}
</style>
<rect class="background" width="100%" height="100%"/>
<g class="heading"><text class="title" x="${origin.x}" y="34">${escapeXml(title)}</text><text class="byline" x="${origin.x}" y="57">${escapeXml(byline)}</text>${globals.length > 0 ? `<text class="global-rules" x="${width - sideMargin}" y="57" text-anchor="end">${escapeXml(globals.join(" · "))}</text>` : ""}</g>
<g>${backgrounds}<g class="grid-lines">${gridLines}</g>${constraints}${renderRegionBoundaries(puzzle, origin)}${digits}</g>
<g>${backgrounds}${sourceUnderlays}<g class="grid-lines">${gridLines}</g>${constraints}${renderRegionBoundaries(puzzle, origin)}${digits}${sourceOverlays}</g>
</svg>`;
if (new TextEncoder().encode(svg).byteLength > MAX_VISUAL_EXPORT_BYTES) {
throw new SudokuFormatError(
+4
View File
@@ -139,6 +139,10 @@ export function houseLabel(unit: Pick<SudokuUnit, "kind" | "index">): string {
return `Region ${String(unit.index + 1)}`;
case "diagonal":
return unit.index === 0 ? "Main diagonal" : "Anti-diagonal";
case "disjoint-group":
return `Disjoint group ${String(unit.index + 1)}`;
case "extra-region":
return `Extra region ${String(unit.index + 1)}`;
}
}
+14
View File
@@ -7,3 +7,17 @@ createRoot(document.getElementById("root")!).render(
<App />
</StrictMode>,
);
if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", () => {
const url = new URL("./sw.js", document.baseURI);
void navigator.serviceWorker
.register(url, {
scope: new URL("./", document.baseURI).pathname,
})
.catch(() => {
// Offline support is progressive enhancement; the workbench remains
// fully usable when a host or private browsing mode blocks workers.
});
});
}
+954
View File
@@ -0,0 +1,954 @@
import type { CellId, SudokuUnit } from "../domain";
import type {
LogicalElimination,
LogicalStep,
LogicalTechnique,
} from "./logical";
export interface AdvancedLogicalContext {
readonly size: number;
readonly values: readonly number[];
/** Candidate masks use bit `1 << digit`, matching the logical solver. */
readonly masks: readonly number[];
readonly regions: readonly number[];
readonly peers: readonly ReadonlySet<CellId>[];
readonly units: readonly SudokuUnit[];
/** Must come from a completed uniqueness proof; false/omitted is the default. */
readonly uniquenessProven?: boolean;
/** Extra constraints can invalidate uniqueness-pattern swap arguments. */
readonly uniquenessPatternsSafe?: boolean;
}
const MAX_CHAIN_NODES = 10;
const MAX_CHAIN_VISITS = 200_000;
function digitBit(value: number): number {
return 1 << value;
}
function popcount(mask: number): number {
let value = mask >>> 0;
let count = 0;
while (value !== 0) {
value &= value - 1;
count += 1;
}
return count;
}
function maskDigits(mask: number, size: number): number[] {
const result: number[] = [];
for (let value = 1; value <= size; value += 1) {
if ((mask & digitBit(value)) !== 0) result.push(value);
}
return result;
}
function hasCandidate(
context: AdvancedLogicalContext,
cell: CellId,
value: number,
): boolean {
return (
context.values[cell] === 0 &&
((context.masks[cell] ?? 0) & digitBit(value)) !== 0
);
}
function combinations<T>(values: readonly T[], count: number): T[][] {
const result: T[][] = [];
const selected: T[] = [];
const visit = (start: number): void => {
if (selected.length === count) {
result.push([...selected]);
return;
}
for (
let index = start;
index <= values.length - (count - selected.length);
index += 1
) {
const value = values[index];
if (value === undefined) continue;
selected.push(value);
visit(index + 1);
selected.pop();
}
};
visit(0);
return result;
}
function uniqueSorted(values: Iterable<number>): number[] {
return [...new Set(values)].sort((a, b) => a - b);
}
function step(
technique: LogicalTechnique,
eliminations: readonly LogicalElimination[],
focusCells: Iterable<CellId>,
explanation: string,
): LogicalStep | undefined {
if (eliminations.length === 0) return undefined;
const byCell = new Map<number, Set<number>>();
for (const elimination of eliminations) {
const values = byCell.get(elimination.cell) ?? new Set<number>();
for (const value of elimination.values) values.add(value);
byCell.set(elimination.cell, values);
}
return {
technique,
placements: [],
eliminations: [...byCell]
.sort(([a], [b]) => a - b)
.map(([cell, values]) => ({
cell,
values: [...values].sort((a, b) => a - b),
})),
focusCells: uniqueSorted(focusCells),
explanation,
};
}
function unitCandidates(
context: AdvancedLogicalContext,
unit: SudokuUnit,
value: number,
): number[] {
return unit.cells.filter((cell) => hasCandidate(context, cell, value));
}
function lineUnits(
context: AdvancedLogicalContext,
kind: "row" | "column",
): SudokuUnit[] {
return context.units
.filter((unit) => unit.kind === kind)
.sort((a, b) => a.index - b.index);
}
function baseIndex(
context: AdvancedLogicalContext,
kind: "row" | "column",
cell: CellId,
): number {
return kind === "row" ? Math.floor(cell / context.size) : cell % context.size;
}
function coverIndex(
context: AdvancedLogicalContext,
kind: "row" | "column",
cell: CellId,
): number {
return kind === "row" ? cell % context.size : Math.floor(cell / context.size);
}
function commonPeers(
context: AdvancedLogicalContext,
cells: readonly CellId[],
): Set<CellId> {
const first = cells[0];
if (first === undefined) return new Set<CellId>();
const result = new Set(context.peers[first] ?? []);
for (const cell of cells.slice(1)) {
for (const candidate of result) {
if (!(context.peers[cell]?.has(candidate) ?? false)) {
result.delete(candidate);
}
}
}
for (const cell of cells) result.delete(cell);
return result;
}
function commonPeerEliminations(
context: AdvancedLogicalContext,
endpoints: readonly CellId[],
value: number,
excluded: ReadonlySet<CellId> = new Set(),
): LogicalElimination[] {
return [...commonPeers(context, endpoints)]
.filter((cell) => !excluded.has(cell) && hasCandidate(context, cell, value))
.sort((a, b) => a - b)
.map((cell) => ({ cell, values: [value] }));
}
export function findJellyfish(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
const count = 4;
for (const baseKind of ["row", "column"] as const) {
const bases = lineUnits(context, baseKind);
for (let value = 1; value <= context.size; value += 1) {
const eligible = bases.filter((unit) => {
const total = unitCandidates(context, unit, value).length;
return total >= 2 && total <= count;
});
for (const selected of combinations(eligible, count)) {
const covers = new Set<number>();
const focus: number[] = [];
for (const unit of selected) {
for (const cell of unitCandidates(context, unit, value)) {
covers.add(coverIndex(context, baseKind, cell));
focus.push(cell);
}
}
if (covers.size !== count) continue;
const selectedBases = new Set(selected.map((unit) => unit.index));
const eliminations: LogicalElimination[] = [];
for (let cell = 0; cell < context.values.length; cell += 1) {
if (
!selectedBases.has(baseIndex(context, baseKind, cell)) &&
covers.has(coverIndex(context, baseKind, cell)) &&
hasCandidate(context, cell, value)
) {
eliminations.push({ cell, values: [value] });
}
}
const found = step(
"jellyfish",
eliminations,
focus,
`${value} is confined to four cover lines across four ${baseKind}s, forming a Jellyfish.`,
);
if (found !== undefined) return found;
}
}
}
return undefined;
}
function findFinnedFishOfSize(
context: AdvancedLogicalContext,
count: 2 | 3,
): LogicalStep | undefined {
for (const baseKind of ["row", "column"] as const) {
const bases = lineUnits(context, baseKind);
for (let value = 1; value <= context.size; value += 1) {
const eligible = bases.filter((unit) => {
const total = unitCandidates(context, unit, value).length;
return total >= 2 && total <= count + 2;
});
for (const selected of combinations(eligible, count)) {
const allCovers = uniqueSorted(
selected.flatMap((unit) =>
unitCandidates(context, unit, value).map((cell) =>
coverIndex(context, baseKind, cell),
),
),
);
// One or two extra cover lines provide ordinary fins without allowing
// an unbounded cover-subset search on large grids.
if (allCovers.length < count + 1 || allCovers.length > count + 2) {
continue;
}
for (const covers of combinations(allCovers, count)) {
const coverSet = new Set(covers);
for (const finBase of selected) {
let compatible = true;
const bodyCells: number[] = [];
const finCells: number[] = [];
for (const unit of selected) {
for (const cell of unitCandidates(context, unit, value)) {
if (coverSet.has(coverIndex(context, baseKind, cell))) {
bodyCells.push(cell);
} else if (unit.index === finBase.index) {
finCells.push(cell);
} else {
compatible = false;
}
}
}
if (!compatible || finCells.length === 0) continue;
if (
selected.some(
(unit) =>
unitCandidates(context, unit, value).filter((cell) =>
coverSet.has(coverIndex(context, baseKind, cell)),
).length === 0,
)
) {
continue;
}
const usedBodyCovers = new Set(
bodyCells.map((cell) => coverIndex(context, baseKind, cell)),
);
if (usedBodyCovers.size !== count) continue;
const finRegion = context.regions[finCells[0] as number];
if (
finRegion === undefined ||
finCells.some((cell) => context.regions[cell] !== finRegion)
) {
continue;
}
const baseSet = new Set(selected.map((unit) => unit.index));
const finPeers = commonPeers(context, finCells);
const eliminations: LogicalElimination[] = [];
for (let cell = 0; cell < context.values.length; cell += 1) {
if (
!baseSet.has(baseIndex(context, baseKind, cell)) &&
coverSet.has(coverIndex(context, baseKind, cell)) &&
finPeers.has(cell) &&
hasCandidate(context, cell, value)
) {
eliminations.push({ cell, values: [value] });
}
}
const technique: LogicalTechnique =
count === 2 ? "finned-x-wing" : "finned-swordfish";
const found = step(
technique,
eliminations,
[...bodyCells, ...finCells],
`${value} forms a ${count === 2 ? "Finned X-Wing" : "Finned Swordfish"}; the fin and fish body both eliminate it in their shared region.`,
);
if (found !== undefined) return found;
}
}
}
}
}
return undefined;
}
export function findFinnedFish(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
return findFinnedFishOfSize(context, 2) ?? findFinnedFishOfSize(context, 3);
}
export function findSkyscraper(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
for (const baseKind of ["row", "column"] as const) {
const bases = lineUnits(context, baseKind);
for (let value = 1; value <= context.size; value += 1) {
const strongLines = bases
.map((unit) => ({ unit, cells: unitCandidates(context, unit, value) }))
.filter(({ cells }) => cells.length === 2);
for (const pair of combinations(strongLines, 2)) {
const first = pair[0];
const second = pair[1];
if (first === undefined || second === undefined) continue;
for (let firstBase = 0; firstBase < 2; firstBase += 1) {
for (let secondBase = 0; secondBase < 2; secondBase += 1) {
const aBase = first.cells[firstBase];
const bBase = second.cells[secondBase];
const aRoof = first.cells[1 - firstBase];
const bRoof = second.cells[1 - secondBase];
if (
aBase === undefined ||
bBase === undefined ||
aRoof === undefined ||
bRoof === undefined ||
coverIndex(context, baseKind, aBase) !==
coverIndex(context, baseKind, bBase)
) {
continue;
}
const pattern = new Set([aBase, bBase, aRoof, bRoof]);
if (pattern.size !== 4) continue;
const found = step(
"skyscraper",
commonPeerEliminations(context, [aRoof, bRoof], value, pattern),
pattern,
`${value} forms a Skyscraper: at least one of the two roof cells must contain it.`,
);
if (found !== undefined) return found;
}
}
}
}
}
return undefined;
}
export function findTwoStringKite(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
const rows = lineUnits(context, "row");
const columns = lineUnits(context, "column");
for (let value = 1; value <= context.size; value += 1) {
const rowLinks = rows
.map((unit) => ({ unit, cells: unitCandidates(context, unit, value) }))
.filter(({ cells }) => cells.length === 2);
const columnLinks = columns
.map((unit) => ({ unit, cells: unitCandidates(context, unit, value) }))
.filter(({ cells }) => cells.length === 2);
for (const row of rowLinks) {
for (const column of columnLinks) {
for (let rowBaseIndex = 0; rowBaseIndex < 2; rowBaseIndex += 1) {
for (
let columnBaseIndex = 0;
columnBaseIndex < 2;
columnBaseIndex += 1
) {
const rowBase = row.cells[rowBaseIndex];
const columnBase = column.cells[columnBaseIndex];
const rowRoof = row.cells[1 - rowBaseIndex];
const columnRoof = column.cells[1 - columnBaseIndex];
if (
rowBase === undefined ||
columnBase === undefined ||
rowRoof === undefined ||
columnRoof === undefined
) {
continue;
}
const pattern = new Set([rowBase, columnBase, rowRoof, columnRoof]);
if (
pattern.size !== 4 ||
context.regions[rowBase] !== context.regions[columnBase]
) {
continue;
}
const found = step(
"two-string-kite",
commonPeerEliminations(
context,
[rowRoof, columnRoof],
value,
pattern,
),
pattern,
`${value} forms a Two-String Kite through conjugate row and column links.`,
);
if (found !== undefined) return found;
}
}
}
}
}
return undefined;
}
interface CellStrongLink {
readonly a: CellId;
readonly b: CellId;
readonly value: number;
}
function cellStrongLinks(
context: AdvancedLogicalContext,
requestedValue?: number,
): CellStrongLink[] {
const links = new Map<string, CellStrongLink>();
const first = requestedValue ?? 1;
const last = requestedValue ?? context.size;
for (let value = first; value <= last; value += 1) {
for (const unit of context.units) {
const cells = unitCandidates(context, unit, value);
if (cells.length !== 2) continue;
const a = Math.min(cells[0] as number, cells[1] as number);
const b = Math.max(cells[0] as number, cells[1] as number);
links.set(`${value}:${a}:${b}`, { a, b, value });
}
}
return [...links.values()].sort(
(a, b) => a.value - b.value || a.a - b.a || a.b - b.b,
);
}
function strongCellAdjacency(
context: AdvancedLogicalContext,
value: number,
): readonly ReadonlySet<CellId>[] {
const adjacency = Array.from(
{ length: context.values.length },
() => new Set<CellId>(),
);
for (const link of cellStrongLinks(context, value)) {
adjacency[link.a]?.add(link.b);
adjacency[link.b]?.add(link.a);
}
return adjacency;
}
export function findSimpleColouring(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
for (let value = 1; value <= context.size; value += 1) {
const adjacency = strongCellAdjacency(context, value);
const visited = new Set<number>();
for (let start = 0; start < context.values.length; start += 1) {
if (visited.has(start) || (adjacency[start]?.size ?? 0) === 0) continue;
const colours = new Map<number, 0 | 1>([[start, 0]]);
const queue = [start];
let bipartite = true;
while (queue.length > 0) {
const cell = queue.shift() as number;
visited.add(cell);
const colour = colours.get(cell) as 0 | 1;
const neighbours = [...(adjacency[cell] ?? [])].sort((a, b) => a - b);
for (const neighbour of neighbours) {
const expected = colour === 0 ? 1 : 0;
const existing = colours.get(neighbour);
if (existing === undefined) {
colours.set(neighbour, expected);
queue.push(neighbour);
} else if (existing !== expected) {
bipartite = false;
}
}
}
if (!bipartite) continue;
const component = uniqueSorted(colours.keys());
const componentSet = new Set(component);
for (const colour of [0, 1] as const) {
const cells = component.filter((cell) => colours.get(cell) === colour);
const conflict = cells.some((cell, index) =>
cells
.slice(index + 1)
.some((other) => context.peers[cell]?.has(other) ?? false),
);
if (!conflict) continue;
const found = step(
"simple-colouring",
cells.map((cell) => ({ cell, values: [value] })),
component,
`Two ${value} candidates with the same colour see each other, so that colour is false.`,
);
if (found !== undefined) return found;
}
const colourZero = component.filter((cell) => colours.get(cell) === 0);
const colourOne = component.filter((cell) => colours.get(cell) === 1);
const eliminations: LogicalElimination[] = [];
for (let cell = 0; cell < context.values.length; cell += 1) {
if (!hasCandidate(context, cell, value) || componentSet.has(cell)) {
continue;
}
if (
colourZero.some(
(coloured) => context.peers[cell]?.has(coloured) ?? false,
) &&
colourOne.some(
(coloured) => context.peers[cell]?.has(coloured) ?? false,
)
) {
eliminations.push({ cell, values: [value] });
}
}
const found = step(
"simple-colouring",
eliminations,
component,
`This ${value} candidate sees both colours of one conjugate chain.`,
);
if (found !== undefined) return found;
}
}
return undefined;
}
export function findWWing(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
const bivalue = context.masks
.map((mask, cell) => ({ cell, mask }))
.filter(
({ cell, mask }) => context.values[cell] === 0 && popcount(mask) === 2,
);
const links = cellStrongLinks(context);
for (const pair of combinations(bivalue, 2)) {
const first = pair[0];
const second = pair[1];
if (
first === undefined ||
second === undefined ||
first.mask !== second.mask ||
(context.peers[first.cell]?.has(second.cell) ?? false)
) {
continue;
}
for (const linkValue of maskDigits(first.mask, context.size)) {
const otherValue = maskDigits(
first.mask & ~digitBit(linkValue),
context.size,
)[0];
if (otherValue === undefined) continue;
for (const link of links) {
if (link.value !== linkValue) continue;
if (new Set([first.cell, second.cell, link.a, link.b]).size !== 4) {
continue;
}
const connected =
((context.peers[first.cell]?.has(link.a) ?? false) &&
(context.peers[second.cell]?.has(link.b) ?? false)) ||
((context.peers[first.cell]?.has(link.b) ?? false) &&
(context.peers[second.cell]?.has(link.a) ?? false));
if (!connected) continue;
const pattern = new Set([first.cell, second.cell, link.a, link.b]);
const found = step(
"w-wing",
commonPeerEliminations(
context,
[first.cell, second.cell],
otherValue,
pattern,
),
pattern,
`The ${linkValue} conjugate link joins two ${linkValue}/${otherValue} cells, so one wing must contain ${otherValue}.`,
);
if (found !== undefined) return found;
}
}
}
return undefined;
}
export function findXChain(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
for (let value = 1; value <= context.size; value += 1) {
const strong = strongCellAdjacency(context, value);
const candidates = Array.from(
{ length: context.values.length },
(_unused, cell) => cell,
).filter((cell) => hasCandidate(context, cell, value));
let visits = 0;
for (const start of candidates) {
const path = [start];
const inPath = new Set(path);
const search = (nextStrong: boolean): LogicalStep | undefined => {
if (visits >= MAX_CHAIN_VISITS) return undefined;
visits += 1;
const current = path[path.length - 1] as number;
const edges = path.length - 1;
if (!nextStrong && edges >= 3) {
const found = step(
"x-chain",
commonPeerEliminations(context, [start, current], value, inPath),
path,
`An alternating strong/weak X-Chain proves that at least one endpoint is ${value}.`,
);
if (found !== undefined) return found;
}
if (path.length >= MAX_CHAIN_NODES) return undefined;
const neighbours = nextStrong
? [...(strong[current] ?? [])]
: [...(context.peers[current] ?? [])].filter((cell) =>
hasCandidate(context, cell, value),
);
neighbours.sort((a, b) => a - b);
for (const neighbour of neighbours) {
if (inPath.has(neighbour)) continue;
path.push(neighbour);
inPath.add(neighbour);
const found = search(!nextStrong);
if (found !== undefined) return found;
inPath.delete(neighbour);
path.pop();
}
return undefined;
};
const found = search(true);
if (found !== undefined) return found;
if (visits >= MAX_CHAIN_VISITS) return undefined;
}
}
return undefined;
}
export function findXYChain(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
const bivalue = context.masks
.map((mask, cell) => ({ cell, mask }))
.filter(
({ cell, mask }) => context.values[cell] === 0 && popcount(mask) === 2,
);
const byCell = new Map(bivalue.map((entry) => [entry.cell, entry.mask]));
let visits = 0;
for (const start of bivalue) {
for (const target of maskDigits(start.mask, context.size)) {
const initialLink = maskDigits(
start.mask & ~digitBit(target),
context.size,
)[0];
if (initialLink === undefined) continue;
const path = [start.cell];
const inPath = new Set(path);
const search = (linkValue: number): LogicalStep | undefined => {
if (visits >= MAX_CHAIN_VISITS) return undefined;
visits += 1;
if (path.length >= MAX_CHAIN_NODES) return undefined;
const current = path[path.length - 1] as number;
const neighbours = [...(context.peers[current] ?? [])]
.filter(
(cell) =>
!inPath.has(cell) &&
((byCell.get(cell) ?? 0) & digitBit(linkValue)) !== 0,
)
.sort((a, b) => a - b);
for (const neighbour of neighbours) {
const mask = byCell.get(neighbour) as number;
const outgoing = maskDigits(
mask & ~digitBit(linkValue),
context.size,
)[0];
if (outgoing === undefined) continue;
path.push(neighbour);
inPath.add(neighbour);
if (outgoing === target && path.length >= 3) {
const found = step(
"xy-chain",
commonPeerEliminations(
context,
[start.cell, neighbour],
target,
inPath,
),
path,
`This XY-Chain forces ${target} into at least one endpoint.`,
);
if (found !== undefined) return found;
}
const found = search(outgoing);
if (found !== undefined) return found;
inPath.delete(neighbour);
path.pop();
}
return undefined;
};
const found = search(initialLink);
if (found !== undefined) return found;
if (visits >= MAX_CHAIN_VISITS) return undefined;
}
}
return undefined;
}
interface CandidateNode {
readonly cell: CellId;
readonly value: number;
}
function nodeId(context: AdvancedLogicalContext, node: CandidateNode): number {
return node.cell * (context.size + 1) + node.value;
}
function nodeFromId(
context: AdvancedLogicalContext,
id: number,
): CandidateNode {
return {
cell: Math.floor(id / (context.size + 1)),
value: id % (context.size + 1),
};
}
function aicStrongAdjacency(
context: AdvancedLogicalContext,
): ReadonlyMap<number, ReadonlySet<number>> {
const adjacency = new Map<number, Set<number>>();
const add = (a: CandidateNode, b: CandidateNode): void => {
const aId = nodeId(context, a);
const bId = nodeId(context, b);
const aLinks = adjacency.get(aId) ?? new Set<number>();
const bLinks = adjacency.get(bId) ?? new Set<number>();
aLinks.add(bId);
bLinks.add(aId);
adjacency.set(aId, aLinks);
adjacency.set(bId, bLinks);
};
for (const link of cellStrongLinks(context)) {
add(
{ cell: link.a, value: link.value },
{ cell: link.b, value: link.value },
);
}
for (let cell = 0; cell < context.values.length; cell += 1) {
const values = maskDigits(context.masks[cell] ?? 0, context.size);
if (context.values[cell] !== 0 || values.length !== 2) continue;
add(
{ cell, value: values[0] as number },
{ cell, value: values[1] as number },
);
}
return adjacency;
}
function aicWeakNeighbours(
context: AdvancedLogicalContext,
node: CandidateNode,
): number[] {
const ids: number[] = [];
for (const value of maskDigits(context.masks[node.cell] ?? 0, context.size)) {
if (value !== node.value)
ids.push(nodeId(context, { cell: node.cell, value }));
}
for (const cell of context.peers[node.cell] ?? []) {
if (hasCandidate(context, cell, node.value)) {
ids.push(nodeId(context, { cell, value: node.value }));
}
}
return uniqueSorted(ids);
}
export function findAic(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
const strong = aicStrongAdjacency(context);
const starts = [...strong.keys()].sort((a, b) => a - b);
let visits = 0;
for (const startId of starts) {
const start = nodeFromId(context, startId);
const path = [startId];
const inPath = new Set(path);
const search = (nextStrong: boolean): LogicalStep | undefined => {
if (visits >= MAX_CHAIN_VISITS) return undefined;
visits += 1;
const currentId = path[path.length - 1] as number;
const current = nodeFromId(context, currentId);
const edges = path.length - 1;
if (
!nextStrong &&
edges >= 3 &&
current.cell !== start.cell &&
current.value === start.value
) {
const pathCells = new Set(
path.map((id) => nodeFromId(context, id).cell),
);
const found = step(
"aic",
commonPeerEliminations(
context,
[start.cell, current.cell],
start.value,
pathCells,
),
pathCells,
`An Alternating Inference Chain proves that at least one endpoint is ${start.value}.`,
);
if (found !== undefined) return found;
}
if (path.length >= MAX_CHAIN_NODES) return undefined;
const neighbours = nextStrong
? [...(strong.get(currentId) ?? [])].sort((a, b) => a - b)
: aicWeakNeighbours(context, current);
for (const neighbour of neighbours) {
if (inPath.has(neighbour)) continue;
path.push(neighbour);
inPath.add(neighbour);
const found = search(!nextStrong);
if (found !== undefined) return found;
inPath.delete(neighbour);
path.pop();
}
return undefined;
};
const found = search(true);
if (found !== undefined) return found;
if (visits >= MAX_CHAIN_VISITS) return undefined;
}
return undefined;
}
export function findUniqueRectangle(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
if (
context.uniquenessProven !== true ||
context.uniquenessPatternsSafe !== true
) {
return undefined;
}
for (let firstRow = 0; firstRow < context.size; firstRow += 1) {
for (
let secondRow = firstRow + 1;
secondRow < context.size;
secondRow += 1
) {
for (let firstColumn = 0; firstColumn < context.size; firstColumn += 1) {
for (
let secondColumn = firstColumn + 1;
secondColumn < context.size;
secondColumn += 1
) {
const cells = [
firstRow * context.size + firstColumn,
firstRow * context.size + secondColumn,
secondRow * context.size + firstColumn,
secondRow * context.size + secondColumn,
];
if (cells.some((cell) => context.values[cell] !== 0)) continue;
const regionCounts = new Map<number, number>();
for (const cell of cells) {
const region = context.regions[cell];
if (region === undefined) continue;
regionCounts.set(region, (regionCounts.get(region) ?? 0) + 1);
}
if (
regionCounts.size !== 2 ||
[...regionCounts.values()].some((count) => count !== 2)
) {
continue;
}
const regionsPreservePair = [...regionCounts.keys()].every(
(region) => {
const pair = cells.filter(
(cell) => context.regions[cell] === region,
);
const first = pair[0];
const second = pair[1];
return (
first !== undefined &&
second !== undefined &&
(Math.floor(first / context.size) ===
Math.floor(second / context.size) ||
first % context.size === second % context.size)
);
},
);
if (!regionsPreservePair) continue;
for (let roofIndex = 0; roofIndex < cells.length; roofIndex += 1) {
const roof = cells[roofIndex] as number;
const floor = cells.filter((_cell, index) => index !== roofIndex);
const pairMask = context.masks[floor[0] as number] ?? 0;
if (
popcount(pairMask) !== 2 ||
floor.some((cell) => (context.masks[cell] ?? 0) !== pairMask)
) {
continue;
}
const roofMask = context.masks[roof] ?? 0;
if (
(roofMask & pairMask) !== pairMask ||
popcount(roofMask & ~pairMask) === 0
) {
continue;
}
const values = maskDigits(pairMask, context.size);
const found = step(
"unique-rectangle",
[{ cell: roof, values }],
cells,
`A proven-unique puzzle cannot complete this ${values.join("/")} rectangle in two interchangeable ways.`,
);
if (found !== undefined) return found;
}
}
}
}
}
return undefined;
}
export function findAdvancedLogicalStep(
context: AdvancedLogicalContext,
): LogicalStep | undefined {
return (
findJellyfish(context) ??
findFinnedFish(context) ??
findSkyscraper(context) ??
findTwoStringKite(context) ??
findSimpleColouring(context) ??
findWWing(context) ??
findUniqueRectangle(context) ??
findXChain(context) ??
findXYChain(context) ??
findAic(context)
);
}
+15 -1
View File
@@ -58,6 +58,17 @@ const TECHNIQUE_WEIGHT: Readonly<Record<LogicalTechnique, number>> = {
"xy-wing": 57,
"xyz-wing": 61,
swordfish: 66,
skyscraper: 67,
"two-string-kite": 68,
"finned-x-wing": 69,
"simple-colouring": 70,
jellyfish: 72,
"w-wing": 73,
"unique-rectangle": 74,
"finned-swordfish": 75,
"x-chain": 78,
"xy-chain": 82,
aic: 88,
};
function boundedInteger(
@@ -151,12 +162,15 @@ export function evaluateDifficulty(
120_000,
"exactTimeoutMs",
);
const logical = solveLogically(normalized, { maxSteps: logicalMaxSteps });
const exact = solveExact(normalized, {
maxSolutions: 2,
maxNodes: exactMaxNodes,
timeoutMs: exactTimeoutMs,
});
const logical = solveLogically(normalized, {
maxSteps: logicalMaxSteps,
uniquenessProven: exact.count === 1 && !exact.truncated,
});
if (exact.count === 0 && !exact.truncated) {
return unrated(
+280 -36
View File
@@ -7,12 +7,45 @@ import {
import { solveExact } from "./exact";
import { seededRandom, shuffled } from "./random";
export type ClueSymmetry = "none" | "rotational";
export type ClueSymmetry =
| "none"
| "rotational"
| "horizontal"
| "vertical"
| "diagonal-main"
| "diagonal-anti"
| "orthogonal"
/** Alias for orthogonal quarter-turn symmetry. */
| "four-way";
export type MinimalGivensStatus =
"not-requested" | "proven-minimal" | "unknown";
export type MinimalityLimitReason =
"check-cap" | "node-cap" | "timeout" | "not-unique" | "inconsistent-result";
export interface MinimalGivensEvidence {
readonly status: MinimalGivensStatus;
readonly checksPerformed: number;
readonly nodes: number;
readonly removedClues: number;
readonly criticalCells: readonly number[];
readonly unknownCells: readonly number[];
readonly limitReasons: readonly MinimalityLimitReason[];
readonly symmetryPreserved: boolean;
}
export interface MinimizePuzzleResult {
readonly puzzle: NormalizedPuzzle;
readonly minimality: MinimalGivensEvidence;
}
export interface MinimizeOptions {
readonly seed?: string | number;
readonly targetClues?: number;
readonly symmetry?: ClueSymmetry;
/** Continue with individual clue deletion tests until givens are minimal. */
readonly minimalGivens?: boolean;
readonly maxChecks?: number;
readonly solveMaxNodes?: number;
readonly solveTimeoutMs?: number;
@@ -117,11 +150,106 @@ function boundedOption(
return result;
}
export function minimizePuzzle(
function validatedSymmetry(value: ClueSymmetry | undefined): ClueSymmetry {
const symmetry = value ?? "rotational";
if (
symmetry !== "none" &&
symmetry !== "rotational" &&
symmetry !== "horizontal" &&
symmetry !== "vertical" &&
symmetry !== "diagonal-main" &&
symmetry !== "diagonal-anti" &&
symmetry !== "orthogonal" &&
symmetry !== "four-way"
) {
throw new RangeError(`Unsupported clue symmetry: ${String(symmetry)}.`);
}
return symmetry;
}
/** Returns the complete deterministic clue orbit for a symmetry. */
export function clueOrbit(
size: number,
cell: number,
requestedSymmetry: ClueSymmetry,
): readonly number[] {
if (
!Number.isInteger(size) ||
size < 1 ||
!Number.isInteger(cell) ||
cell < 0 ||
cell >= size * size
) {
throw new RangeError("Clue orbit requires a valid square-grid cell.");
}
const symmetry = validatedSymmetry(requestedSymmetry);
const row = Math.floor(cell / size);
const column = cell % size;
const at = (nextRow: number, nextColumn: number): number =>
nextRow * size + nextColumn;
const horizontal = at(size - row - 1, column);
const vertical = at(row, size - column - 1);
const rotational = at(size - row - 1, size - column - 1);
const quarterTurn = at(column, size - row - 1);
const threeQuarterTurn = at(size - column - 1, row);
const cells = (() => {
switch (symmetry) {
case "none":
return [cell];
case "rotational":
return [cell, rotational];
case "horizontal":
return [cell, horizontal];
case "vertical":
return [cell, vertical];
case "diagonal-main":
return [cell, at(column, row)];
case "diagonal-anti":
return [cell, at(size - column - 1, size - row - 1)];
case "orthogonal":
case "four-way":
return [cell, quarterTurn, rotational, threeQuarterTurn];
}
})();
return [...new Set(cells)].sort((a, b) => a - b);
}
function cluePatternPreservesSymmetry(
givens: readonly number[],
size: number,
symmetry: ClueSymmetry,
): boolean {
for (let cell = 0; cell < givens.length; cell += 1) {
const present = (givens[cell] ?? 0) !== 0;
if (
clueOrbit(size, cell, symmetry).some(
(other) => ((givens[other] ?? 0) !== 0) !== present,
)
) {
return false;
}
}
return true;
}
function uniqueAfterRemoval(
puzzle: PuzzleDefinition,
maxNodes: number,
timeoutMs: number,
) {
return solveExact(puzzle, {
maxSolutions: 2,
maxNodes,
timeoutMs,
});
}
export function minimizePuzzleWithReport(
puzzle: PuzzleDefinition | NormalizedPuzzle,
options: MinimizeOptions = {},
): NormalizedPuzzle {
): MinimizePuzzleResult {
const normalized = normalizePuzzle(puzzle);
const minimalGivens = options.minimalGivens === true;
const targetClues = boundedOption(
options.targetClues,
Math.max(
@@ -134,17 +262,30 @@ export function minimizePuzzle(
);
const maxChecks = boundedOption(
options.maxChecks,
normalized.size * normalized.size * 2,
normalized.size * normalized.size * (minimalGivens ? 3 : 2),
1,
normalized.size * normalized.size * 10,
"maxChecks",
);
const symmetry = options.symmetry ?? "rotational";
if (symmetry !== "none" && symmetry !== "rotational") {
throw new RangeError('symmetry must be "none" or "rotational"');
}
const random = seededRandom(options.seed ?? "sudoku-tools");
const solveMaxNodes = boundedOption(
options.solveMaxNodes,
2_000_000,
1,
100_000_000,
"solveMaxNodes",
);
const solveTimeoutMs = boundedOption(
options.solveTimeoutMs,
10_000,
1,
120_000,
"solveTimeoutMs",
);
const symmetry = validatedSymmetry(options.symmetry);
const seed = options.seed ?? "sudoku-tools";
const random = seededRandom(seed);
const givens = [...normalized.givens];
const initialClues = givens.filter((value) => value !== 0).length;
const countClues = (): number =>
givens.reduce((count, value) => count + (value === 0 ? 0 : 1), 0);
const order = shuffled(
@@ -152,36 +293,139 @@ export function minimizePuzzle(
random,
);
let checks = 0;
for (const cell of order) {
if (checks >= maxChecks || countClues() <= targetClues) break;
if (givens[cell] === 0) continue;
const mirror = givens.length - cell - 1;
const group =
symmetry === "rotational" && mirror !== cell ? [cell, mirror] : [cell];
if (group.some((entry) => givens[entry] === 0)) continue;
if (countClues() - group.length < targetClues) continue;
const saved = group.map((entry) => givens[entry] ?? 0);
group.forEach((entry) => {
givens[entry] = 0;
});
let nodes = 0;
const criticalCells: number[] = [];
const unknownCells: number[] = [];
const limitReasons = new Set<MinimalityLimitReason>();
let uniquenessPrerequisiteProven = true;
// Minimality is meaningful only for a uniquely solvable starting puzzle.
// This check shares the same explicit check, node and wall-clock budgets as
// every subsequent deletion test.
if (minimalGivens) {
checks += 1;
const candidate: PuzzleDefinition = {
...normalized,
givens,
solution: normalized.solution,
};
const result = solveExact(candidate, {
maxSolutions: 2,
maxNodes: options.solveMaxNodes ?? 2_000_000,
timeoutMs: options.solveTimeoutMs ?? 10_000,
});
if (result.count !== 1 || result.truncated) {
group.forEach((entry, index) => {
givens[entry] = saved[index] ?? 0;
});
const baseline = uniqueAfterRemoval(
{ ...normalized, givens, solution: normalized.solution },
solveMaxNodes,
solveTimeoutMs,
);
nodes += baseline.nodes;
if (baseline.count !== 1 || baseline.truncated) {
uniquenessPrerequisiteProven = false;
unknownCells.push(
...givens.flatMap((value, cell) => (value === 0 ? [] : [cell])),
);
if (
baseline.count >= 2 ||
(baseline.count === 0 && !baseline.truncated)
) {
limitReasons.add("not-unique");
} else if (baseline.limitReason === "node-cap") {
limitReasons.add("node-cap");
} else if (baseline.limitReason === "timeout") {
limitReasons.add("timeout");
} else {
limitReasons.add("inconsistent-result");
}
}
}
return normalizePuzzle({ ...normalized, givens });
if (!minimalGivens || uniquenessPrerequisiteProven) {
for (const cell of order) {
if (checks >= maxChecks || countClues() <= targetClues) break;
if (givens[cell] === 0) continue;
const group = clueOrbit(normalized.size, cell, symmetry);
if (group.some((entry) => givens[entry] === 0)) continue;
if (countClues() - group.length < targetClues) continue;
const saved = group.map((entry) => givens[entry] ?? 0);
group.forEach((entry) => {
givens[entry] = 0;
});
checks += 1;
const result = uniqueAfterRemoval(
{ ...normalized, givens, solution: normalized.solution },
solveMaxNodes,
solveTimeoutMs,
);
nodes += result.nodes;
if (result.count !== 1 || result.truncated) {
group.forEach((entry, index) => {
givens[entry] = saved[index] ?? 0;
});
}
}
}
if (minimalGivens && uniquenessPrerequisiteProven) {
const minimalOrder = shuffled(
Array.from({ length: givens.length }, (_, cell) => cell).filter(
(cell) => givens[cell] !== 0,
),
seededRandom(`${String(seed)}:minimal-givens`),
);
for (let index = 0; index < minimalOrder.length; index += 1) {
const cell = minimalOrder[index] as number;
if (givens[cell] === 0) continue;
if (checks >= maxChecks) {
limitReasons.add("check-cap");
unknownCells.push(
...minimalOrder
.slice(index)
.filter((remaining) => givens[remaining] !== 0),
);
break;
}
const saved = givens[cell] as number;
givens[cell] = 0;
checks += 1;
const result = uniqueAfterRemoval(
{ ...normalized, givens, solution: normalized.solution },
solveMaxNodes,
solveTimeoutMs,
);
nodes += result.nodes;
if (result.count === 1 && !result.truncated) continue;
givens[cell] = saved;
if (result.count >= 2) {
criticalCells.push(cell);
} else {
unknownCells.push(cell);
if (result.limitReason === "node-cap") limitReasons.add("node-cap");
else if (result.limitReason === "timeout") limitReasons.add("timeout");
else limitReasons.add("inconsistent-result");
}
}
}
const minimized = normalizePuzzle({ ...normalized, givens });
return {
puzzle: minimized,
minimality: {
status: minimalGivens
? uniquenessPrerequisiteProven && unknownCells.length === 0
? "proven-minimal"
: "unknown"
: "not-requested",
checksPerformed: checks,
nodes,
removedClues: initialClues - countClues(),
criticalCells: [...new Set(criticalCells)].sort((a, b) => a - b),
unknownCells: [...new Set(unknownCells)].sort((a, b) => a - b),
limitReasons: [...limitReasons].sort(),
symmetryPreserved: cluePatternPreservesSymmetry(
givens,
normalized.size,
symmetry,
),
},
};
}
export function minimizePuzzle(
puzzle: PuzzleDefinition | NormalizedPuzzle,
options: MinimizeOptions = {},
): NormalizedPuzzle {
return minimizePuzzleWithReport(puzzle, options).puzzle;
}
export function generateClassic(
+2
View File
@@ -1,6 +1,8 @@
export * from "./advancedLogical";
export * from "./difficulty";
export * from "./exact";
export * from "./generator";
export * from "./killer";
export * from "./logical";
export * from "./quality";
export * from "./variantGenerator";
+119 -4
View File
@@ -12,6 +12,7 @@ import {
type SudokuUnit,
type ValidationIssue,
} from "../domain";
import { findAdvancedLogicalStep } from "./advancedLogical";
export type LogicalTechnique =
| "naked-single"
@@ -26,6 +27,17 @@ export type LogicalTechnique =
| "claiming"
| "x-wing"
| "swordfish"
| "jellyfish"
| "finned-x-wing"
| "finned-swordfish"
| "skyscraper"
| "two-string-kite"
| "simple-colouring"
| "w-wing"
| "x-chain"
| "xy-chain"
| "aic"
| "unique-rectangle"
| "xy-wing"
| "xyz-wing"
| "killer-cage";
@@ -52,6 +64,19 @@ export type LogicalSolveStatus = "solved" | "stuck" | "invalid" | "step-limit";
export interface LogicalSolveOptions {
readonly values?: readonly number[];
/**
* Optional per-cell candidate restrictions to resume a logical solve after
* applying an elimination-only step. Each entry contains ordinary Sudoku
* digits (1 through the puzzle size), rather than an implementation-specific
* bit mask. Restrictions are intersected with the candidates that remain
* legal on the supplied board; entries for filled cells are ignored.
*/
readonly candidates?: readonly (readonly number[])[];
/**
* Enables uniqueness-dependent deductions only after the caller has proved
* exactly one solution with an exhaustive solver result. Never inferred.
*/
readonly uniquenessProven?: boolean;
readonly maxSteps?: number;
}
@@ -148,9 +173,73 @@ function validateStart(
if (issues.length > 0) throw new PuzzleValidationError(issues);
}
function compileCandidateRestrictions(
normalized: NormalizedPuzzle,
input: unknown,
): number[] | undefined {
if (input === undefined) return undefined;
const issues: ValidationIssue[] = [];
const cellCount = normalized.size * normalized.size;
if (!Array.isArray(input)) {
throw new PuzzleValidationError([
{ path: "candidates", message: "must be an array of candidate arrays" },
]);
}
if (input.length !== cellCount) {
issues.push({
path: "candidates",
message: `must contain exactly ${cellCount} candidate arrays`,
});
}
const masks = new Array<number>(cellCount).fill(0);
for (let cell = 0; cell < Math.min(input.length, cellCount); cell += 1) {
const cellCandidates: unknown = input[cell];
if (!Array.isArray(cellCandidates)) {
issues.push({
path: `candidates[${cell}]`,
message: "must be an array of candidate digits",
});
continue;
}
let mask = 0;
for (let index = 0; index < cellCandidates.length; index += 1) {
const value: unknown = cellCandidates[index];
if (
typeof value !== "number" ||
!Number.isInteger(value) ||
value < 1 ||
value > normalized.size
) {
issues.push({
path: `candidates[${cell}][${index}]`,
message: `must be an integer from 1 to ${normalized.size}`,
});
continue;
}
const bit = digitBit(value);
if ((mask & bit) !== 0) {
issues.push({
path: `candidates[${cell}][${index}]`,
message: `contains duplicate candidate ${value}`,
});
continue;
}
mask |= bit;
}
masks[cell] = mask;
}
if (issues.length > 0) throw new PuzzleValidationError(issues);
return masks;
}
function initializeState(
normalized: NormalizedPuzzle,
values: readonly number[],
candidateRestrictions?: readonly number[],
): LogicalState {
const compiled = compilePuzzle(normalized);
return {
@@ -158,10 +247,13 @@ function initializeState(
values: [...values],
masks: values.map((value, cell) => {
if (value !== 0) return 0;
return candidatesForCell(compiled, values, cell).reduce(
const legalMask = candidatesForCell(compiled, values, cell).reduce(
(mask, candidate) => mask | digitBit(candidate),
0,
);
return candidateRestrictions === undefined
? legalMask
: legalMask & (candidateRestrictions[cell] ?? 0);
}),
};
}
@@ -624,7 +716,10 @@ function findKillerReduction(state: LogicalState): LogicalStep | undefined {
return undefined;
}
function findStep(state: LogicalState): LogicalStep | undefined {
function findStep(
state: LogicalState,
uniquenessProven: boolean,
): LogicalStep | undefined {
return (
findNakedSingle(state) ??
findHiddenSingle(state) ??
@@ -634,6 +729,16 @@ function findStep(state: LogicalState): LogicalStep | undefined {
findFish(state) ??
findXyWing(state) ??
findXyzWing(state) ??
findAdvancedLogicalStep({
size: state.compiled.puzzle.size,
values: state.values,
masks: state.masks,
regions: state.compiled.puzzle.regions,
peers: state.compiled.peers,
units: state.compiled.units,
uniquenessProven,
uniquenessPatternsSafe: state.compiled.puzzle.constraints.length === 0,
}) ??
findKillerReduction(state)
);
}
@@ -677,7 +782,17 @@ export function solveLogically(
if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > 10_000) {
throw new RangeError("maxSteps must be an integer from 1 to 10000");
}
const state = initializeState(normalized, values);
if (
options.uniquenessProven !== undefined &&
typeof options.uniquenessProven !== "boolean"
) {
throw new TypeError("uniquenessProven must be a boolean when supplied");
}
const candidateRestrictions = compileCandidateRestrictions(
normalized,
options.candidates,
);
const state = initializeState(normalized, values, candidateRestrictions);
const steps: LogicalStep[] = [];
if (findConflicts(state.compiled, state.values).length > 0) {
return {
@@ -708,7 +823,7 @@ export function solveLogically(
steps,
};
}
const step = findStep(state);
const step = findStep(state, options.uniquenessProven === true);
if (step === undefined) {
return {
status: "stuck",
+791
View File
@@ -0,0 +1,791 @@
import {
PuzzleValidationError,
constraintCells,
normalizePuzzle,
type NormalizedPuzzle,
type PuzzleDefinition,
type VariantConstraint,
} from "../domain";
import {
solveExact,
type ExactLimitReason,
type ExactSolveResult,
} from "./exact";
export type QualitySolutionStatus =
"unsatisfiable" | "unique" | "multiple" | "unknown";
export type QualityClassification = "critical" | "redundant" | "unknown";
export type QualityUnknownReason =
| "baseline-not-unique"
| "baseline-unknown"
| "per-check-node-cap"
| "per-check-timeout"
| "aggregate-check-cap"
| "aggregate-node-cap"
| "aggregate-timeout"
| "unexpected-unsatisfiable-relaxation";
export type QualityCheckPurpose =
"baseline" | "redundancy" | "contradiction-core";
export type QualityItemReference =
| {
readonly kind: "given";
readonly cell: number;
readonly value: number;
}
| {
readonly kind: "constraint";
readonly index: number;
readonly constraintType: VariantConstraint["type"];
};
export interface PuzzleQualityOptions {
/** Baseline only is a fast 0/1/2-solution check; full adds setter QC. */
readonly analysisDepth?: "baseline" | "full";
/** Maximum search nodes consumed by any one exact-solver check. */
readonly perCheckMaxNodes?: number;
/** Wall-clock limit for any one exact-solver check. */
readonly perCheckTimeoutMs?: number;
/** Maximum number of exact-solver checks in the whole analysis. */
readonly aggregateMaxChecks?: number;
/** Maximum exact-solver nodes shared by the whole analysis. */
readonly aggregateMaxNodes?: number;
/** Wall-clock limit shared by the whole analysis. */
readonly aggregateTimeoutMs?: number;
/** Derive a bounded single-clue/constraint minimality proof. */
readonly proveMinimality?: boolean;
}
export interface QualityBounds {
readonly perCheck: {
readonly maxNodes: number;
readonly timeoutMs: number;
};
readonly aggregate: {
readonly maxChecks: number;
readonly maxNodes: number;
readonly timeoutMs: number;
};
}
export interface QualitySearchCheck {
readonly index: number;
readonly purpose: QualityCheckPurpose;
readonly item?: QualityItemReference;
readonly solutionStatus: QualitySolutionStatus;
readonly solutionsFound: number;
/** False only when a node/time limit prevented a conclusion. */
readonly conclusive: boolean;
/** Mirrors the exact solver; two found solutions intentionally hit solution-cap. */
readonly truncated: boolean;
readonly limitReason?: ExactLimitReason;
readonly unknownReason?: QualityUnknownReason;
readonly nodes: number;
readonly elapsedMs: number;
}
export interface AmbiguityDifference {
readonly cell: number;
readonly first: number;
readonly second: number;
}
export interface AmbiguityWitness {
readonly firstSolution: readonly number[];
readonly secondSolution: readonly number[];
readonly differences: readonly AmbiguityDifference[];
}
export interface QualityItemAssessment {
readonly item: QualityItemReference;
readonly classification: QualityClassification;
readonly checkIndex?: number;
readonly solutionStatus?: QualitySolutionStatus;
readonly unknownReason?: QualityUnknownReason;
}
export interface ContradictionLocalization {
readonly status: "not-applicable" | "localized" | "incomplete";
/** A deletion-minimized unsatisfiable core. Unknown items remain in this set. */
readonly core: readonly QualityItemReference[];
/** Core items proven necessary: removing one made the current core satisfiable. */
readonly necessary: readonly QualityItemReference[];
/** Items proven unnecessary to retain unsatisfiability. */
readonly removable: readonly QualityItemReference[];
readonly unknown: readonly QualityItemReference[];
readonly reason?: string;
}
export interface CellCriticality {
readonly cell: number;
/** Critical share among conclusive assessments, or null without one. */
readonly score: number | null;
readonly criticalWeight: number;
readonly redundantWeight: number;
readonly unknownWeight: number;
}
export interface QualityMinimalityAnalysis {
readonly status:
"proven-minimal" | "not-minimal" | "unknown" | "not-applicable";
readonly redundant: readonly QualityItemReference[];
readonly unknown: readonly QualityItemReference[];
readonly reason?: string;
}
export interface QualityBudgetSummary {
readonly checksPlanned: number;
readonly checksPerformed: number;
readonly nodes: number;
readonly elapsedMs: number;
/** True when at least one requested conclusion remained unknown. */
readonly truncated: boolean;
readonly unknownReasons: readonly QualityUnknownReason[];
}
export interface PuzzleQualityAnalysis {
readonly analysisDepth: "baseline" | "full";
readonly solutionStatus: QualitySolutionStatus;
readonly solution?: readonly number[];
readonly baselineCheckIndex?: number;
readonly ambiguityWitness?: AmbiguityWitness;
readonly contradiction: ContradictionLocalization;
readonly redundancy: {
readonly givens: readonly QualityItemAssessment[];
readonly constraints: readonly QualityItemAssessment[];
};
readonly criticalityHeatmap: readonly CellCriticality[];
readonly minimality?: QualityMinimalityAnalysis;
readonly checks: readonly QualitySearchCheck[];
readonly bounds: QualityBounds;
readonly budget: QualityBudgetSummary;
}
const DEFAULT_BOUNDS: QualityBounds = {
perCheck: { maxNodes: 2_000_000, timeoutMs: 10_000 },
aggregate: { maxChecks: 1_000, maxNodes: 20_000_000, timeoutMs: 30_000 },
};
function boundedInteger(
value: number | undefined,
fallback: number,
minimum: number,
maximum: number,
name: string,
): number {
const result = value ?? fallback;
if (!Number.isInteger(result) || result < minimum || result > maximum) {
throw new RangeError(
`${name} must be an integer from ${minimum} to ${maximum}.`,
);
}
return result;
}
function resolveBounds(options: PuzzleQualityOptions): QualityBounds {
return {
perCheck: {
maxNodes: boundedInteger(
options.perCheckMaxNodes,
DEFAULT_BOUNDS.perCheck.maxNodes,
1,
100_000_000,
"perCheckMaxNodes",
),
timeoutMs: boundedInteger(
options.perCheckTimeoutMs,
DEFAULT_BOUNDS.perCheck.timeoutMs,
1,
120_000,
"perCheckTimeoutMs",
),
},
aggregate: {
maxChecks: boundedInteger(
options.aggregateMaxChecks,
DEFAULT_BOUNDS.aggregate.maxChecks,
1,
20_000,
"aggregateMaxChecks",
),
maxNodes: boundedInteger(
options.aggregateMaxNodes,
DEFAULT_BOUNDS.aggregate.maxNodes,
1,
2_000_000_000,
"aggregateMaxNodes",
),
timeoutMs: boundedInteger(
options.aggregateTimeoutMs,
DEFAULT_BOUNDS.aggregate.timeoutMs,
1,
600_000,
"aggregateTimeoutMs",
),
},
};
}
/**
* Quality analysis deliberately accepts structurally valid but contradictory
* givens. The regular normalizer rejects those because they are not playable,
* so validate the same document with an empty board and retain the original
* givens for the diagnostic search.
*/
function normalizeForQuality(
puzzle: PuzzleDefinition | NormalizedPuzzle,
): NormalizedPuzzle {
try {
return normalizePuzzle(puzzle);
} catch (error) {
if (!(error instanceof PuzzleValidationError)) throw error;
if (error.issues.some((issue) => issue.path !== "givens")) throw error;
const cellCount = puzzle.size * puzzle.size;
if (
!Number.isInteger(puzzle.size) ||
puzzle.givens.length !== cellCount ||
puzzle.givens.some(
(value) => !Number.isInteger(value) || value < 0 || value > puzzle.size,
)
) {
throw error;
}
const skeleton = normalizePuzzle({
...puzzle,
givens: new Array<number>(cellCount).fill(0),
solution: undefined,
});
return {
...skeleton,
givens: [...puzzle.givens],
};
}
}
function itemKey(item: QualityItemReference): string {
return item.kind === "given"
? `given:${item.cell}`
: `constraint:${item.index}`;
}
function puzzleItems(puzzle: NormalizedPuzzle): QualityItemReference[] {
const givens: QualityItemReference[] = [];
puzzle.givens.forEach((value, cell) => {
if (value !== 0) givens.push({ kind: "given", cell, value });
});
const constraints: QualityItemReference[] = puzzle.constraints.map(
(constraint, index) => ({
kind: "constraint",
index,
constraintType: constraint.type,
}),
);
return [...givens, ...constraints];
}
function withActiveItems(
puzzle: NormalizedPuzzle,
active: ReadonlySet<string>,
): PuzzleDefinition {
return {
...puzzle,
givens: puzzle.givens.map((value, cell) =>
active.has(`given:${cell}`) ? value : 0,
),
constraints: puzzle.constraints.filter((_constraint, index) =>
active.has(`constraint:${index}`),
),
};
}
function withoutItem(
puzzle: NormalizedPuzzle,
item: QualityItemReference,
): PuzzleDefinition {
if (item.kind === "given") {
return {
...puzzle,
givens: puzzle.givens.map((value, cell) =>
cell === item.cell ? 0 : value,
),
};
}
return {
...puzzle,
constraints: puzzle.constraints.filter(
(_constraint, index) => index !== item.index,
),
};
}
function statusOf(result: ExactSolveResult): QualitySolutionStatus {
if (result.count >= 2) return "multiple";
if (result.truncated) return "unknown";
return result.count === 1 ? "unique" : "unsatisfiable";
}
interface SearchOutcome {
readonly status: QualitySolutionStatus;
readonly checkIndex?: number;
readonly unknownReason?: QualityUnknownReason;
readonly solutions: readonly (readonly number[])[];
}
interface SearchBudget {
readonly bounds: QualityBounds;
readonly started: number;
readonly checks: QualitySearchCheck[];
readonly unknownReasons: Set<QualityUnknownReason>;
nodes: number;
}
function skipped(
budget: SearchBudget,
reason: QualityUnknownReason,
): SearchOutcome {
budget.unknownReasons.add(reason);
return { status: "unknown", unknownReason: reason, solutions: [] };
}
function runCheck(
budget: SearchBudget,
puzzle: PuzzleDefinition,
purpose: QualityCheckPurpose,
item?: QualityItemReference,
): SearchOutcome {
if (budget.checks.length >= budget.bounds.aggregate.maxChecks) {
return skipped(budget, "aggregate-check-cap");
}
const remainingNodes = budget.bounds.aggregate.maxNodes - budget.nodes;
if (remainingNodes <= 0) return skipped(budget, "aggregate-node-cap");
const aggregateElapsed = Date.now() - budget.started;
const remainingMs = budget.bounds.aggregate.timeoutMs - aggregateElapsed;
if (remainingMs <= 0) return skipped(budget, "aggregate-timeout");
const maxNodes = Math.min(budget.bounds.perCheck.maxNodes, remainingNodes);
const timeoutMs = Math.min(budget.bounds.perCheck.timeoutMs, remainingMs);
const searchStarted = Date.now();
let result: ExactSolveResult;
try {
result = solveExact(puzzle, {
maxSolutions: 2,
maxNodes,
timeoutMs,
});
} catch (error) {
if (!(error instanceof PuzzleValidationError)) throw error;
if (error.issues.some((issue) => issue.path !== "givens")) throw error;
// All generated relaxations are structurally normalized. A validation
// failure here therefore means their active givens already conflict.
result = {
solutions: [],
count: 0,
truncated: false,
nodes: 0,
elapsedMs: Date.now() - searchStarted,
};
}
budget.nodes += result.nodes;
const status = statusOf(result);
let unknownReason: QualityUnknownReason | undefined;
if (status === "unknown") {
if (result.limitReason === "node-cap") {
unknownReason =
maxNodes < budget.bounds.perCheck.maxNodes
? "aggregate-node-cap"
: "per-check-node-cap";
} else {
unknownReason =
timeoutMs < budget.bounds.perCheck.timeoutMs
? "aggregate-timeout"
: "per-check-timeout";
}
budget.unknownReasons.add(unknownReason);
}
const checkIndex = budget.checks.length;
budget.checks.push({
index: checkIndex,
purpose,
...(item === undefined ? {} : { item }),
solutionStatus: status,
solutionsFound: result.count,
conclusive: status !== "unknown",
truncated: result.truncated,
...(result.limitReason === undefined
? {}
: { limitReason: result.limitReason }),
...(unknownReason === undefined ? {} : { unknownReason }),
nodes: result.nodes,
elapsedMs: result.elapsedMs,
});
return {
status,
checkIndex,
...(unknownReason === undefined ? {} : { unknownReason }),
solutions: result.solutions,
};
}
function unknownAssessment(
item: QualityItemReference,
reason: QualityUnknownReason,
): QualityItemAssessment {
return { item, classification: "unknown", unknownReason: reason };
}
function assessItem(
baseline: QualitySolutionStatus,
item: QualityItemReference,
outcome: SearchOutcome,
): QualityItemAssessment {
if (outcome.status === "unknown") {
return {
item,
classification: "unknown",
...(outcome.checkIndex === undefined
? {}
: { checkIndex: outcome.checkIndex }),
solutionStatus: "unknown",
...(outcome.unknownReason === undefined
? {}
: { unknownReason: outcome.unknownReason }),
};
}
if (baseline === "unique") {
if (outcome.status === "unique") {
return {
item,
classification: "redundant",
checkIndex: outcome.checkIndex,
solutionStatus: outcome.status,
};
}
if (outcome.status === "multiple") {
return {
item,
classification: "critical",
checkIndex: outcome.checkIndex,
solutionStatus: outcome.status,
};
}
return {
item,
classification: "unknown",
checkIndex: outcome.checkIndex,
solutionStatus: outcome.status,
unknownReason: "unexpected-unsatisfiable-relaxation",
};
}
// For an inconsistent definition, an item is critical if removing it makes
// the definition satisfiable, and redundant if inconsistency remains.
if (baseline === "unsatisfiable") {
return {
item,
classification:
outcome.status === "unsatisfiable" ? "redundant" : "critical",
checkIndex: outcome.checkIndex,
solutionStatus: outcome.status,
};
}
return unknownAssessment(
item,
baseline === "multiple" ? "baseline-not-unique" : "baseline-unknown",
);
}
function redundancyAnalysis(
budget: SearchBudget,
puzzle: NormalizedPuzzle,
items: readonly QualityItemReference[],
baseline: QualitySolutionStatus,
): {
readonly givens: readonly QualityItemAssessment[];
readonly constraints: readonly QualityItemAssessment[];
} {
const assessments: QualityItemAssessment[] = [];
if (baseline === "multiple" || baseline === "unknown") {
const reason: QualityUnknownReason =
baseline === "multiple" ? "baseline-not-unique" : "baseline-unknown";
budget.unknownReasons.add(reason);
assessments.push(...items.map((item) => unknownAssessment(item, reason)));
} else {
for (const item of items) {
const outcome = runCheck(
budget,
withoutItem(puzzle, item),
"redundancy",
item,
);
const assessment = assessItem(baseline, item, outcome);
if (assessment.unknownReason !== undefined) {
budget.unknownReasons.add(assessment.unknownReason);
}
assessments.push(assessment);
}
}
return {
givens: assessments.filter(
(assessment) => assessment.item.kind === "given",
),
constraints: assessments.filter(
(assessment) => assessment.item.kind === "constraint",
),
};
}
function localizeContradiction(
budget: SearchBudget,
puzzle: NormalizedPuzzle,
items: readonly QualityItemReference[],
baseline: QualitySolutionStatus,
): ContradictionLocalization {
if (baseline !== "unsatisfiable") {
return {
status: "not-applicable",
core: [],
necessary: [],
removable: [],
unknown: [],
reason:
baseline === "unknown"
? "Unsatisfiability was not proven within the search bounds."
: "The puzzle is satisfiable.",
};
}
const active = new Set(items.map(itemKey));
const necessary: QualityItemReference[] = [];
const removable: QualityItemReference[] = [];
const unknown: QualityItemReference[] = [];
for (const item of items) {
active.delete(itemKey(item));
const outcome = runCheck(
budget,
withActiveItems(puzzle, active),
"contradiction-core",
item,
);
if (outcome.status === "unsatisfiable") {
removable.push(item);
continue;
}
active.add(itemKey(item));
if (outcome.status === "unique" || outcome.status === "multiple") {
necessary.push(item);
} else {
unknown.push(item);
}
}
const core = items.filter((item) => active.has(itemKey(item)));
return {
status: unknown.length === 0 ? "localized" : "incomplete",
core,
necessary,
removable,
unknown,
...(unknown.length === 0
? {}
: { reason: "One or more deletion checks exhausted the shared bounds." }),
};
}
function buildHeatmap(
puzzle: NormalizedPuzzle,
assessments: readonly QualityItemAssessment[],
): CellCriticality[] {
const weights = Array.from({ length: puzzle.size * puzzle.size }, () => ({
critical: 0,
redundant: 0,
unknown: 0,
}));
for (const assessment of assessments) {
const cells =
assessment.item.kind === "given"
? [assessment.item.cell]
: constraintCells(
puzzle.size,
puzzle.constraints[assessment.item.index] as VariantConstraint,
);
for (const cell of new Set(cells)) {
const weight = weights[cell];
if (weight === undefined) continue;
if (assessment.classification === "critical") weight.critical += 1;
else if (assessment.classification === "redundant") weight.redundant += 1;
else weight.unknown += 1;
}
}
return weights.map((weight, cell) => {
const conclusive = weight.critical + weight.redundant;
return {
cell,
score: conclusive === 0 ? null : weight.critical / conclusive,
criticalWeight: weight.critical,
redundantWeight: weight.redundant,
unknownWeight: weight.unknown,
};
});
}
/** Resolves the board cells represented by a quality finding. */
export function qualityItemCells(
puzzle: PuzzleDefinition | NormalizedPuzzle,
item: QualityItemReference,
): readonly number[] {
const normalized = normalizeForQuality(puzzle);
if (item.kind === "given") {
if (item.cell < 0 || item.cell >= normalized.size * normalized.size) {
throw new RangeError("Given quality item cell is outside the puzzle.");
}
return [item.cell];
}
const constraint = normalized.constraints[item.index];
if (constraint === undefined || constraint.type !== item.constraintType) {
throw new RangeError("Constraint quality item does not match the puzzle.");
}
return constraintCells(normalized.size, constraint);
}
function minimalityFrom(
baseline: QualitySolutionStatus,
assessments: readonly QualityItemAssessment[],
): QualityMinimalityAnalysis {
if (baseline !== "unique") {
return {
status: "not-applicable",
redundant: [],
unknown: assessments.map((assessment) => assessment.item),
reason: "Minimality requires a proven unique baseline puzzle.",
};
}
const redundant = assessments
.filter((assessment) => assessment.classification === "redundant")
.map((assessment) => assessment.item);
const unknown = assessments
.filter((assessment) => assessment.classification === "unknown")
.map((assessment) => assessment.item);
if (redundant.length > 0) {
return { status: "not-minimal", redundant, unknown };
}
if (unknown.length > 0) {
return {
status: "unknown",
redundant,
unknown,
reason: "At least one removal check was inconclusive.",
};
}
return { status: "proven-minimal", redundant, unknown };
}
/**
* Runs a bounded, immutable setter-quality audit. Every exact-solver call uses
* both the per-check limits and one shared aggregate budget. A capped search
* is always reported as unknown unless two solutions already prove ambiguity.
*/
export function analyzePuzzleQuality(
puzzle: PuzzleDefinition | NormalizedPuzzle,
options: PuzzleQualityOptions = {},
): PuzzleQualityAnalysis {
const normalized = normalizeForQuality(puzzle);
const analysisDepth = options.analysisDepth ?? "full";
if (analysisDepth !== "baseline" && analysisDepth !== "full") {
throw new RangeError('analysisDepth must be "baseline" or "full".');
}
const bounds = resolveBounds(options);
const budget: SearchBudget = {
bounds,
started: Date.now(),
checks: [],
unknownReasons: new Set<QualityUnknownReason>(),
nodes: 0,
};
const items = puzzleItems(normalized);
const baseline = runCheck(budget, normalized, "baseline");
const solutionStatus = baseline.status;
const ambiguityWitness = (() => {
const firstSolution = baseline.solutions[0];
const secondSolution = baseline.solutions[1];
if (
solutionStatus !== "multiple" ||
firstSolution === undefined ||
secondSolution === undefined
) {
return undefined;
}
return {
firstSolution: [...firstSolution],
secondSolution: [...secondSolution],
differences: firstSolution.flatMap((first, cell) => {
const second = secondSolution[cell] as number;
return first === second ? [] : [{ cell, first, second }];
}),
} satisfies AmbiguityWitness;
})();
const redundancy =
analysisDepth === "full"
? redundancyAnalysis(budget, normalized, items, solutionStatus)
: { givens: [], constraints: [] };
const contradiction =
analysisDepth === "full"
? localizeContradiction(budget, normalized, items, solutionStatus)
: {
status: "not-applicable" as const,
core: [],
necessary: [],
removable: [],
unknown: [],
reason: "Full quality analysis was not requested.",
};
const assessments = [...redundancy.givens, ...redundancy.constraints];
const elapsedMs = Date.now() - budget.started;
const result: PuzzleQualityAnalysis = {
analysisDepth,
solutionStatus,
...(solutionStatus === "unique" && baseline.solutions[0] !== undefined
? { solution: [...baseline.solutions[0]] }
: {}),
...(baseline.checkIndex === undefined
? {}
: { baselineCheckIndex: baseline.checkIndex }),
...(ambiguityWitness === undefined ? {} : { ambiguityWitness }),
contradiction,
redundancy,
criticalityHeatmap:
analysisDepth === "full" ? buildHeatmap(normalized, assessments) : [],
...(options.proveMinimality === true
? {
minimality:
analysisDepth === "full"
? minimalityFrom(solutionStatus, assessments)
: {
status: "not-applicable" as const,
redundant: [],
unknown: [],
reason: "A minimality proof requires full quality analysis.",
},
}
: {}),
checks: budget.checks,
bounds,
budget: {
checksPlanned:
analysisDepth === "baseline" ||
solutionStatus === "multiple" ||
solutionStatus === "unknown"
? 1
: 1 +
items.length +
(solutionStatus === "unsatisfiable" ? items.length : 0),
checksPerformed: budget.checks.length,
nodes: budget.nodes,
elapsedMs,
truncated: budget.unknownReasons.size > 0,
unknownReasons: [...budget.unknownReasons],
},
};
return result;
}
+532 -80
View File
@@ -15,8 +15,9 @@ import { solveExact } from "./exact";
import type { LogicalTechnique } from "./logical";
import {
generateClassic,
minimizePuzzle,
minimizePuzzleWithReport,
type ClueSymmetry,
type MinimalGivensEvidence,
} from "./generator";
import { seededRandom, shuffled, type RandomSource } from "./random";
@@ -118,16 +119,61 @@ export const PRACTICE_TECHNIQUES = [
"naked-quad",
"hidden-quad",
"x-wing",
"finned-x-wing",
"xy-wing",
"xyz-wing",
"swordfish",
"finned-swordfish",
"jellyfish",
"skyscraper",
"two-string-kite",
"simple-colouring",
"w-wing",
"x-chain",
"xy-chain",
"aic",
"unique-rectangle",
"killer-cage",
] as const satisfies readonly LogicalTechnique[];
export type PracticeTechnique = (typeof PRACTICE_TECHNIQUES)[number];
export type ConstraintDensity = "sparse" | "balanced" | "dense";
export type GeneratedVariantKind = GeneratorVariant | "mixed";
export interface TechniqueCountRequirement {
readonly technique: PracticeTechnique;
readonly min?: number;
readonly max?: number;
}
export interface TechniqueProfile {
readonly required?: readonly PracticeTechnique[];
readonly forbidden?: readonly PracticeTechnique[];
readonly counts?: readonly TechniqueCountRequirement[];
readonly hardestTechnique?: PracticeTechnique;
}
export interface TechniqueRequirementEvidence {
readonly technique: PracticeTechnique;
readonly actual: number;
readonly minimum?: number;
readonly maximum?: number;
readonly matched: boolean;
}
export interface TechniqueProfileEvidence {
readonly status: "matched" | "not-matched";
readonly completePath: boolean;
readonly requirements: readonly TechniqueRequirementEvidence[];
readonly requestedHardestTechnique?: PracticeTechnique;
readonly actualHardestTechnique?: LogicalTechnique;
readonly hardestMatched: boolean;
}
export interface GenerateVariantOptions {
readonly variant?: GeneratorVariant;
/** Canonicalized set of families for mixed-variant generation. */
readonly variants?: readonly GeneratorVariant[];
readonly size?: number;
readonly boxRows?: number;
readonly boxColumns?: number;
@@ -136,14 +182,18 @@ export interface GenerateVariantOptions {
readonly targetDifficulty?: GenerationDifficultyTarget;
readonly targetClues?: number;
readonly symmetry?: ClueSymmetry;
readonly minimalGivens?: boolean;
/** Number of local markings requested. Global rules, Killer and diagonal use structural counts. */
readonly constraintCount?: number;
readonly constraintDensity?: ConstraintDensity;
readonly maxChecks?: number;
readonly solveMaxNodes?: number;
readonly solveTimeoutMs?: number;
readonly difficulty?: DifficultyOptions;
/** Require the independently rated logical path to use this technique. */
readonly requiredTechnique?: PracticeTechnique;
/** Full independently checked logical-path profile. */
readonly techniqueProfile?: TechniqueProfile;
/** Bounded deterministic attempts used to find the requested technique. */
readonly maxTechniqueAttempts?: number;
}
@@ -151,11 +201,50 @@ export interface GenerateVariantOptions {
export interface GeneratedVariantPuzzle {
readonly puzzle: NormalizedPuzzle;
readonly difficulty: DifficultyAssessment;
readonly variant: GeneratorVariant;
readonly variant: GeneratedVariantKind;
readonly families: readonly GeneratorVariant[];
readonly seed: string | number;
readonly generatedConstraintCount: number;
readonly generationAttempts: number;
readonly constraintDensity: ConstraintDensity;
readonly minimality: MinimalGivensEvidence;
readonly requestedTechnique?: PracticeTechnique;
readonly techniqueProfile?: TechniqueProfileEvidence;
}
export type BatchRanking = "difficulty" | "fewest-givens" | "most-givens";
export interface GenerateVariantBatchOptions extends GenerateVariantOptions {
readonly batchSize?: number;
readonly ranking?: BatchRanking;
}
export interface GeneratedPuzzleSummary {
readonly rank: number;
readonly seed: string | number;
readonly families: readonly GeneratorVariant[];
readonly clueCount: number;
readonly constraintCount: number;
readonly score: number | null;
readonly level: DifficultyAssessment["level"];
readonly minimalityStatus: MinimalGivensEvidence["status"];
readonly profileStatus?: TechniqueProfileEvidence["status"];
}
export interface BatchGenerationFailure {
readonly seed: string | number;
readonly message: string;
}
export interface GeneratedVariantBatch {
readonly entries: readonly GeneratedVariantPuzzle[];
readonly summaries: readonly GeneratedPuzzleSummary[];
readonly failures: readonly BatchGenerationFailure[];
readonly requested: number;
readonly completed: number;
readonly truncated: boolean;
readonly ranking: BatchRanking;
readonly baseSeed: string | number;
}
const DEFAULT_CONSTRAINT_COUNTS: Readonly<
@@ -229,6 +318,47 @@ function getDefinition(variant: string) {
return GENERATOR_VARIANTS.find((definition) => definition.id === variant);
}
function canonicalFamilies(
options: GenerateVariantOptions,
): GeneratorVariant[] {
const requested =
options.variants === undefined
? [options.variant ?? "classic"]
: [...options.variants];
if (requested.length === 0 || requested.length > GENERATOR_VARIANTS.length) {
throw new RangeError(
`variants must contain 1 to ${String(GENERATOR_VARIANTS.length)} supported families.`,
);
}
const requestedSet = new Set<string>(requested);
for (const variant of requestedSet) {
if (getDefinition(variant) === undefined) {
throw new RangeError(
`Unsupported generator variant: ${String(variant)}.`,
);
}
}
return GENERATOR_VARIANTS.map(({ id }) => id).filter((id) =>
requestedSet.has(id),
);
}
function validatedDensity(
value: ConstraintDensity | undefined,
): ConstraintDensity {
const density = value ?? "balanced";
if (density !== "sparse" && density !== "balanced" && density !== "dense") {
throw new RangeError(
'constraintDensity must be "sparse", "balanced" or "dense".',
);
}
return density;
}
function densityMultiplier(density: ConstraintDensity): number {
return density === "sparse" ? 0.55 : density === "dense" ? 1.55 : 1;
}
function clueTarget(size: number, target: GenerationDifficultyTarget): number {
const ratio: Record<GenerationDifficultyTarget, number> = {
beginner: 0.58,
@@ -240,6 +370,171 @@ function clueTarget(size: number, target: GenerationDifficultyTarget): number {
return Math.max(size, Math.round(size * size * ratio[target]));
}
function isPracticeTechnique(value: unknown): value is PracticeTechnique {
return (
typeof value === "string" &&
(PRACTICE_TECHNIQUES as readonly string[]).includes(value)
);
}
interface NormalizedTechniqueProfile {
readonly requirements: readonly {
readonly technique: PracticeTechnique;
readonly minimum?: number;
readonly maximum?: number;
}[];
readonly hardestTechnique?: PracticeTechnique;
}
function techniqueBound(
value: number | undefined,
name: string,
): number | undefined {
if (value === undefined) return undefined;
return boundedInteger(value, 0, 0, 10_000, name);
}
function normalizeTechniqueProfile(
profile: TechniqueProfile | undefined,
legacyRequired?: PracticeTechnique,
): NormalizedTechniqueProfile | undefined {
if (legacyRequired !== undefined && !isPracticeTechnique(legacyRequired)) {
throw new RangeError(
`Unsupported practice technique: ${String(legacyRequired)}.`,
);
}
const requirements = new Map<
PracticeTechnique,
{ minimum?: number; maximum?: number }
>();
const ensure = (technique: unknown) => {
if (!isPracticeTechnique(technique)) {
throw new RangeError(
`Unsupported practice technique: ${String(technique)}.`,
);
}
const current = requirements.get(technique) ?? {};
requirements.set(technique, current);
return current;
};
if (legacyRequired !== undefined) {
ensure(legacyRequired).minimum = 1;
}
for (const technique of profile?.required ?? []) {
const requirement = ensure(technique);
requirement.minimum = Math.max(requirement.minimum ?? 0, 1);
}
for (const technique of profile?.forbidden ?? []) {
const requirement = ensure(technique);
requirement.maximum = Math.min(requirement.maximum ?? 0, 0);
}
const seenCounts = new Set<PracticeTechnique>();
for (const count of profile?.counts ?? []) {
const requirement = ensure(count.technique);
if (seenCounts.has(count.technique)) {
throw new RangeError(
`Technique count profile repeats ${count.technique}.`,
);
}
seenCounts.add(count.technique);
const minimum = techniqueBound(
count.min,
`techniqueProfile.counts.${count.technique}.min`,
);
const maximum = techniqueBound(
count.max,
`techniqueProfile.counts.${count.technique}.max`,
);
if (minimum === undefined && maximum === undefined) {
throw new RangeError(
`Technique count profile for ${count.technique} needs min or max.`,
);
}
if (minimum !== undefined) {
requirement.minimum = Math.max(requirement.minimum ?? 0, minimum);
}
if (maximum !== undefined) {
requirement.maximum = Math.min(requirement.maximum ?? maximum, maximum);
}
}
for (const [technique, requirement] of requirements) {
if (
requirement.minimum !== undefined &&
requirement.maximum !== undefined &&
requirement.minimum > requirement.maximum
) {
throw new RangeError(
`Technique profile for ${technique} has a minimum above its maximum.`,
);
}
}
const hardestTechnique = profile?.hardestTechnique;
if (
hardestTechnique !== undefined &&
!isPracticeTechnique(hardestTechnique)
) {
throw new RangeError(
`Unsupported hardest technique: ${String(hardestTechnique)}.`,
);
}
if (requirements.size === 0 && hardestTechnique === undefined) {
return undefined;
}
const ordered = PRACTICE_TECHNIQUES.flatMap((technique) => {
const requirement = requirements.get(technique);
return requirement === undefined ? [] : [{ technique, ...requirement }];
});
return {
requirements: ordered,
...(hardestTechnique === undefined ? {} : { hardestTechnique }),
};
}
function evaluateNormalizedTechniqueProfile(
assessment: DifficultyAssessment,
profile: NormalizedTechniqueProfile,
): TechniqueProfileEvidence {
const requirements = profile.requirements.map((requirement) => {
const actual = assessment.techniqueCounts[requirement.technique] ?? 0;
const matched =
(requirement.minimum === undefined || actual >= requirement.minimum) &&
(requirement.maximum === undefined || actual <= requirement.maximum);
return { ...requirement, actual, matched };
});
const completePath = assessment.logicalStatus === "solved";
const hardestMatched =
profile.hardestTechnique === undefined ||
assessment.hardestTechnique === profile.hardestTechnique;
return {
status:
completePath &&
requirements.every(({ matched }) => matched) &&
hardestMatched
? "matched"
: "not-matched",
completePath,
requirements,
...(profile.hardestTechnique === undefined
? {}
: { requestedHardestTechnique: profile.hardestTechnique }),
...(assessment.hardestTechnique === undefined
? {}
: { actualHardestTechnique: assessment.hardestTechnique }),
hardestMatched,
};
}
export function evaluateTechniqueProfile(
assessment: DifficultyAssessment,
profile: TechniqueProfile,
): TechniqueProfileEvidence {
const normalized = normalizeTechniqueProfile(profile);
if (normalized === undefined) {
throw new RangeError("Technique profile must contain a requirement.");
}
return evaluateNormalizedTechniqueProfile(assessment, normalized);
}
function allEdges(size: number): Array<readonly [number, number]> {
const edges: Array<readonly [number, number]> = [];
for (let cell = 0; cell < size * size; cell += 1) {
@@ -285,6 +580,7 @@ function killerCages(
size: number,
solution: readonly number[],
random: RandomSource,
density: ConstraintDensity,
): VariantConstraint[] {
const unassigned = new Set(
Array.from({ length: size * size }, (_, cell) => cell),
@@ -292,7 +588,16 @@ function killerCages(
const constraints: VariantConstraint[] = [];
for (const start of shuffled([...unassigned], random)) {
if (!unassigned.has(start)) continue;
const target = 2 + Math.floor(random() * Math.min(3, size - 1));
const minimumTarget = density === "sparse" ? Math.min(3, size) : 2;
const maximumTarget =
density === "dense"
? Math.min(3, size)
: density === "sparse"
? Math.min(5, size)
: Math.min(4, size);
const target =
minimumTarget +
Math.floor(random() * (maximumTarget - minimumTarget + 1));
const cells = [start];
const digits = new Set([solution[start]]);
unassigned.delete(start);
@@ -497,8 +802,11 @@ function localConstraints(
solution: readonly number[],
count: number,
random: RandomSource,
density: ConstraintDensity,
): VariantConstraint[] {
if (variant === "killer") return killerCages(size, solution, random);
if (variant === "killer") {
return killerCages(size, solution, random, density);
}
const candidates = (() => {
switch (variant) {
case "thermo":
@@ -527,8 +835,29 @@ function localConstraints(
);
}
function globalConstraints(
families: readonly GeneratorVariant[],
): VariantConstraint[] {
const constraints: VariantConstraint[] = [];
for (const variant of families) {
if (variant === "diagonal") {
constraints.push(
{ type: "diagonal", direction: "main" },
{ type: "diagonal", direction: "anti" },
);
} else if (
variant === "anti-knight" ||
variant === "anti-king" ||
variant === "non-consecutive"
) {
constraints.push({ type: variant });
}
}
return constraints;
}
function fullSolution(
variant: GeneratorVariant,
families: readonly GeneratorVariant[],
size: number,
boxRows: number | undefined,
boxColumns: number | undefined,
@@ -536,12 +865,8 @@ function fullSolution(
maxNodes: number,
timeoutMs: number,
): NormalizedPuzzle {
const globalVariant =
variant === "diagonal" ||
variant === "anti-knight" ||
variant === "anti-king" ||
variant === "non-consecutive";
if (!globalVariant) {
const constraints = globalConstraints(families);
if (constraints.length === 0) {
return generateClassic({
size,
boxRows,
@@ -551,13 +876,6 @@ function fullSolution(
maxChecks: 1,
});
}
const constraints: VariantConstraint[] =
variant === "diagonal"
? [
{ type: "diagonal", direction: "main" },
{ type: "diagonal", direction: "anti" },
]
: [{ type: variant }];
const empty: PuzzleDefinition = {
version: 1,
size,
@@ -569,17 +887,51 @@ function fullSolution(
maxSolutions: 1,
maxNodes,
timeoutMs,
seed: `${String(seed)}:${variant}-solution`,
seed: `${String(seed)}:${families.join("+")}-solution`,
});
const solution = solved.solutions[0];
if (solution === undefined) {
throw new Error(
`Could not construct a ${variant} solution within the ${String(timeoutMs)} ms / ${String(maxNodes)} node generation limit.`,
`Could not construct a ${families.join(" + ")} solution within the ${String(timeoutMs)} ms / ${String(maxNodes)} node generation limit.`,
);
}
return normalizePuzzle({ ...empty, givens: solution, solution });
}
type LocalGeneratorVariant = Exclude<
GeneratorVariant,
"classic" | "diagonal" | "anti-knight" | "anti-king" | "non-consecutive"
>;
function isLocalGeneratorVariant(
variant: GeneratorVariant,
): variant is LocalGeneratorVariant {
return (
variant !== "classic" &&
variant !== "diagonal" &&
variant !== "anti-knight" &&
variant !== "anti-king" &&
variant !== "non-consecutive"
);
}
function rulesForFamilies(families: readonly GeneratorVariant[]): string {
if (families.length === 1) return RULES[families[0] as GeneratorVariant];
return families
.map((family) => RULES[family])
.join(" ")
.slice(0, 16_384);
}
function labelForFamilies(families: readonly GeneratorVariant[]): string {
if (families.length === 1) {
return getDefinition(families[0] as GeneratorVariant)?.label ?? "Sudoku";
}
return `Mixed · ${families
.map((family) => getDefinition(family)?.label ?? family)
.join(" + ")}`;
}
/**
* Creates a unique, seedable variant puzzle. Generation is suitable for a Web
* Worker: every exact search has explicit node/time caps and worker termination
@@ -587,17 +939,21 @@ function fullSolution(
*/
function generateVariantAttempt(
options: GenerateVariantOptions = {},
techniqueProfile?: NormalizedTechniqueProfile,
): GeneratedVariantPuzzle {
const variant = options.variant ?? "classic";
const definition = getDefinition(variant);
if (definition === undefined) {
throw new RangeError(`Unsupported generator variant: ${String(variant)}.`);
}
const families = canonicalFamilies(options);
const variant: GeneratedVariantKind =
families.length === 1 ? (families[0] as GeneratorVariant) : "mixed";
const size = boundedInteger(options.size, 9, 4, 16, "size");
if (!(definition.supportedSizes as readonly number[]).includes(size)) {
throw new RangeError(
`${definition.label} generation supports sizes ${definition.supportedSizes.join(", ")}.`,
);
for (const family of families) {
const definition = getDefinition(
family,
) as (typeof GENERATOR_VARIANTS)[number];
if (!(definition.supportedSizes as readonly number[]).includes(size)) {
throw new RangeError(
`${definition.label} generation supports sizes ${definition.supportedSizes.join(", ")}.`,
);
}
}
const seed = options.seed ?? "sudoku-tools";
const targetDifficulty = options.targetDifficulty ?? "medium";
@@ -610,7 +966,9 @@ function generateVariantAttempt(
);
const maxChecks = boundedInteger(
options.maxChecks,
Math.min(size * size, 72),
options.minimalGivens === true
? Math.min(size * size * 3, size * size * 10)
: Math.min(size * size, 72),
1,
size * size * 10,
"maxChecks",
@@ -629,23 +987,19 @@ function generateVariantAttempt(
120_000,
"solveTimeoutMs",
);
const requestedConstraintCount = boundedInteger(
options.constraintCount,
variant === "classic" ||
variant === "diagonal" ||
variant === "anti-knight" ||
variant === "anti-king" ||
variant === "non-consecutive" ||
variant === "killer"
? 1
: DEFAULT_CONSTRAINT_COUNTS[variant],
1,
size * size * 2,
"constraintCount",
);
const random = seededRandom(`${String(seed)}:${variant}:constraints`);
const requestedConstraintCount =
options.constraintCount === undefined
? undefined
: boundedInteger(
options.constraintCount,
1,
1,
size * size * 2,
"constraintCount",
);
const constraintDensity = validatedDensity(options.constraintDensity);
const full = fullSolution(
variant,
families,
size,
options.boxRows,
options.boxColumns,
@@ -657,22 +1011,28 @@ function generateVariantAttempt(
if (solution === undefined) {
throw new Error("Internal generator error: the completed grid was lost.");
}
const constraints: VariantConstraint[] =
variant === "classic"
? []
: variant === "diagonal" ||
variant === "anti-knight" ||
variant === "anti-king" ||
variant === "non-consecutive"
? [...full.constraints]
: localConstraints(
variant,
size,
solution,
requestedConstraintCount,
random,
);
const title = `${definition.label} · ${String(seed)}`.slice(0, 256);
const constraints: VariantConstraint[] = [...full.constraints];
for (const family of families) {
if (!isLocalGeneratorVariant(family)) continue;
const baseCount =
requestedConstraintCount ??
(family === "killer" ? 1 : DEFAULT_CONSTRAINT_COUNTS[family]);
const familyCount = Math.max(
1,
Math.round(baseCount * densityMultiplier(constraintDensity)),
);
constraints.push(
...localConstraints(
family,
size,
solution,
familyCount,
seededRandom(`${String(seed)}:${family}:constraints`),
constraintDensity,
),
);
}
const title = `${labelForFamilies(families)} · ${String(seed)}`.slice(0, 256);
const complete = normalizePuzzle({
version: 1,
size,
@@ -682,29 +1042,41 @@ function generateVariantAttempt(
solution,
title,
author: "Sudoku Tools generator",
rules: RULES[variant],
rules: rulesForFamilies(families),
});
const puzzle = minimizePuzzle(complete, {
seed: `${String(seed)}:${variant}:clues`,
const minimized = minimizePuzzleWithReport(complete, {
seed: `${String(seed)}:${families.join("+")}:clues`,
targetClues,
symmetry: options.symmetry ?? "rotational",
maxChecks,
solveMaxNodes,
solveTimeoutMs,
minimalGivens: options.minimalGivens,
});
const puzzle = minimized.puzzle;
const difficulty = evaluateDifficulty(puzzle, options.difficulty);
if (difficulty.uniqueness !== "unique") {
throw new Error(
"Generated puzzle did not pass the bounded uniqueness audit; no puzzle was returned.",
);
}
const profileEvidence =
techniqueProfile === undefined
? undefined
: evaluateNormalizedTechniqueProfile(difficulty, techniqueProfile);
return {
puzzle,
difficulty,
variant,
families,
seed,
generatedConstraintCount: constraints.length,
generationAttempts: 1,
constraintDensity,
minimality: minimized.minimality,
...(profileEvidence === undefined
? {}
: { techniqueProfile: profileEvidence }),
};
}
@@ -716,15 +1088,18 @@ export function generateVariant(
options: GenerateVariantOptions = {},
): GeneratedVariantPuzzle {
const requiredTechnique = options.requiredTechnique;
if (
requiredTechnique !== undefined &&
!(PRACTICE_TECHNIQUES as readonly string[]).includes(requiredTechnique)
) {
throw new RangeError(
`Unsupported practice technique: ${String(requiredTechnique)}.`,
);
}
if (requiredTechnique === "killer-cage" && options.variant !== "killer") {
const profile = normalizeTechniqueProfile(
options.techniqueProfile,
requiredTechnique,
);
const families = canonicalFamilies(options);
const requiresKiller =
profile?.hardestTechnique === "killer-cage" ||
profile?.requirements.some(
({ technique, minimum }) =>
technique === "killer-cage" && (minimum ?? 0) > 0,
) === true;
if (requiresKiller && !families.includes("killer")) {
throw new RangeError(
"Killer-cage practice requires the Killer generator variant.",
);
@@ -732,7 +1107,7 @@ export function generateVariant(
const maxAttempts = boundedInteger(
options.maxTechniqueAttempts,
requiredTechnique === undefined ? 1 : 10,
profile === undefined ? 1 : 10,
1,
32,
"maxTechniqueAttempts",
@@ -742,11 +1117,14 @@ export function generateVariant(
const seed =
attempt === 0
? baseSeed
: `${String(baseSeed)}:practice:${String(requiredTechnique)}:${String(attempt + 1)}`;
const generated = generateVariantAttempt({ ...options, seed });
: options.techniqueProfile === undefined &&
requiredTechnique !== undefined
? `${String(baseSeed)}:practice:${String(requiredTechnique)}:${String(attempt + 1)}`
: `${String(baseSeed)}:profile:${String(attempt + 1)}`;
const generated = generateVariantAttempt({ ...options, seed }, profile);
if (
requiredTechnique === undefined ||
(generated.difficulty.techniqueCounts[requiredTechnique] ?? 0) > 0
profile === undefined ||
generated.techniqueProfile?.status === "matched"
) {
return {
...generated,
@@ -759,6 +1137,80 @@ export function generateVariant(
}
throw new Error(
`No uniquely checked puzzle using ${requiredTechnique?.replaceAll("-", " ")} was found in ${String(maxAttempts)} deterministic attempts. Try a different seed, difficulty profile, or a larger attempt limit.`,
`No uniquely checked puzzle matching the requested technique profile was found in ${String(maxAttempts)} deterministic attempts. Try a different seed, profile, or a larger attempt limit.`,
);
}
function rankingComparator(ranking: BatchRanking) {
return (
left: GeneratedVariantPuzzle,
right: GeneratedVariantPuzzle,
): number => {
const leftClues = left.puzzle.givens.filter(Boolean).length;
const rightClues = right.puzzle.givens.filter(Boolean).length;
if (ranking === "fewest-givens" && leftClues !== rightClues) {
return leftClues - rightClues;
}
if (ranking === "most-givens" && leftClues !== rightClues) {
return rightClues - leftClues;
}
const scoreDifference =
(right.difficulty.score ?? -1) - (left.difficulty.score ?? -1);
if (scoreDifference !== 0) return scoreDifference;
if (leftClues !== rightClues) return leftClues - rightClues;
return String(left.seed).localeCompare(String(right.seed));
};
}
/** Generates a deterministic bounded batch and ranks only verified results. */
export function generateVariantBatch(
options: GenerateVariantBatchOptions = {},
): GeneratedVariantBatch {
const batchSize = boundedInteger(options.batchSize, 4, 1, 12, "batchSize");
const ranking = options.ranking ?? "difficulty";
if (
ranking !== "difficulty" &&
ranking !== "fewest-givens" &&
ranking !== "most-givens"
) {
throw new RangeError(`Unsupported batch ranking: ${String(ranking)}.`);
}
const baseSeed = options.seed ?? "sudoku-tools";
const entries: GeneratedVariantPuzzle[] = [];
const failures: BatchGenerationFailure[] = [];
for (let index = 0; index < batchSize; index += 1) {
const seed = `${String(baseSeed)}:batch:${String(index + 1)}`;
try {
entries.push(generateVariant({ ...options, seed }));
} catch (error) {
failures.push({
seed,
message: error instanceof Error ? error.message : "Generation failed.",
});
}
}
entries.sort(rankingComparator(ranking));
const summaries = entries.map((entry, index): GeneratedPuzzleSummary => ({
rank: index + 1,
seed: entry.seed,
families: entry.families,
clueCount: entry.puzzle.givens.filter(Boolean).length,
constraintCount: entry.generatedConstraintCount,
score: entry.difficulty.score,
level: entry.difficulty.level,
minimalityStatus: entry.minimality.status,
...(entry.techniqueProfile === undefined
? {}
: { profileStatus: entry.techniqueProfile.status }),
}));
return {
entries,
summaries,
failures,
requested: batchSize,
completed: entries.length,
truncated: failures.length > 0,
ranking,
baseSeed,
};
}
+6 -1
View File
@@ -1,4 +1,5 @@
import { maskValues, symbolFor, type EntryMode } from "./session";
import { colorMarkDescription } from "./uiPreferences";
export const AID_MEMOIRE_VERSION = 1 as const;
export const MAX_AID_MEMOIRE_CELLS = 36;
@@ -260,7 +261,11 @@ export function aidMemoireCellDescription(
`centre marks ${center.map((value) => symbolFor(value, size)).join(", ")}`,
);
}
if (cell.color) parts.push(`colour ${String(cell.color)}`);
if (cell.color) {
parts.push(
`colour ${String(cell.color)}: ${colorMarkDescription(cell.color)}`,
);
}
return parts.join(", ");
}
+213
View File
@@ -0,0 +1,213 @@
import {
allCandidates,
type CompiledPuzzle,
type NormalizedPuzzle,
type PuzzleDefinition,
} from "../domain";
import type { LogicalStep } from "../solver/logical";
import type { PlaySession } from "./session";
export type CandidatePuzzle =
PuzzleDefinition | NormalizedPuzzle | CompiledPuzzle;
export interface NoteMaintenanceOptions {
/** Maintain small/corner pencil marks. Defaults to true. */
readonly cornerMarks?: boolean;
/** Maintain central candidate marks. Defaults to true. */
readonly centerMarks?: boolean;
}
function sizeOf(puzzle: CandidatePuzzle): number {
return "puzzle" in puzzle ? puzzle.puzzle.size : puzzle.size;
}
function cloneSession(session: PlaySession): PlaySession {
return {
...session,
values: [...session.values],
cornerMarks: [...session.cornerMarks],
centerMarks: [...session.centerMarks],
colors: [...session.colors],
};
}
function assertSessionShape(
session: PlaySession,
puzzle: CandidatePuzzle,
label = "session",
): number {
const size = sizeOf(puzzle);
const cells = size * size;
for (const [name, values] of [
["values", session.values],
["cornerMarks", session.cornerMarks],
["centerMarks", session.centerMarks],
["colors", session.colors],
] as const) {
if (values.length !== cells) {
throw new RangeError(
`${label}.${name} must contain exactly ${String(cells)} entries`,
);
}
}
return size;
}
function valuesMask(values: readonly number[], size: number): number {
let mask = 0;
for (const value of values) {
if (Number.isInteger(value) && value >= 1 && value <= size) {
mask |= 1 << (value - 1);
}
}
return mask;
}
function legalMasks(
session: PlaySession,
puzzle: CandidatePuzzle,
size: number,
): number[] {
return allCandidates(puzzle, session.values).map((values) =>
valuesMask(values, size),
);
}
function pruneWithOptions(
session: PlaySession,
puzzle: CandidatePuzzle,
options: NoteMaintenanceOptions,
): PlaySession {
const size = assertSessionShape(session, puzzle);
const allowed = legalMasks(session, puzzle, size);
const next = cloneSession(session);
if (options.cornerMarks !== false) {
next.cornerMarks = next.cornerMarks.map(
(mask, cell) => mask & (allowed[cell] ?? 0),
);
}
if (options.centerMarks !== false) {
next.centerMarks = next.centerMarks.map(
(mask, cell) => mask & (allowed[cell] ?? 0),
);
}
return next;
}
/**
* Replace every center-mark set with the candidates that are legal in the
* current position. Filled cells receive no center marks. Other session state,
* including deliberately entered corner marks, is retained unchanged.
*/
export function fillLegalCenterCandidates(
session: PlaySession,
puzzle: CandidatePuzzle,
): PlaySession {
const size = assertSessionShape(session, puzzle);
const next = cloneSession(session);
next.centerMarks = legalMasks(session, puzzle, size);
return next;
}
/**
* Remove notes that cannot currently be placed according to all active Sudoku
* constraints. This never adds a note; use fillLegalCenterCandidates when a
* complete center-candidate grid is desired.
*/
export function pruneInvalidNotes(
session: PlaySession,
puzzle: CandidatePuzzle,
options: NoteMaintenanceOptions = {},
): PlaySession {
return pruneWithOptions(session, puzzle, options);
}
/**
* Prune selected note kinds after one or more values have just been placed.
* Erasures alone do not add candidates or otherwise rewrite notes. Both input
* sessions remain untouched, and the returned session is detached from them.
*/
export function autoRemoveNotesAfterPlacements(
previous: PlaySession,
nextSession: PlaySession,
puzzle: CandidatePuzzle,
options: NoteMaintenanceOptions = {},
): PlaySession {
assertSessionShape(previous, puzzle, "previous");
assertSessionShape(nextSession, puzzle, "nextSession");
const hasPlacement = nextSession.values.some(
(value, cell) => value !== 0 && value !== previous.values[cell],
);
return hasPlacement
? pruneWithOptions(nextSession, puzzle, options)
: cloneSession(nextSession);
}
function assertCell(cell: number, cells: number, path: string): void {
if (!Number.isInteger(cell) || cell < 0 || cell >= cells) {
throw new RangeError(
`${path} must identify a cell from 0 to ${String(cells - 1)}`,
);
}
}
/**
* Apply a solver-produced logical step as one immutable session transition.
* Placements update values and clear notes in the placed cells. Existing center
* candidates are pruned after placements, then the step's explicit candidate
* eliminations are removed. Empty center-mark sets stay empty: callers that
* want a complete tracked candidate grid should call fillLegalCenterCandidates
* before applying the first elimination. Corner marks outside placed cells are
* intentionally left alone.
*/
export function applyLogicalStepToSession(
session: PlaySession,
step: LogicalStep,
puzzle: CandidatePuzzle,
): PlaySession {
const size = assertSessionShape(session, puzzle);
const cells = size * size;
const next = cloneSession(session);
for (const [index, placement] of step.placements.entries()) {
assertCell(placement.cell, cells, `placements[${String(index)}].cell`);
if (
!Number.isInteger(placement.value) ||
placement.value < 1 ||
placement.value > size
) {
throw new RangeError(
`placements[${String(index)}].value must be from 1 to ${String(size)}`,
);
}
if (next.values[placement.cell] !== 0) {
throw new RangeError(
`placements[${String(index)}] targets a filled cell`,
);
}
next.values[placement.cell] = placement.value;
next.cornerMarks[placement.cell] = 0;
next.centerMarks[placement.cell] = 0;
}
const maintained = autoRemoveNotesAfterPlacements(session, next, puzzle, {
cornerMarks: false,
centerMarks: true,
});
for (const [index, elimination] of step.eliminations.entries()) {
assertCell(elimination.cell, cells, `eliminations[${String(index)}].cell`);
let mask = maintained.centerMarks[elimination.cell] ?? 0;
for (const value of elimination.values) {
if (!Number.isInteger(value) || value < 1 || value > size) {
throw new RangeError(
`eliminations[${String(index)}].values must be from 1 to ${String(size)}`,
);
}
mask &= ~(1 << (value - 1));
}
maintained.centerMarks[elimination.cell] = mask;
}
return maintained;
}
+282
View File
@@ -4,6 +4,8 @@ import {
type PlaySnapshot,
} from "./session";
import {
aidMemoireFromPortable,
aidMemoireToPortable,
cloneAidMemoire,
createAidMemoire,
type AidMemoireState,
@@ -57,6 +59,286 @@ export interface HistoryTransition {
export const MAIN_BRANCH_ID = "main";
export const MAX_GAMEPLAY_MOMENTS = 500;
export const MAX_GAMEPLAY_HISTORY_BYTES = 1_048_576;
const GAMEPLAY_HISTORY_SCHEMA =
"de.add-ideas.sudoku-tools.gameplay-history" as const;
function historyRecord(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new TypeError(`${label} must be an object.`);
}
return value as Record<string, unknown>;
}
function historyText(value: unknown, label: string, maximum = 128): string {
if (
typeof value !== "string" ||
value.length === 0 ||
value.length > maximum
) {
throw new TypeError(`${label} must be non-empty bounded text.`);
}
return value;
}
function historyInteger(
value: unknown,
label: string,
minimum: number,
maximum: number,
): number {
if (
!Number.isInteger(value) ||
(value as number) < minimum ||
(value as number) > maximum
) {
throw new TypeError(
`${label} must be an integer from ${String(minimum)} to ${String(maximum)}.`,
);
}
return value as number;
}
function parseHistoryState(value: unknown, size: number): GameplayState {
const record = historyRecord(value, "A gameplay state");
const count = size * size;
const numbers = (
input: unknown,
label: string,
minimum: number,
maximum: number,
): number[] => {
if (!Array.isArray(input) || input.length !== count) {
throw new TypeError(
`${label} must contain exactly ${String(count)} values.`,
);
}
return input.map((entry, index) =>
historyInteger(entry, `${label}[${String(index)}]`, minimum, maximum),
);
};
const maximumMask = 2 ** size - 1;
const parsed: GameplayState = {
values: numbers(record.values, "state.values", 0, size),
cornerMarks: numbers(
record.cornerMarks,
"state.cornerMarks",
0,
maximumMask,
),
centerMarks: numbers(
record.centerMarks,
"state.centerMarks",
0,
maximumMask,
),
colors: numbers(record.colors, "state.colors", 0, 8),
elapsedSeconds: historyInteger(
record.elapsedSeconds,
"state.elapsedSeconds",
0,
31_536_000,
),
...(record.aidMemoire === undefined
? {}
: { aidMemoire: aidMemoireFromPortable(record.aidMemoire, size) }),
};
return parsed;
}
function portableHistoryState(state: GameplayState, size: number) {
return {
values: [...state.values],
cornerMarks: [...state.cornerMarks],
centerMarks: [...state.centerMarks],
colors: [...state.colors],
elapsedSeconds: state.elapsedSeconds,
...(state.aidMemoire === undefined
? {}
: { aidMemoire: aidMemoireToPortable(state.aidMemoire, size) }),
};
}
/** Serialize bounded replay/savepoint history for durable project storage. */
export function serializeGameplayHistory(
history: GameplayHistory,
size: number,
): string {
const payload = JSON.stringify({
schema: GAMEPLAY_HISTORY_SCHEMA,
version: 1,
history: {
...history,
moments: history.moments.map((moment) => ({
...moment,
state: portableHistoryState(moment.state, size),
})),
branches: history.branches.map((branch) => ({
...branch,
baseState: portableHistoryState(branch.baseState, size),
})),
savepoints: history.savepoints.map((savepoint) => ({
...savepoint,
state: portableHistoryState(savepoint.state, size),
})),
},
});
if (
new TextEncoder().encode(payload).byteLength > MAX_GAMEPLAY_HISTORY_BYTES
) {
throw new RangeError("Gameplay history is too large to persist safely.");
}
// Parsing here gives callers one canonical validation boundary even when a
// history object was assembled outside the normal reducers.
void parseGameplayHistory(payload, size);
return payload;
}
/** Parse an untrusted persisted gameplay history into independent state. */
export function parseGameplayHistory(
input: string,
size: number,
): GameplayHistory {
if (new TextEncoder().encode(input).byteLength > MAX_GAMEPLAY_HISTORY_BYTES) {
throw new RangeError("Gameplay history is too large to open safely.");
}
let decoded: unknown;
try {
decoded = JSON.parse(input) as unknown;
} catch (error) {
throw new TypeError("Gameplay history is not valid JSON.", {
cause: error,
});
}
const envelope = historyRecord(decoded, "Gameplay history");
if (envelope.schema !== GAMEPLAY_HISTORY_SCHEMA || envelope.version !== 1) {
throw new TypeError("Unsupported gameplay-history version.");
}
const raw = historyRecord(envelope.history, "Gameplay history payload");
if (
!Array.isArray(raw.moments) ||
raw.moments.length < 1 ||
raw.moments.length > MAX_GAMEPLAY_MOMENTS ||
!Array.isArray(raw.branches) ||
raw.branches.length > MAX_GAMEPLAY_MOMENTS ||
!Array.isArray(raw.savepoints) ||
raw.savepoints.length > MAX_GAMEPLAY_MOMENTS
) {
throw new TypeError(
"Gameplay history collections are invalid or too large.",
);
}
const moments = raw.moments.map((value, index): GameplayMoment => {
const item = historyRecord(value, `moments[${String(index)}]`);
return {
id: historyText(item.id, `moments[${String(index)}].id`),
sequence: historyInteger(
item.sequence,
`moments[${String(index)}].sequence`,
0,
1_000_000,
),
branchId: historyText(
item.branchId,
`moments[${String(index)}].branchId`,
),
label: historyText(item.label, `moments[${String(index)}].label`, 200),
state: parseHistoryState(item.state, size),
};
});
const branches = raw.branches.map((value, index): HypothesisBranch => {
const item = historyRecord(value, `branches[${String(index)}]`);
if (
item.status !== "active" &&
item.status !== "kept" &&
item.status !== "discarded"
) {
throw new TypeError(`branches[${String(index)}].status is invalid.`);
}
return {
id: historyText(item.id, `branches[${String(index)}].id`),
name: historyText(item.name, `branches[${String(index)}].name`, 80),
parentBranchId: historyText(
item.parentBranchId,
`branches[${String(index)}].parentBranchId`,
),
baseMomentId: historyText(
item.baseMomentId,
`branches[${String(index)}].baseMomentId`,
),
baseState: parseHistoryState(item.baseState, size),
status: item.status,
};
});
const savepoints = raw.savepoints.map((value, index): NamedSavepoint => {
const item = historyRecord(value, `savepoints[${String(index)}]`);
return {
id: historyText(item.id, `savepoints[${String(index)}].id`),
name: historyText(item.name, `savepoints[${String(index)}].name`, 80),
momentId: historyText(
item.momentId,
`savepoints[${String(index)}].momentId`,
),
branchId: historyText(
item.branchId,
`savepoints[${String(index)}].branchId`,
),
state: parseHistoryState(item.state, size),
};
});
const unique = (values: readonly string[], label: string): void => {
if (new Set(values).size !== values.length) {
throw new TypeError(`${label} contains duplicate IDs.`);
}
};
unique(
moments.map(({ id }) => id),
"Gameplay moments",
);
unique(
branches.map(({ id }) => id),
"Gameplay branches",
);
unique(
savepoints.map(({ id }) => id),
"Gameplay savepoints",
);
const momentIds = new Set(moments.map(({ id }) => id));
const branchIds = new Set([MAIN_BRANCH_ID, ...branches.map(({ id }) => id)]);
const activeBranchId = historyText(raw.activeBranchId, "activeBranchId");
const currentMomentId = historyText(raw.currentMomentId, "currentMomentId");
if (!branchIds.has(activeBranchId) || !momentIds.has(currentMomentId)) {
throw new TypeError(
"Gameplay history points to a missing active branch or moment.",
);
}
if (
moments.some(({ branchId }) => !branchIds.has(branchId)) ||
branches.some(
({ parentBranchId, baseMomentId }) =>
!branchIds.has(parentBranchId) || !momentIds.has(baseMomentId),
) ||
savepoints.some(
({ momentId, branchId }) =>
!momentIds.has(momentId) || !branchIds.has(branchId),
)
) {
throw new TypeError("Gameplay history contains a dangling reference.");
}
return {
moments,
branches,
savepoints,
activeBranchId,
currentMomentId,
nextSequence: historyInteger(
raw.nextSequence,
"nextSequence",
1,
1_000_001,
),
};
}
function cloneState(state: GameplayState): GameplayState {
return {
+70
View File
@@ -0,0 +1,70 @@
export type CandidateVerbosity = "off" | "concise" | "detailed";
export const BOARD_SCALE_MIN = 0.75;
export const BOARD_SCALE_MAX = 2;
export const BOARD_SCALE_STEP = 0.25;
export const BOARD_SCALE_STORAGE_KEY = "sudoku-tools:board-scale:v1";
export const COLOR_MARK_DESCRIPTIONS = [
"red, diagonal stripes",
"orange, reverse diagonal stripes",
"yellow, dots",
"green, crosshatch",
"teal, horizontal bars",
"blue, vertical bars",
"purple, checkerboard",
"pink, rings",
] as const;
export function normalizeBoardScale(value: unknown): number {
const numeric = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numeric)) return 1;
const clamped = Math.min(BOARD_SCALE_MAX, Math.max(BOARD_SCALE_MIN, numeric));
return Math.round(clamped / BOARD_SCALE_STEP) * BOARD_SCALE_STEP;
}
function browserStorage(): Storage | undefined {
if (typeof window === "undefined") return undefined;
try {
return window.localStorage;
} catch {
return undefined;
}
}
export function readStoredBoardScale(
storage: Pick<Storage, "getItem"> | undefined = browserStorage(),
): number {
if (storage === undefined) return 1;
try {
const value = storage.getItem(BOARD_SCALE_STORAGE_KEY);
return value === null ? 1 : normalizeBoardScale(value);
} catch {
return 1;
}
}
export function writeStoredBoardScale(
scale: number,
storage: Pick<Storage, "setItem"> | undefined = browserStorage(),
): void {
if (storage === undefined) return;
try {
storage.setItem(
BOARD_SCALE_STORAGE_KEY,
String(normalizeBoardScale(scale)),
);
} catch {
// A blocked or full storage area must not make the local board unusable.
}
}
export function parseCandidateVerbosity(value: unknown): CandidateVerbosity {
return value === "off" || value === "concise" || value === "detailed"
? value
: "detailed";
}
export function colorMarkDescription(value: number): string {
return COLOR_MARK_DESCRIPTIONS[value - 1] ?? `mark ${String(value)}`;
}
+171 -5
View File
@@ -1,19 +1,23 @@
import { SudokuFormatError } from "../formats";
import {
cloneProjectRecord,
createProjectRecord,
MAX_PROJECT_BYTES,
normalizeProjectRecord,
} from "./record";
import type {
ProjectLibraryExport,
ProjectLibraryQuery,
SudokuProjectRecord,
SudokuProjectSummary,
} from "./types";
export const MAX_LIBRARY_PROJECTS = 256;
export const MAX_MEMORY_LIBRARY_BYTES = 32 * 1_048_576;
const DATABASE_VERSION = 1;
const DATABASE_VERSION = 2;
const STORE_NAME = "projects";
const AUTOSAVE_STORE_NAME = "autosaves";
const DEFAULT_AUTOSAVE_SLOT = "current";
export type ProjectLibraryMode = "indexeddb" | "memory";
@@ -30,9 +34,34 @@ function summary(record: SudokuProjectRecord): SudokuProjectSummary {
updatedAt: record.updatedAt,
size: record.puzzle.size,
completed: record.progress?.completed ?? false,
tags: [...(record.tags ?? [])],
thumbnail:
record.thumbnail ??
record.puzzle.givens
.map((value) => (value === 0 ? "." : String(value)))
.join(""),
};
}
function matchesQuery(
item: SudokuProjectSummary,
query: ProjectLibraryQuery,
): boolean {
const search = query.search?.trim().toLocaleLowerCase();
if (
search &&
!item.title.toLocaleLowerCase().includes(search) &&
!item.tags.some((tag) => tag.toLocaleLowerCase().includes(search))
) {
return false;
}
const tags = query.tags?.filter(Boolean) ?? [];
if (tags.length > 0 && !tags.every((tag) => item.tags.includes(tag))) {
return false;
}
return query.completed === undefined || item.completed === query.completed;
}
function bytes(record: SudokuProjectRecord): number {
return new TextEncoder().encode(JSON.stringify(record)).byteLength;
}
@@ -63,10 +92,21 @@ async function openDatabase(
const request = factory.open(name, DATABASE_VERSION);
request.onupgradeneeded = () => {
const database = request.result;
if (!database.objectStoreNames.contains(STORE_NAME)) {
const store = database.createObjectStore(STORE_NAME, { keyPath: "id" });
const store = database.objectStoreNames.contains(STORE_NAME)
? request.transaction!.objectStore(STORE_NAME)
: database.createObjectStore(STORE_NAME, { keyPath: "id" });
if (!store.indexNames.contains("updatedAt")) {
store.createIndex("updatedAt", "updatedAt");
}
if (!store.indexNames.contains("title")) {
store.createIndex("title", "title");
}
if (!store.indexNames.contains("tags")) {
store.createIndex("tags", "tags", { multiEntry: true });
}
if (!database.objectStoreNames.contains(AUTOSAVE_STORE_NAME)) {
database.createObjectStore(AUTOSAVE_STORE_NAME, { keyPath: "slot" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () =>
@@ -80,6 +120,7 @@ export class ProjectLibrary {
readonly #factory: IDBFactory | null;
readonly #databaseName: string;
readonly #memory = new Map<string, SudokuProjectRecord>();
readonly #memoryAutosaves = new Map<string, SudokuProjectRecord>();
#database: Promise<IDBDatabase> | undefined;
#mode: ProjectLibraryMode;
@@ -143,11 +184,14 @@ export class ProjectLibrary {
this.#memory.set(record.id, cloneProjectRecord(record));
}
async list(): Promise<readonly SudokuProjectSummary[]> {
async list(
query: ProjectLibraryQuery = {},
): Promise<readonly SudokuProjectSummary[]> {
const database = await this.#db();
if (database === undefined) {
return [...this.#memory.values()]
.map(summary)
.filter((item) => matchesQuery(item, query))
.sort(
(a, b) => b.updatedAt - a.updatedAt || a.title.localeCompare(b.title),
);
@@ -166,13 +210,14 @@ export class ProjectLibrary {
}
return records
.map((record) => summary(normalizeProjectRecord(record)))
.filter((item) => matchesQuery(item, query))
.sort(
(a, b) => b.updatedAt - a.updatedAt || a.title.localeCompare(b.title),
);
} catch (error) {
if (error instanceof SudokuFormatError) throw error;
this.#mode = "memory";
return this.list();
return this.list(query);
}
}
@@ -264,6 +309,82 @@ export class ProjectLibrary {
}
}
/** Persist the latest working state separately from explicit Library saves. */
async putAutosave(
value: SudokuProjectRecord,
slot = DEFAULT_AUTOSAVE_SLOT,
): Promise<SudokuProjectRecord> {
if (!slot || slot.length > 128) {
throw new SudokuFormatError(
"INVALID_PROJECT",
"Autosave slot is invalid.",
);
}
const record = normalizeProjectRecord(value);
const database = await this.#db();
if (database === undefined) {
this.#memoryAutosaves.set(slot, cloneProjectRecord(record));
return cloneProjectRecord(record);
}
try {
const transaction = database.transaction(
AUTOSAVE_STORE_NAME,
"readwrite",
);
transaction.objectStore(AUTOSAVE_STORE_NAME).put({ slot, record });
await transactionDone(transaction);
return cloneProjectRecord(record);
} catch {
this.#mode = "memory";
this.#memoryAutosaves.set(slot, cloneProjectRecord(record));
return cloneProjectRecord(record);
}
}
async getAutosave(
slot = DEFAULT_AUTOSAVE_SLOT,
): Promise<SudokuProjectRecord | undefined> {
const database = await this.#db();
if (database === undefined) {
const record = this.#memoryAutosaves.get(slot);
return record === undefined ? undefined : cloneProjectRecord(record);
}
try {
const transaction = database.transaction(AUTOSAVE_STORE_NAME, "readonly");
const value = await requestResult(
transaction.objectStore(AUTOSAVE_STORE_NAME).get(slot),
);
await transactionDone(transaction);
if (typeof value !== "object" || value === null || !("record" in value)) {
return undefined;
}
return normalizeProjectRecord((value as { record: unknown }).record);
} catch (error) {
if (error instanceof SudokuFormatError) throw error;
this.#mode = "memory";
return this.getAutosave(slot);
}
}
async clearAutosave(slot = DEFAULT_AUTOSAVE_SLOT): Promise<void> {
const database = await this.#db();
if (database === undefined) {
this.#memoryAutosaves.delete(slot);
return;
}
try {
const transaction = database.transaction(
AUTOSAVE_STORE_NAME,
"readwrite",
);
transaction.objectStore(AUTOSAVE_STORE_NAME).delete(slot);
await transactionDone(transaction);
} catch {
this.#mode = "memory";
this.#memoryAutosaves.delete(slot);
}
}
async exportAll(): Promise<ProjectLibraryExport> {
const summaries = await this.list();
const projects: SudokuProjectRecord[] = [];
@@ -279,6 +400,51 @@ export class ProjectLibrary {
};
}
async exportSelected(ids: readonly string[]): Promise<ProjectLibraryExport> {
const unique = [...new Set(ids)].slice(0, MAX_LIBRARY_PROJECTS);
const projects: SudokuProjectRecord[] = [];
for (const id of unique) {
const record = await this.get(id);
if (record !== undefined) projects.push(record);
}
return {
schema: "de.add-ideas.sudoku-tools.library",
version: 1,
exportedAt: Date.now(),
projects,
};
}
/** Create independent local copies without reusing source record IDs. */
async duplicateSelected(ids: readonly string[]): Promise<number> {
const unique = [...new Set(ids)].slice(0, MAX_LIBRARY_PROJECTS);
const sources: SudokuProjectRecord[] = [];
for (const id of unique) {
const source = await this.get(id);
if (source !== undefined) sources.push(source);
}
if ((await this.list()).length + sources.length > MAX_LIBRARY_PROJECTS) {
throw new SudokuFormatError(
"STORAGE_LIMIT",
`Copying this selection would exceed the ${String(MAX_LIBRARY_PROJECTS)}-project limit.`,
);
}
let copied = 0;
for (const source of sources) {
const now = Date.now() + copied;
await this.put(
createProjectRecord(source.puzzle, {
title: `${source.title || "Untitled puzzle"} copy`,
progress: source.progress,
tags: source.tags,
now,
}),
);
copied += 1;
}
return copied;
}
async importAll(value: unknown, replace = false): Promise<number> {
if (
typeof value !== "object" ||
+88
View File
@@ -8,6 +8,8 @@ import {
normalizePortableAidMemoire,
type PortableAidMemoire,
} from "../state/aidMemoire";
import { parseGameplayHistory } from "../state/playHistory";
import { symbolFor } from "../state/session";
import {
PROJECT_RECORD_SCHEMA,
PROJECT_RECORD_VERSION,
@@ -18,6 +20,8 @@ import {
export const MAX_PROJECT_BYTES = MAX_DOCUMENT_BYTES * 2;
export const MAX_PROJECT_ID_LENGTH = 128;
export const MAX_PROJECT_TITLE_LENGTH = 500;
export const MAX_PROJECT_TAGS = 20;
export const MAX_PROJECT_TAG_LENGTH = 40;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -143,6 +147,26 @@ function progress(value: unknown, size: number): SudokuProgress | undefined {
);
}
}
let gameplayHistory: string | undefined;
if (value.gameplayHistory !== undefined) {
if (typeof value.gameplayHistory !== "string") {
return storageError(
"INVALID_PROJECT",
"Progress gameplayHistory must be serialized text.",
);
}
try {
void parseGameplayHistory(value.gameplayHistory, size);
gameplayHistory = value.gameplayHistory;
} catch (error) {
return storageError(
"INVALID_PROJECT",
error instanceof Error
? error.message
: "Progress gameplay history is invalid.",
);
}
}
return {
version: 1,
values,
@@ -155,9 +179,57 @@ function progress(value: unknown, size: number): SudokuProgress | undefined {
: { elapsedMs: value.elapsedMs as number }),
...(value.completed === undefined ? {} : { completed: value.completed }),
...(aidMemoire === undefined ? {} : { aidMemoire }),
...(gameplayHistory === undefined ? {} : { gameplayHistory }),
};
}
function projectTags(value: unknown): string[] | undefined {
if (value === undefined) return undefined;
if (!Array.isArray(value) || value.length > MAX_PROJECT_TAGS) {
return storageError(
"INVALID_PROJECT",
`Project tags must contain at most ${MAX_PROJECT_TAGS} entries.`,
);
}
const tags = value.map((tag, index) => {
if (
typeof tag !== "string" ||
tag.trim().length === 0 ||
tag.length > MAX_PROJECT_TAG_LENGTH
) {
return storageError(
"INVALID_PROJECT",
`Project tag ${String(index + 1)} is empty or too long.`,
);
}
return tag.trim();
});
return [...new Set(tags)].sort((a, b) => a.localeCompare(b));
}
function projectThumbnail(
value: unknown,
givens: readonly number[],
size: number,
) {
if (value === undefined) {
return givens
.map((digit) => (digit === 0 ? "." : symbolFor(digit, size)))
.join("");
}
if (
typeof value !== "string" ||
[...value].length !== size * size ||
!/^[.1-9A-G]+$/u.test(value)
) {
return storageError(
"INVALID_PROJECT",
"Project thumbnail must be a safe row-major grid preview.",
);
}
return value;
}
export function normalizeProjectRecord(value: unknown): SudokuProjectRecord {
if (!isRecord(value))
return storageError("INVALID_PROJECT", "A project must be an object.");
@@ -199,6 +271,12 @@ export function normalizeProjectRecord(value: unknown): SudokuProjectRecord {
);
}
const puzzle = normalizeSudokuDocument(value.puzzle);
const tags = projectTags(value.tags);
const thumbnail = projectThumbnail(
value.thumbnail,
puzzle.givens,
puzzle.size,
);
const normalized: SudokuProjectRecord = {
schema: PROJECT_RECORD_SCHEMA,
version: PROJECT_RECORD_VERSION,
@@ -210,6 +288,8 @@ export function normalizeProjectRecord(value: unknown): SudokuProjectRecord {
...(value.progress === undefined
? {}
: { progress: progress(value.progress, puzzle.size) }),
...(tags === undefined ? {} : { tags }),
thumbnail,
};
const bytes = new TextEncoder().encode(JSON.stringify(normalized)).byteLength;
if (bytes > MAX_PROJECT_BYTES) {
@@ -226,6 +306,7 @@ export interface NewProjectOptions {
readonly title?: string;
readonly now?: number;
readonly progress?: SudokuProgress;
readonly tags?: readonly string[];
}
function randomId(): string {
@@ -252,6 +333,7 @@ export function createProjectRecord(
updatedAt: now,
puzzle,
...(options.progress === undefined ? {} : { progress: options.progress }),
...(options.tags === undefined ? {} : { tags: options.tags }),
});
}
@@ -262,6 +344,7 @@ export function cloneProjectRecord(
return {
...normalized,
puzzle: cloneSudokuDocument(normalized.puzzle),
...(normalized.tags === undefined ? {} : { tags: [...normalized.tags] }),
...(normalized.progress === undefined
? {}
: {
@@ -300,6 +383,11 @@ export function cloneProjectRecord(
normalized.puzzle.size,
),
}),
...(normalized.progress.gameplayHistory === undefined
? {}
: {
gameplayHistory: normalized.progress.gameplayHistory,
}),
},
}),
};
+13
View File
@@ -15,6 +15,8 @@ export interface SudokuProgress {
readonly elapsedMs?: number;
readonly completed?: boolean;
readonly aidMemoire?: PortableAidMemoire;
/** Canonical, bounded replay/savepoint history JSON. */
readonly gameplayHistory?: string;
}
export interface SudokuProjectRecord {
@@ -26,6 +28,9 @@ export interface SudokuProjectRecord {
readonly updatedAt: number;
readonly puzzle: SudokuDocument;
readonly progress?: SudokuProgress;
readonly tags?: readonly string[];
/** Compact row-major symbols used for a script-free Library preview. */
readonly thumbnail?: string;
}
export interface SudokuProjectSummary {
@@ -35,6 +40,14 @@ export interface SudokuProjectSummary {
readonly updatedAt: number;
readonly size: number;
readonly completed: boolean;
readonly tags: readonly string[];
readonly thumbnail: string;
}
export interface ProjectLibraryQuery {
readonly search?: string;
readonly tags?: readonly string[];
readonly completed?: boolean;
}
export interface ProjectLibraryExport {
+1272 -13
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.sudoku-tools",
"name": "Sudoku Tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
+1 -1
View File
@@ -1 +1 @@
export const APPLICATION_VERSION = "0.1.0";
export const APPLICATION_VERSION = "0.2.0";
+15
View File
@@ -6,12 +6,16 @@ import type {
DifficultyOptions,
GenerateClassicOptions,
GeneratedVariantPuzzle,
GeneratedVariantBatch,
GenerateVariantBatchOptions,
GenerateVariantOptions,
KillerCombinationOptions,
KillerCombinationResult,
LogicalSolveOptions,
LogicalSolveResult,
MinimizeOptions,
PuzzleQualityAnalysis,
PuzzleQualityOptions,
} from "../solver";
import type { NormalizedPuzzle, ValidationIssue } from "../domain";
@@ -31,11 +35,20 @@ export type SolverWorkerOperation =
readonly kind: "generate-variant";
readonly options?: GenerateVariantOptions;
}
| {
readonly kind: "generate-batch";
readonly options?: GenerateVariantBatchOptions;
}
| {
readonly kind: "difficulty";
readonly puzzle: PuzzleDefinition;
readonly options?: DifficultyOptions;
}
| {
readonly kind: "quality";
readonly puzzle: PuzzleDefinition;
readonly options?: PuzzleQualityOptions;
}
| {
readonly kind: "minimize";
readonly puzzle: PuzzleDefinition;
@@ -56,7 +69,9 @@ export type SolverWorkerValue =
| LogicalSolveResult
| NormalizedPuzzle
| GeneratedVariantPuzzle
| GeneratedVariantBatch
| DifficultyAssessment
| PuzzleQualityAnalysis
| KillerCombinationResult;
export interface SolverWorkerError {
+6
View File
@@ -2,9 +2,11 @@
import { PuzzleValidationError } from "../domain";
import {
analyzePuzzleQuality,
evaluateDifficulty,
generateClassic,
generateVariant,
generateVariantBatch,
killerDigitCombinations,
minimizePuzzle,
solveExact,
@@ -29,8 +31,12 @@ function run(request: SolverWorkerRequest): SolverWorkerValue {
return generateClassic(operation.options);
case "generate-variant":
return generateVariant(operation.options);
case "generate-batch":
return generateVariantBatch(operation.options);
case "difficulty":
return evaluateDifficulty(operation.puzzle, operation.options);
case "quality":
return analyzePuzzleQuality(operation.puzzle, operation.options);
case "minimize":
return minimizePuzzle(operation.puzzle, operation.options);
case "killer-combinations":