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