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
+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}