244 lines
7.4 KiB
TypeScript
244 lines
7.4 KiB
TypeScript
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>
|
||
);
|
||
}
|