feat: add gameplay assists and variant generator
This commit is contained in:
+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}
|
||||
|
||||
Reference in New Issue
Block a user