feat: add gameplay assists and variant generator
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import type { PuzzleDefinition, VariantConstraint } from "../domain/types";
|
||||
import {
|
||||
removeKillerCagesAtCells,
|
||||
replaceOverlappingKillerCages,
|
||||
selectionTouchesKillerCage,
|
||||
} from "./constraintEditing";
|
||||
|
||||
interface ConstraintEditorProps {
|
||||
puzzle: PuzzleDefinition;
|
||||
@@ -34,7 +39,7 @@ function describeConstraint(constraint: VariantConstraint, size: number) {
|
||||
case "kropki":
|
||||
return `${constraint.kind} dot · ${cell(constraint.a)}–${cell(constraint.b)}`;
|
||||
case "xv":
|
||||
return `${constraint.total === 5 ? "V" : "X"} · ${cell(constraint.a)}–${cell(constraint.b)}`;
|
||||
return `sum ${String(constraint.total)} pair · ${cell(constraint.a)}–${cell(constraint.b)}`;
|
||||
case "inequality":
|
||||
return `${cell(constraint.lesser)} < ${cell(constraint.greater)}`;
|
||||
}
|
||||
@@ -57,6 +62,17 @@ export function ConstraintEditor({
|
||||
onChange({ ...puzzle, constraints: [...constraints, constraint] });
|
||||
const need = (count: number) => selection.length === count;
|
||||
const atLeast = (count: number) => selection.length >= count;
|
||||
const cageCellCount = Math.max(1, selection.length);
|
||||
const minimumCageSum = (cageCellCount * (cageCellCount + 1)) / 2;
|
||||
const maximumCageSum =
|
||||
(cageCellCount * (2 * puzzle.size - cageCellCount + 1)) / 2;
|
||||
const validCage =
|
||||
selection.length >= 1 &&
|
||||
selection.length <= puzzle.size &&
|
||||
Number.isInteger(cageSum) &&
|
||||
cageSum >= minimumCageSum &&
|
||||
cageSum <= maximumCageSum;
|
||||
const selectedCageExists = selectionTouchesKillerCage(constraints, selection);
|
||||
|
||||
const toggleGlobal = (
|
||||
type: "anti-knight" | "anti-king" | "non-consecutive",
|
||||
@@ -143,22 +159,46 @@ export function ConstraintEditor({
|
||||
Cage sum
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={puzzle.size * puzzle.size}
|
||||
min={minimumCageSum}
|
||||
max={maximumCageSum}
|
||||
value={cageSum}
|
||||
onChange={(event) => setCageSum(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!atLeast(1) || !Number.isInteger(cageSum)}
|
||||
disabled={!validCage}
|
||||
onClick={() =>
|
||||
append({ type: "killer-cage", cells: selection, sum: cageSum })
|
||||
onChange({
|
||||
...puzzle,
|
||||
constraints: replaceOverlappingKillerCages(constraints, {
|
||||
type: "killer-cage",
|
||||
cells: selection,
|
||||
sum: cageSum,
|
||||
}),
|
||||
})
|
||||
}
|
||||
>
|
||||
Add cage
|
||||
Add / replace cage
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
disabled={!selectedCageExists}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...puzzle,
|
||||
constraints: removeKillerCagesAtCells(constraints, selection),
|
||||
})
|
||||
}
|
||||
>
|
||||
Remove selected cage
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Adding replaces any cage touching the selection. Removing clears every
|
||||
cage touching a selected cell.
|
||||
</p>
|
||||
<div className="button-grid">
|
||||
<button
|
||||
type="button"
|
||||
@@ -234,7 +274,7 @@ export function ConstraintEditor({
|
||||
})
|
||||
}
|
||||
>
|
||||
V pair
|
||||
Sum 5 (XV)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -248,7 +288,7 @@ export function ConstraintEditor({
|
||||
})
|
||||
}
|
||||
>
|
||||
X pair
|
||||
Sum 10 (XV)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -261,7 +301,20 @@ export function ConstraintEditor({
|
||||
})
|
||||
}
|
||||
>
|
||||
First < second
|
||||
1st < 2nd (inequality)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!need(2)}
|
||||
onClick={() =>
|
||||
append({
|
||||
type: "inequality",
|
||||
lesser: selection[1]!,
|
||||
greater: selection[0]!,
|
||||
})
|
||||
}
|
||||
>
|
||||
1st > 2nd (inequality)
|
||||
</button>
|
||||
</div>
|
||||
<div className="inline-fields">
|
||||
@@ -374,12 +427,8 @@ export function ConstraintEditor({
|
||||
<button type="button" onClick={onCheck} disabled={busy}>
|
||||
Check definition & uniqueness
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onGenerate}
|
||||
disabled={busy || puzzle.size > 9}
|
||||
>
|
||||
Generate classic
|
||||
<button type="button" onClick={onGenerate} disabled={busy}>
|
||||
Open generator
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { DigitCompletion } from "../state/gameplayHelpers";
|
||||
import { symbolFor } from "../state/session";
|
||||
|
||||
function completionLabel(item: DigitCompletion, size: number): string {
|
||||
const symbol = symbolFor(item.digit, size);
|
||||
if (item.status === "done")
|
||||
return `Digit ${symbol}: complete, ${String(item.placed)} of ${String(item.target)} placed`;
|
||||
if (item.status === "overdone")
|
||||
return `Digit ${symbol}: overdone by ${String(item.excess)}, ${String(item.placed)} of ${String(item.target)} placed`;
|
||||
return `Digit ${symbol}: ${String(item.remaining)} remaining, ${String(item.placed)} of ${String(item.target)} placed`;
|
||||
}
|
||||
|
||||
export function DigitCompletionBar({
|
||||
size,
|
||||
completions,
|
||||
highlightedDigit,
|
||||
highlightingEnabled,
|
||||
onHighlight,
|
||||
}: {
|
||||
size: number;
|
||||
completions: readonly DigitCompletion[];
|
||||
highlightedDigit: number | null;
|
||||
highlightingEnabled: boolean;
|
||||
onHighlight: (digit: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="digit-completion" aria-label="Digit completion">
|
||||
<div className="digit-completion__heading">
|
||||
<span>Digit progress</span>
|
||||
<small>muted = complete · red = too many</small>
|
||||
</div>
|
||||
<div
|
||||
className={`digit-completion__bar${size > 9 ? " digit-completion__bar--wide" : ""}`}
|
||||
role="group"
|
||||
aria-label="Highlight matching digits"
|
||||
>
|
||||
{completions.map((item) => (
|
||||
<button
|
||||
key={item.digit}
|
||||
type="button"
|
||||
className={`digit-completion__digit is-${item.status}${highlightedDigit === item.digit ? " is-highlighted" : ""}`}
|
||||
aria-label={completionLabel(item, size)}
|
||||
aria-pressed={highlightedDigit === item.digit}
|
||||
disabled={!highlightingEnabled}
|
||||
title={completionLabel(item, size)}
|
||||
onClick={() => onHighlight(item.digit)}
|
||||
>
|
||||
<strong>{symbolFor(item.digit, size)}</strong>
|
||||
<span>
|
||||
{item.placed}/{item.target}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import {
|
||||
GENERATOR_VARIANTS,
|
||||
type DifficultyAssessment,
|
||||
type GeneratedVariantPuzzle,
|
||||
type GenerationDifficultyTarget,
|
||||
type GeneratorVariant,
|
||||
type GenerateVariantOptions,
|
||||
} from "../solver";
|
||||
|
||||
const difficultyTargets: readonly GenerationDifficultyTarget[] = [
|
||||
"beginner",
|
||||
"easy",
|
||||
"medium",
|
||||
"hard",
|
||||
"expert",
|
||||
];
|
||||
|
||||
function techniqueLabel(value: string | undefined): string {
|
||||
return value === undefined
|
||||
? "Direct placements"
|
||||
: value
|
||||
.split("-")
|
||||
.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function GeneratorWorkspace({
|
||||
busy,
|
||||
assessment,
|
||||
generation,
|
||||
onGenerate,
|
||||
onRate,
|
||||
}: {
|
||||
busy: boolean;
|
||||
assessment?: DifficultyAssessment;
|
||||
generation?: GeneratedVariantPuzzle;
|
||||
onGenerate: (options: GenerateVariantOptions) => void;
|
||||
onRate: () => void;
|
||||
}) {
|
||||
const [variant, setVariant] = useState<GeneratorVariant>("classic");
|
||||
const definition = useMemo(
|
||||
() => GENERATOR_VARIANTS.find((item) => item.id === variant)!,
|
||||
[variant],
|
||||
);
|
||||
const [size, setSize] = useState(9);
|
||||
const [targetDifficulty, setTargetDifficulty] =
|
||||
useState<GenerationDifficultyTarget>("medium");
|
||||
const [symmetry, setSymmetry] = useState<"none" | "rotational">("rotational");
|
||||
const [constraintCount, setConstraintCount] = useState(8);
|
||||
const [seed, setSeed] = useState("");
|
||||
const usesMarkingCount = ![
|
||||
"classic",
|
||||
"diagonal",
|
||||
"killer",
|
||||
"anti-knight",
|
||||
"anti-king",
|
||||
"non-consecutive",
|
||||
].includes(variant);
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
onGenerate({
|
||||
variant,
|
||||
size,
|
||||
targetDifficulty,
|
||||
symmetry,
|
||||
...(usesMarkingCount ? { constraintCount } : {}),
|
||||
seed: seed.trim() || `local-${Date.now().toString(36)}`,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="generator-workspace stack">
|
||||
<div>
|
||||
<p className="eyebrow">Bounded local construction</p>
|
||||
<h2>Sudoku generator</h2>
|
||||
<p className="muted">
|
||||
Build a seedable, uniquely checked puzzle in the worker. The requested
|
||||
level guides clue removal; the finished puzzle receives an independent
|
||||
evidence-based rating.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="panel-section generator-form" onSubmit={submit}>
|
||||
<div className="field-grid">
|
||||
<label>
|
||||
Variant
|
||||
<select
|
||||
value={variant}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value as GeneratorVariant;
|
||||
const nextDefinition = GENERATOR_VARIANTS.find(
|
||||
(item) => item.id === next,
|
||||
)!;
|
||||
setVariant(next);
|
||||
if (
|
||||
!(
|
||||
nextDefinition.supportedSizes as readonly number[]
|
||||
).includes(size)
|
||||
) {
|
||||
setSize(nextDefinition.supportedSizes[0]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{GENERATOR_VARIANTS.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Grid
|
||||
<select
|
||||
value={size}
|
||||
onChange={(event) => setSize(Number(event.target.value))}
|
||||
>
|
||||
{definition.supportedSizes.map((supportedSize) => (
|
||||
<option key={supportedSize} value={supportedSize}>
|
||||
{supportedSize} × {supportedSize}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Requested profile
|
||||
<select
|
||||
value={targetDifficulty}
|
||||
onChange={(event) =>
|
||||
setTargetDifficulty(
|
||||
event.target.value as GenerationDifficultyTarget,
|
||||
)
|
||||
}
|
||||
>
|
||||
{difficultyTargets.map((target) => (
|
||||
<option key={target} value={target}>
|
||||
{target[0]!.toUpperCase() + target.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Given symmetry
|
||||
<select
|
||||
value={symmetry}
|
||||
onChange={(event) =>
|
||||
setSymmetry(event.target.value as "none" | "rotational")
|
||||
}
|
||||
>
|
||||
<option value="rotational">Rotational</option>
|
||||
<option value="none">None</option>
|
||||
</select>
|
||||
</label>
|
||||
{usesMarkingCount && (
|
||||
<label>
|
||||
Requested markings
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={size * size * 2}
|
||||
value={constraintCount}
|
||||
onChange={(event) =>
|
||||
setConstraintCount(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
Seed
|
||||
<input
|
||||
value={seed}
|
||||
maxLength={128}
|
||||
placeholder="blank = fresh local seed"
|
||||
onChange={(event) => setSeed(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<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}`}
|
||||
</button>
|
||||
<button type="button" disabled={busy} onClick={onRate}>
|
||||
Rate current puzzle
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Difficulty is an estimate from reproducible solver evidence, not a
|
||||
universal promise. Uniqueness is never claimed after a safety limit.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
{assessment && (
|
||||
<section
|
||||
className="analysis-summary difficulty-card"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Difficulty assessment</p>
|
||||
<h3>{assessment.label}</h3>
|
||||
</div>
|
||||
<span
|
||||
className={`status-pill${assessment.uniqueness === "unique" ? " status-solved" : " status-invalid"}`}
|
||||
>
|
||||
{assessment.uniqueness}
|
||||
</span>
|
||||
</div>
|
||||
<div className="metric-row">
|
||||
<span>
|
||||
Score <strong>{assessment.score ?? "—"}/100</strong>
|
||||
</span>
|
||||
<span>
|
||||
Givens <strong>{assessment.clueCount}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Logical steps <strong>{assessment.logicalSteps}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Hardest{" "}
|
||||
<strong>{techniqueLabel(assessment.hardestTechnique)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Search nodes <strong>{assessment.exactNodes}</strong>
|
||||
</span>
|
||||
{generation && (
|
||||
<span>
|
||||
Markings <strong>{generation.generatedConstraintCount}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p>{assessment.summary}</p>
|
||||
{generation && (
|
||||
<p className="muted">
|
||||
Seed: <code>{String(generation.seed)}</code>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,12 +11,13 @@ export function HelpDialog({
|
||||
<Modal open={open} title="Sudoku Tools help" onClose={onClose} wide>
|
||||
<div className="help-grid">
|
||||
<section>
|
||||
<h3>Four complementary workspaces</h3>
|
||||
<h3>Five complementary workspaces</h3>
|
||||
<p>
|
||||
<strong>Play</strong> keeps values, two kinds of notes, colours,
|
||||
history and elapsed time. <strong>Set</strong> edits clues and
|
||||
constraints. <strong>Solve</strong> explains logical steps and can
|
||||
verify uniqueness. <strong>Helpers</strong> answers focused
|
||||
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
|
||||
questions without changing the board.
|
||||
</p>
|
||||
</section>
|
||||
@@ -47,6 +48,10 @@ export function HelpDialog({
|
||||
<dt>Ctrl/⌘ + Z/Y</dt>
|
||||
<dd>Undo or redo</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Ctrl/⌘ + click</dt>
|
||||
<dd>Highlight every placed copy of that digit</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<section>
|
||||
@@ -59,6 +64,16 @@ export function HelpDialog({
|
||||
explanation.
|
||||
</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. A limit never becomes a false
|
||||
uniqueness claim.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Import and privacy</h3>
|
||||
<p>
|
||||
|
||||
@@ -362,12 +362,16 @@ export function HelpersWorkspace({
|
||||
value={relation}
|
||||
onChange={(event) => setRelation(event.target.value)}
|
||||
>
|
||||
<option value="white">White Kropki · difference 1</option>
|
||||
<option value="black">Black Kropki · ratio 1:2</option>
|
||||
<option value="v">V · sum 5</option>
|
||||
<option value="x">X · sum 10</option>
|
||||
<option value="less">First < second</option>
|
||||
<option value="greater">First > second</option>
|
||||
<option value="white">White dot · difference 1</option>
|
||||
<option value="black">Black dot · ratio 1:2</option>
|
||||
<option value="v">Sum = 5 · XV rule</option>
|
||||
<option value="x">Sum = 10 · XV rule</option>
|
||||
<option value="less">
|
||||
First cell < second cell · inequality
|
||||
</option>
|
||||
<option value="greater">
|
||||
First cell > second cell · inequality
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
|
||||
@@ -10,6 +10,7 @@ interface SudokuBoardProps {
|
||||
colors?: readonly number[];
|
||||
candidates?: readonly number[];
|
||||
selected: ReadonlySet<number>;
|
||||
highlighted?: ReadonlySet<number>;
|
||||
conflicts?: ReadonlySet<number>;
|
||||
activeCell: number;
|
||||
showCandidates?: boolean;
|
||||
@@ -150,25 +151,33 @@ function ConstraintLayer({ puzzle }: { puzzle: NormalizedPuzzle }) {
|
||||
r="0.115"
|
||||
/>
|
||||
);
|
||||
if (constraint.type === "xv")
|
||||
return (
|
||||
<g
|
||||
key={index}
|
||||
className={`constraint-xv constraint-xv--${String(constraint.total)}`}
|
||||
>
|
||||
<circle cx={x} cy={y} r="0.18" />
|
||||
<text x={x} y={y}>
|
||||
{constraint.total}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
const rotation = Math.atan2(b.y - a.y, b.x - a.x) * (180 / Math.PI);
|
||||
return (
|
||||
<text
|
||||
<g
|
||||
key={index}
|
||||
className={`constraint-label constraint-label--${constraint.type}`}
|
||||
x={x}
|
||||
y={y}
|
||||
transform={
|
||||
constraint.type === "inequality"
|
||||
? `rotate(${String(rotation)} ${String(x)} ${String(y)})`
|
||||
: undefined
|
||||
}
|
||||
className="constraint-inequality"
|
||||
transform={`translate(${String(x)} ${String(y)}) rotate(${String(rotation)})`}
|
||||
>
|
||||
{constraint.type === "xv"
|
||||
? constraint.total === 5
|
||||
? "V"
|
||||
: "X"
|
||||
: "<"}
|
||||
</text>
|
||||
<path d="M0.12 -0.17L-0.12 0L0.12 0.17" />
|
||||
<circle
|
||||
className="constraint-inequality-tip"
|
||||
cx="-0.12"
|
||||
cy="0"
|
||||
r="0.035"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -198,6 +207,7 @@ export function SudokuBoard({
|
||||
colors = [],
|
||||
candidates = [],
|
||||
selected,
|
||||
highlighted = new Set<number>(),
|
||||
conflicts = new Set<number>(),
|
||||
activeCell,
|
||||
showCandidates,
|
||||
@@ -237,6 +247,7 @@ export function SudokuBoard({
|
||||
className={[
|
||||
"sudoku-cell",
|
||||
isGiven ? "is-given" : "",
|
||||
highlighted.has(cell) ? "is-digit-highlighted" : "",
|
||||
selected.has(cell) ? "is-selected" : "",
|
||||
conflicts.has(cell) ? "has-conflict" : "",
|
||||
colors[cell] ? `has-color-${String(colors[cell])}` : "",
|
||||
|
||||
+171
-44
@@ -24,8 +24,14 @@ import {
|
||||
toDomainPuzzle,
|
||||
type SudokuDocument,
|
||||
} from "../formats";
|
||||
import { CLASSIC_SAMPLE, KILLER_SAMPLE } from "../data/samples";
|
||||
import type { ExactSolveResult, LogicalSolveResult } from "../solver";
|
||||
import { CLASSIC_SAMPLE, SAMPLE_CATALOG } from "../data/samples";
|
||||
import type {
|
||||
DifficultyAssessment,
|
||||
ExactSolveResult,
|
||||
GeneratedVariantPuzzle,
|
||||
GenerateVariantOptions,
|
||||
LogicalSolveResult,
|
||||
} from "../solver";
|
||||
import {
|
||||
createProjectLibrary,
|
||||
createProjectRecord,
|
||||
@@ -46,8 +52,15 @@ import {
|
||||
type PlaySession,
|
||||
type PlaySnapshot,
|
||||
} from "../state/session";
|
||||
import {
|
||||
digitCompletions,
|
||||
matchingDigitCells,
|
||||
toggledDigitHighlight,
|
||||
} from "../state/gameplayHelpers";
|
||||
import { createSolverWorkerClient, type SolverWorkerClient } from "../workers";
|
||||
import { ConstraintEditor } from "./ConstraintEditor";
|
||||
import { DigitCompletionBar } from "./DigitCompletionBar";
|
||||
import { GeneratorWorkspace } from "./GeneratorWorkspace";
|
||||
import { HelpersWorkspace } from "./HelpersWorkspace";
|
||||
import { ImportExportDialog } from "./ImportExportDialog";
|
||||
import { LibraryDialog } from "./LibraryDialog";
|
||||
@@ -55,7 +68,7 @@ import { NumberPad } from "./NumberPad";
|
||||
import { SolveWorkspace } from "./SolveWorkspace";
|
||||
import { SudokuBoard } from "./SudokuBoard";
|
||||
|
||||
type Workspace = "play" | "set" | "solve" | "helpers";
|
||||
type Workspace = "play" | "set" | "generate" | "solve" | "helpers";
|
||||
type FeedbackKind = "info" | "success" | "error";
|
||||
|
||||
interface Feedback {
|
||||
@@ -231,6 +244,7 @@ function errorMessage(error: unknown): string {
|
||||
}
|
||||
|
||||
function constraintLabel(type: string): string {
|
||||
if (type === "xv") return "Sum pairs (5/10)";
|
||||
return type
|
||||
.split("-")
|
||||
.map((part) => part[0]?.toUpperCase() + part.slice(1))
|
||||
@@ -247,12 +261,17 @@ export function Workbench() {
|
||||
const [entryMode, setEntryMode] = useState<EntryMode>("value");
|
||||
const [showCandidates, setShowCandidates] = useState(false);
|
||||
const [showConflicts, setShowConflicts] = useState(true);
|
||||
const [showDigitCompletion, setShowDigitCompletion] = useState(true);
|
||||
const [enableDigitHighlight, setEnableDigitHighlight] = useState(true);
|
||||
const [highlightedDigit, setHighlightedDigit] = useState<number | null>(null);
|
||||
const [past, setPast] = useState<HistoryEntry[]>([]);
|
||||
const [future, setFuture] = useState<HistoryEntry[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [feedback, setFeedback] = useState<Feedback>();
|
||||
const [logical, setLogical] = useState<LogicalSolveResult>();
|
||||
const [exact, setExact] = useState<ExactSolveResult>();
|
||||
const [difficulty, setDifficulty] = useState<DifficultyAssessment>();
|
||||
const [generation, setGeneration] = useState<GeneratedVariantPuzzle>();
|
||||
const [solveError, setSolveError] = useState<string>();
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [libraryOpen, setLibraryOpen] = useState(false);
|
||||
@@ -270,6 +289,19 @@ export function Workbench() {
|
||||
|
||||
const normalized = useMemo(() => safeNormalize(puzzle), [puzzle]);
|
||||
const selectedSet = useMemo(() => new Set(selection), [selection]);
|
||||
const completionData = useMemo(
|
||||
() => digitCompletions(puzzle.size, session.values),
|
||||
[puzzle.size, session.values],
|
||||
);
|
||||
const highlightedCells = useMemo(
|
||||
() =>
|
||||
highlightedDigit === null
|
||||
? new Set<number>()
|
||||
: new Set(
|
||||
matchingDigitCells(puzzle.size, session.values, highlightedDigit),
|
||||
),
|
||||
[highlightedDigit, puzzle.size, session.values],
|
||||
);
|
||||
const candidateMasks = useMemo(() => {
|
||||
if (normalized.error) return session.values.map(() => 0);
|
||||
try {
|
||||
@@ -307,6 +339,8 @@ export function Workbench() {
|
||||
const clearAnalysis = useCallback(() => {
|
||||
setLogical(undefined);
|
||||
setExact(undefined);
|
||||
setDifficulty(undefined);
|
||||
setGeneration(undefined);
|
||||
setSolveError(undefined);
|
||||
}, []);
|
||||
|
||||
@@ -346,6 +380,7 @@ export function Workbench() {
|
||||
setSession(sessionFromDocument(valid, progress));
|
||||
setSelection([0]);
|
||||
setActiveCell(0);
|
||||
setHighlightedDigit(null);
|
||||
setPast([]);
|
||||
setFuture([]);
|
||||
setCurrentProjectId(projectId);
|
||||
@@ -362,6 +397,7 @@ export function Workbench() {
|
||||
setSession(sessionFromProgress(valid, record.progress));
|
||||
setSelection([0]);
|
||||
setActiveCell(0);
|
||||
setHighlightedDigit(null);
|
||||
setPast([]);
|
||||
setFuture([]);
|
||||
setCurrentProjectId(record.id);
|
||||
@@ -454,11 +490,33 @@ export function Workbench() {
|
||||
if (session.paused) return;
|
||||
event.preventDefault();
|
||||
event.currentTarget.focus();
|
||||
const command = event.ctrlKey || event.metaKey;
|
||||
if (
|
||||
workspace === "play" &&
|
||||
enableDigitHighlight &&
|
||||
command &&
|
||||
session.values[cell]
|
||||
) {
|
||||
draggingRef.current = false;
|
||||
changeSelection(cell, false, false);
|
||||
setHighlightedDigit((current) =>
|
||||
toggledDigitHighlight(puzzle.size, session.values, cell, current),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!command) setHighlightedDigit(null);
|
||||
draggingRef.current = true;
|
||||
const additive = event.shiftKey || event.ctrlKey || event.metaKey;
|
||||
changeSelection(cell, additive, event.ctrlKey || event.metaKey);
|
||||
const additive = event.shiftKey || command;
|
||||
changeSelection(cell, additive, command);
|
||||
},
|
||||
[changeSelection, session.paused],
|
||||
[
|
||||
changeSelection,
|
||||
enableDigitHighlight,
|
||||
puzzle.size,
|
||||
session.paused,
|
||||
session.values,
|
||||
workspace,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePointerEnter = useCallback(
|
||||
@@ -750,47 +808,65 @@ export function Workbench() {
|
||||
}
|
||||
}, [puzzle]);
|
||||
|
||||
const generatePuzzle = useCallback(async () => {
|
||||
const generateConfigured = useCallback(
|
||||
async (options: GenerateVariantOptions) => {
|
||||
if (workerRef.current === null) return;
|
||||
setBusy(true);
|
||||
setFeedback({
|
||||
kind: "info",
|
||||
message:
|
||||
"Generating and rating a uniquely checked puzzle locally. The current board stays available until it is ready…",
|
||||
});
|
||||
try {
|
||||
const generated = (await workerRef.current.request(
|
||||
{ kind: "generate-variant", options },
|
||||
{ timeoutMs: 240_000 },
|
||||
)) as GeneratedVariantPuzzle;
|
||||
loadPuzzle(generated.puzzle);
|
||||
setGeneration(generated);
|
||||
setDifficulty(generated.difficulty);
|
||||
setWorkspace("generate");
|
||||
setFeedback({
|
||||
kind: "success",
|
||||
message: `Generated a unique ${generated.variant} puzzle. Rated ${generated.difficulty.label}${generated.difficulty.score === null ? "" : ` (${String(generated.difficulty.score)}/100)`}.`,
|
||||
});
|
||||
} catch (error) {
|
||||
setFeedback({ kind: "error", message: errorMessage(error) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[loadPuzzle],
|
||||
);
|
||||
|
||||
const rateCurrentPuzzle = useCallback(async () => {
|
||||
if (workerRef.current === null) return;
|
||||
if (normalized.error) {
|
||||
setFeedback({ kind: "error", message: normalized.error });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setFeedback({
|
||||
kind: "info",
|
||||
message: "Generating a unique puzzle locally…",
|
||||
message: "Checking uniqueness and rating the current puzzle locally…",
|
||||
});
|
||||
try {
|
||||
const generated = (await workerRef.current.request(
|
||||
{
|
||||
kind: "generate",
|
||||
options: {
|
||||
size: puzzle.size,
|
||||
targetClues: Math.max(
|
||||
puzzle.size,
|
||||
Math.round(puzzle.size * puzzle.size * 0.42),
|
||||
),
|
||||
symmetry: "rotational",
|
||||
seed: `${Date.now().toString(36)}-${Math.random().toString(36)}`,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 180_000 },
|
||||
)) as NormalizedPuzzle;
|
||||
loadPuzzle({
|
||||
...generated,
|
||||
title: "Generated classic",
|
||||
author: "Sudoku Tools",
|
||||
rules: `Place 1–${String(generated.size)} exactly once in every row, column and outlined region.`,
|
||||
});
|
||||
setWorkspace("play");
|
||||
const assessment = (await workerRef.current.request(
|
||||
{ kind: "difficulty", puzzle: normalized.puzzle },
|
||||
{ timeoutMs: 60_000 },
|
||||
)) as DifficultyAssessment;
|
||||
setGeneration(undefined);
|
||||
setDifficulty(assessment);
|
||||
setFeedback({
|
||||
kind: "success",
|
||||
message:
|
||||
"A unique rotationally symmetric puzzle was generated locally.",
|
||||
kind: assessment.uniqueness === "unique" ? "success" : "info",
|
||||
message: assessment.summary,
|
||||
});
|
||||
} catch (error) {
|
||||
setFeedback({ kind: "error", message: errorMessage(error) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [loadPuzzle, puzzle.size]);
|
||||
}, [normalized]);
|
||||
|
||||
const checkBoard = useCallback(() => {
|
||||
if (normalized.error) {
|
||||
@@ -990,20 +1066,24 @@ export function Workbench() {
|
||||
<select
|
||||
defaultValue=""
|
||||
onChange={(event) => {
|
||||
const sample =
|
||||
event.target.value === "killer"
|
||||
? KILLER_SAMPLE
|
||||
: CLASSIC_SAMPLE;
|
||||
loadPuzzle(cloneSample(sample));
|
||||
setWorkspace("play");
|
||||
const sample = SAMPLE_CATALOG.find(
|
||||
({ id }) => id === event.target.value,
|
||||
);
|
||||
if (sample !== undefined) {
|
||||
loadPuzzle(cloneSample(sample.puzzle));
|
||||
setWorkspace("play");
|
||||
}
|
||||
event.target.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Built-in examples…
|
||||
</option>
|
||||
<option value="classic">Classic 9 × 9</option>
|
||||
<option value="killer">Pocket Killer 4 × 4</option>
|
||||
{SAMPLE_CATALOG.map((sample) => (
|
||||
<option key={sample.id} value={sample.id}>
|
||||
{sample.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" onClick={() => setImportOpen(true)}>
|
||||
@@ -1029,6 +1109,7 @@ export function Workbench() {
|
||||
[
|
||||
["play", "Play"],
|
||||
["set", "Set"],
|
||||
["generate", "Generate"],
|
||||
["solve", "Solve"],
|
||||
["helpers", "Helpers"],
|
||||
] as const
|
||||
@@ -1110,6 +1191,7 @@ export function Workbench() {
|
||||
colors={session.colors}
|
||||
candidates={candidateMasks}
|
||||
selected={selectedSet}
|
||||
highlighted={highlightedCells}
|
||||
conflicts={conflictCells}
|
||||
activeCell={activeCell}
|
||||
showCandidates={showCandidates}
|
||||
@@ -1130,6 +1212,19 @@ export function Workbench() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{workspace === "play" && showDigitCompletion && (
|
||||
<DigitCompletionBar
|
||||
size={puzzle.size}
|
||||
completions={completionData}
|
||||
highlightedDigit={highlightedDigit}
|
||||
highlightingEnabled={enableDigitHighlight}
|
||||
onHighlight={(digit) =>
|
||||
setHighlightedDigit((current) =>
|
||||
current === digit ? null : digit,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="board-meta">
|
||||
<section className="rule-card">
|
||||
<p className="eyebrow">Rules</p>
|
||||
@@ -1206,6 +1301,28 @@ export function Workbench() {
|
||||
/>
|
||||
Highlight conflicts immediately
|
||||
</label>
|
||||
<label className="option-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showDigitCompletion}
|
||||
onChange={(event) =>
|
||||
setShowDigitCompletion(event.target.checked)
|
||||
}
|
||||
/>
|
||||
Show digit completion bar
|
||||
</label>
|
||||
<label className="option-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableDigitHighlight}
|
||||
onChange={(event) => {
|
||||
const enabled = event.target.checked;
|
||||
setEnableDigitHighlight(enabled);
|
||||
if (!enabled) setHighlightedDigit(null);
|
||||
}}
|
||||
/>
|
||||
Enable matching-digit highlighting (Ctrl/⌘-click or bar)
|
||||
</label>
|
||||
</section>
|
||||
<section className="panel-section">
|
||||
<button
|
||||
@@ -1218,7 +1335,7 @@ export function Workbench() {
|
||||
</section>
|
||||
<p className="muted shortcut-note">
|
||||
Z/X/C/V change mode · arrows move · Shift extends · Ctrl/⌘ Z
|
||||
undoes.
|
||||
undoes · Ctrl/⌘-click a placed digit highlights its matches.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1253,11 +1370,21 @@ export function Workbench() {
|
||||
)
|
||||
}
|
||||
onCheck={() => void checkDefinition()}
|
||||
onGenerate={() => void generatePuzzle()}
|
||||
onGenerate={() => setWorkspace("generate")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workspace === "generate" && (
|
||||
<GeneratorWorkspace
|
||||
busy={busy}
|
||||
assessment={difficulty}
|
||||
generation={generation}
|
||||
onGenerate={(options) => void generateConfigured(options)}
|
||||
onRate={() => void rateCurrentPuzzle()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{workspace === "solve" && (
|
||||
<SolveWorkspace
|
||||
size={puzzle.size}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { KillerCageConstraint, VariantConstraint } from "../domain/types";
|
||||
|
||||
function touchesAnyCell(
|
||||
constraint: KillerCageConstraint,
|
||||
cells: ReadonlySet<number>,
|
||||
): boolean {
|
||||
return constraint.cells.some((cell) => cells.has(cell));
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter cages form a partition: drawing a cage replaces every existing cage
|
||||
* that touches its cells instead of leaving hidden, overlapping sum rules.
|
||||
*/
|
||||
export function replaceOverlappingKillerCages(
|
||||
constraints: readonly VariantConstraint[],
|
||||
cage: KillerCageConstraint,
|
||||
): VariantConstraint[] {
|
||||
const cells = new Set(cage.cells);
|
||||
return [
|
||||
...constraints.filter(
|
||||
(constraint) =>
|
||||
constraint.type !== "killer-cage" || !touchesAnyCell(constraint, cells),
|
||||
),
|
||||
{ ...cage, cells: [...cage.cells] },
|
||||
];
|
||||
}
|
||||
|
||||
/** Remove every cage touching at least one selected cell. */
|
||||
export function removeKillerCagesAtCells(
|
||||
constraints: readonly VariantConstraint[],
|
||||
cells: readonly number[],
|
||||
): VariantConstraint[] {
|
||||
if (cells.length === 0) return [...constraints];
|
||||
const selected = new Set(cells);
|
||||
return constraints.filter(
|
||||
(constraint) =>
|
||||
constraint.type !== "killer-cage" ||
|
||||
!touchesAnyCell(constraint, selected),
|
||||
);
|
||||
}
|
||||
|
||||
export function selectionTouchesKillerCage(
|
||||
constraints: readonly VariantConstraint[],
|
||||
cells: readonly number[],
|
||||
): boolean {
|
||||
if (cells.length === 0) return false;
|
||||
const selected = new Set(cells);
|
||||
return constraints.some(
|
||||
(constraint) =>
|
||||
constraint.type === "killer-cage" && touchesAnyCell(constraint, selected),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user