feat: add gameplay assists and variant generator

This commit is contained in:
2026-08-30 17:47:10 +02:00
parent 659640b231
commit 4a9869baa0
29 changed files with 2899 additions and 110 deletions
+64 -15
View File
@@ -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 &lt; second
1st &lt; 2nd (inequality)
</button>
<button
type="button"
disabled={!need(2)}
onClick={() =>
append({
type: "inequality",
lesser: selection[1]!,
greater: selection[0]!,
})
}
>
1st &gt; 2nd (inequality)
</button>
</div>
<div className="inline-fields">
@@ -374,12 +427,8 @@ export function ConstraintEditor({
<button type="button" onClick={onCheck} disabled={busy}>
Check definition &amp; 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>
+57
View File
@@ -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>
);
}
+243
View File
@@ -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>
);
}
+18 -3
View File
@@ -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>
+10 -6
View File
@@ -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 &lt; second</option>
<option value="greater">First &gt; 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 &lt; second cell · inequality
</option>
<option value="greater">
First cell &gt; second cell · inequality
</option>
</select>
</label>
<label>
+26 -15
View File
@@ -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
View File
@@ -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}
+52
View File
@@ -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),
);
}