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
+83
View File
@@ -32,6 +32,16 @@ test("loads standalone and keeps the core play workflow local", async ({
grid.getByRole("gridcell", { name: "Row 1, column 3, empty" }),
).toBeVisible();
const placedFour = grid.getByRole("gridcell", {
name: "Row 1, column 1, 4",
});
await placedFour.click({ modifiers: ["Control"] });
expect(await grid.locator(".is-digit-highlighted").count()).toBeGreaterThan(
1,
);
await placedFour.click({ modifiers: ["Control"] });
await expect(grid.locator(".is-digit-highlighted")).toHaveCount(0);
await page.getByRole("button", { name: "Helpers" }).click();
await expect(
page.getByRole("heading", { name: "Sudoku helpers" }),
@@ -54,3 +64,76 @@ test("loads standalone and keeps the core play workflow local", async ({
expect(runtimeErrors).toEqual([]);
});
test("replaces setter cages and generates a rated variant", async ({
page,
}) => {
const runtimeErrors: string[] = [];
page.on("pageerror", (error) => runtimeErrors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") runtimeErrors.push(message.text());
});
await page.goto("/deep/nested/sudoku/");
await page.getByRole("button", { name: "New blank" }).click();
const grid = page.getByRole("grid", { name: "9 by 9 Sudoku grid" });
await grid.getByRole("gridcell", { name: "Row 1, column 1, empty" }).click();
await grid
.getByRole("gridcell", { name: "Row 1, column 2, empty" })
.click({ modifiers: ["Shift"] });
await page.getByRole("spinbutton", { name: "Cage sum" }).fill("3");
await page.getByRole("button", { name: "Add / replace cage" }).click();
await expect(page.getByText("3 cage · 2 cells")).toBeVisible();
await page.getByRole("spinbutton", { name: "Cage sum" }).fill("4");
await page.getByRole("button", { name: "Add / replace cage" }).click();
await expect(page.getByText("3 cage · 2 cells")).toHaveCount(0);
await expect(page.getByText("4 cage · 2 cells")).toBeVisible();
await page.getByRole("button", { name: "Remove selected cage" }).click();
await expect(page.getByText("4 cage · 2 cells")).toHaveCount(0);
await page.getByRole("button", { name: "Generate", exact: true }).click();
await page.getByLabel("Variant").selectOption("thermo");
await page
.getByRole("combobox", { name: "Grid", exact: true })
.selectOption("4");
await page.getByLabel("Requested profile").selectOption("beginner");
await page.getByLabel("Seed").fill("browser-thermo");
await page.getByRole("button", { name: "Generate Thermo" }).click();
await expect(
page.getByRole("heading", { name: "Thermo · browser-thermo" }),
).toBeVisible({ timeout: 60_000 });
await expect(page.getByText("Difficulty assessment")).toBeVisible();
await expect(page.locator(".difficulty-card .status-pill")).toHaveText(
"unique",
);
await expect(page.locator(".constraint-thermo")).not.toHaveCount(0);
expect(runtimeErrors).toEqual([]);
});
test("uses distinct numbered sum badges and directional inequalities", async ({
page,
}) => {
await page.goto("/deep/nested/sudoku/");
const examples = page.getByLabel("Open built-in puzzle");
await examples.selectOption("xv");
await expect(
page.getByRole("heading", { name: "Five or ten" }),
).toBeVisible();
await expect(page.locator(".constraint-xv--5 text").first()).toHaveText("5");
await expect(page.locator(".constraint-xv--10 text").first()).toHaveText(
"10",
);
await examples.selectOption("inequality");
await expect(
page.getByRole("heading", { name: "Lesser and greater" }),
).toBeVisible();
await expect(page.locator(".constraint-inequality")).not.toHaveCount(0);
await expect(
page.locator(".constraint-inequality-tip").first(),
).toBeVisible();
});
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import type { VariantConstraint } from "../../src/domain";
import {
removeKillerCagesAtCells,
replaceOverlappingKillerCages,
selectionTouchesKillerCage,
} from "../../src/components/constraintEditing";
const constraints: VariantConstraint[] = [
{ type: "killer-cage", cells: [0, 1], sum: 3 },
{ type: "killer-cage", cells: [2, 3], sum: 7 },
{ type: "thermo", cells: [0, 4] },
];
describe("setter cage editing", () => {
it("replaces every overlapping cage while preserving other rules", () => {
expect(
replaceOverlappingKillerCages(constraints, {
type: "killer-cage",
cells: [1, 2],
sum: 5,
}),
).toEqual([
{ type: "thermo", cells: [0, 4] },
{ type: "killer-cage", cells: [1, 2], sum: 5 },
]);
});
it("removes cages by any selected member cell", () => {
expect(removeKillerCagesAtCells(constraints, [1])).toEqual([
{ type: "killer-cage", cells: [2, 3], sum: 7 },
{ type: "thermo", cells: [0, 4] },
]);
expect(selectionTouchesKillerCage(constraints, [1])).toBe(true);
expect(selectionTouchesKillerCage(constraints, [4])).toBe(false);
});
});
@@ -0,0 +1,32 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DigitCompletionBar } from "../../src/components/DigitCompletionBar";
import { digitCompletions } from "../../src/state/gameplayHelpers";
describe("digit completion bar", () => {
it("renders complete digits muted and excess digits as overdone", () => {
const values = new Array<number>(81).fill(0);
values.fill(1, 0, 9);
values.fill(2, 9, 19);
render(
<DigitCompletionBar
size={9}
completions={digitCompletions(9, values)}
highlightedDigit={null}
highlightingEnabled
onHighlight={vi.fn()}
/>,
);
expect(
screen.getByRole("button", { name: /Digit 1: complete/u }),
).toHaveClass("is-done");
expect(
screen.getByRole("button", { name: /Digit 2: overdone by 1/u }),
).toHaveClass("is-overdone");
expect(
screen.getByRole("button", { name: /Digit 3: 9 remaining/u }),
).toHaveClass("is-undone");
});
});
@@ -0,0 +1,62 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { GeneratorWorkspace } from "../../src/components/GeneratorWorkspace";
describe("Sudoku generator workspace", () => {
it("offers only reliable sizes and submits an explicit local recipe", async () => {
const user = userEvent.setup();
const onGenerate = vi.fn();
render(
<GeneratorWorkspace
busy={false}
onGenerate={onGenerate}
onRate={vi.fn()}
/>,
);
await user.selectOptions(screen.getByLabelText("Variant"), "anti-king");
const grid = screen.getByLabelText("Grid");
expect(
within(grid)
.getAllByRole("option")
.map((option) => option.textContent),
).toEqual(["6 × 6", "9 × 9"]);
expect(
screen.queryByLabelText("Requested markings"),
).not.toBeInTheDocument();
await user.selectOptions(screen.getByLabelText("Variant"), "thermo");
expect(screen.getByLabelText("Requested markings")).toBeInTheDocument();
await user.selectOptions(screen.getByLabelText("Grid"), "4");
await user.selectOptions(
screen.getByLabelText("Requested profile"),
"easy",
);
await user.clear(screen.getByLabelText("Seed"));
await user.type(screen.getByLabelText("Seed"), "repeatable-demo");
await user.click(screen.getByRole("button", { name: "Generate Thermo" }));
expect(onGenerate).toHaveBeenCalledWith({
variant: "thermo",
size: 4,
targetDifficulty: "easy",
symmetry: "rotational",
constraintCount: 8,
seed: "repeatable-demo",
});
});
it("exposes independent rating for the current puzzle", async () => {
const user = userEvent.setup();
const onRate = vi.fn();
render(
<GeneratorWorkspace busy={false} onGenerate={vi.fn()} onRate={onRate} />,
);
await user.click(
screen.getByRole("button", { name: "Rate current puzzle" }),
);
expect(onRate).toHaveBeenCalledOnce();
});
});
+44
View File
@@ -0,0 +1,44 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { normalizePuzzle } from "../../src/domain";
import { SudokuBoard } from "../../src/components/SudokuBoard";
describe("Sudoku board constraint visuals", () => {
it("distinguishes XV sums from directional inequalities", () => {
const puzzle = normalizePuzzle({
version: 1,
size: 4,
givens: new Array<number>(16).fill(0),
constraints: [
{ type: "xv", a: 0, b: 1, total: 5 },
{ type: "xv", a: 4, b: 5, total: 10 },
{ type: "inequality", lesser: 8, greater: 9 },
],
});
const { container } = render(
<SudokuBoard
puzzle={puzzle}
values={puzzle.givens}
selected={new Set([0])}
activeCell={0}
onCellPointerDown={vi.fn()}
onCellPointerEnter={vi.fn()}
onKeyDown={vi.fn()}
/>,
);
expect(container.querySelector(".constraint-xv--5 text")).toHaveTextContent(
"5",
);
expect(
container.querySelector(".constraint-xv--10 text"),
).toHaveTextContent("10");
expect(
container.querySelector(".constraint-inequality path"),
).toHaveAttribute("d", "M0.12 -0.17L-0.12 0L0.12 0.17");
expect(
container.querySelector(".constraint-inequality-tip"),
).toBeInTheDocument();
});
});
+104
View File
@@ -63,6 +63,102 @@ describe("Sudoku workbench", () => {
expect(undo).toBeDisabled();
});
it("shows digit progress and toggles matching-digit highlights separately from selection", async () => {
const user = userEvent.setup();
render(<Workbench />);
const completion = screen.getByRole("group", {
name: "Highlight matching digits",
});
expect(within(completion).getAllByRole("button")).toHaveLength(9);
const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" });
const four = within(grid).getByRole("gridcell", {
name: "Row 1, column 1, 4",
});
fireEvent.pointerDown(four, { buttons: 1, ctrlKey: true });
const highlighted = grid.querySelectorAll(".is-digit-highlighted");
expect(highlighted.length).toBeGreaterThan(1);
expect(four).toHaveAttribute("aria-selected", "true");
fireEvent.pointerDown(four, { buttons: 1, ctrlKey: true });
expect(grid.querySelectorAll(".is-digit-highlighted")).toHaveLength(0);
await user.click(screen.getByLabelText("Show digit completion bar"));
expect(
screen.queryByRole("group", { name: "Highlight matching digits" }),
).not.toBeInTheDocument();
});
it("replaces and removes setter cages through the selected cells", async () => {
const user = userEvent.setup();
render(<Workbench />);
await user.click(screen.getByRole("button", { name: "New blank" }));
const grid = screen.getByRole("grid", { name: "9 by 9 Sudoku grid" });
fireEvent.pointerDown(
within(grid).getByRole("gridcell", {
name: "Row 1, column 2, empty",
}),
{ buttons: 1, shiftKey: true },
);
const cageSum = screen.getByRole("spinbutton", { name: "Cage sum" });
await user.clear(cageSum);
await user.type(cageSum, "3");
await user.click(
screen.getByRole("button", { name: "Add / replace cage" }),
);
expect(screen.getByText("3 cage · 2 cells")).toBeInTheDocument();
await user.clear(cageSum);
await user.type(cageSum, "4");
await user.click(
screen.getByRole("button", { name: "Add / replace cage" }),
);
expect(screen.queryByText("3 cage · 2 cells")).not.toBeInTheDocument();
expect(screen.getByText("4 cage · 2 cells")).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Remove selected cage" }),
);
expect(screen.queryByText("4 cage · 2 cells")).not.toBeInTheDocument();
});
it("opens the expanded built-in variant examples", async () => {
const user = userEvent.setup();
const { container } = render(<Workbench />);
await user.selectOptions(
screen.getByLabelText("Open built-in puzzle"),
"thermo",
);
expect(
screen.getByRole("heading", { name: "Warm fronts" }),
).toBeInTheDocument();
expect(
screen.getByRole("grid", { name: "6 by 6 Sudoku grid" }),
).toBeInTheDocument();
expect(
container.querySelectorAll(".constraint-thermo").length,
).toBeGreaterThan(0);
await user.selectOptions(
screen.getByLabelText("Open built-in puzzle"),
"xv",
);
expect(
screen.getByRole("heading", { name: "Five or ten" }),
).toBeInTheDocument();
expect(container.querySelector(".constraint-xv--5 text")).toHaveTextContent(
"5",
);
expect(
container.querySelector(".constraint-xv--10 text"),
).toHaveTextContent("10");
});
it("switches between setting, solving, playing and helper workspaces", async () => {
const user = userEvent.setup();
render(<Workbench />);
@@ -75,6 +171,14 @@ describe("Sudoku workbench", () => {
screen.getByRole("heading", { name: "Enter givens" }),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Generate" }));
expect(
screen.getByRole("heading", { name: "Sudoku generator" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Rate current puzzle" }),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Solve" }));
expect(
screen.getByRole("heading", { name: "Solve and verify" }),
+44 -3
View File
@@ -1,6 +1,14 @@
import { describe, expect, it } from "vitest";
import { SAMPLE_PUZZLES } from "../../src/data/samples";
import { solveExact, solveLogically } from "../../src/solver";
import {
CLASSIC_SAMPLE,
SAMPLE_CATALOG,
SAMPLE_PUZZLES,
} from "../../src/data/samples";
import {
GENERATOR_VARIANTS,
solveExact,
solveLogically,
} from "../../src/solver";
describe("bundled original samples", () => {
it.each(SAMPLE_PUZZLES)("ships $title as a unique puzzle", (puzzle) => {
@@ -10,6 +18,39 @@ describe("bundled original samples", () => {
});
it("solves the generated classic with the supported logical techniques", () => {
expect(solveLogically(SAMPLE_PUZZLES[0]).status).toBe("solved");
expect(solveLogically(CLASSIC_SAMPLE).status).toBe("solved");
});
it("provides a named example for every advertised generator variant", () => {
expect(new Set(SAMPLE_CATALOG.map(({ id }) => id)).size).toBe(
SAMPLE_CATALOG.length,
);
for (const { id } of GENERATOR_VARIANTS) {
expect(SAMPLE_CATALOG.some((entry) => entry.id === id)).toBe(true);
}
});
it("also demonstrates every supported global chess/adjacency rule", () => {
const constraintTypes = new Set(
SAMPLE_PUZZLES.flatMap((puzzle) =>
(puzzle.constraints ?? []).map(({ type }) => type),
),
);
expect(constraintTypes).toEqual(
new Set([
"diagonal",
"anti-knight",
"anti-king",
"non-consecutive",
"killer-cage",
"thermo",
"arrow",
"kropki",
"xv",
"inequality",
"renban",
"palindrome",
]),
);
});
});
+203
View File
@@ -0,0 +1,203 @@
import { describe, expect, it } from "vitest";
import { classicRegions, type PuzzleDefinition } from "../../src/domain";
import {
evaluateDifficulty,
generateVariant,
GENERATOR_VARIANTS,
solveExact,
type GeneratorVariant,
} from "../../src/solver";
const solution4 = [1, 2, 3, 4, 3, 4, 1, 2, 4, 3, 2, 1, 2, 1, 4, 3] as const;
const puzzle4: PuzzleDefinition = {
version: 1,
size: 4,
givens: [1, 0, 0, 4, 0, 4, 1, 0, 4, 0, 2, 0, 0, 1, 0, 3],
solution: solution4,
regions: classicRegions(4),
constraints: [],
};
describe("difficulty assessment", () => {
it("rates a unique puzzle from deterministic solver evidence", () => {
const first = evaluateDifficulty(puzzle4);
const second = evaluateDifficulty(puzzle4);
expect(first.uniqueness).toBe("unique");
expect(first.score).not.toBeNull();
expect(first).toEqual(second);
expect(first.logicalSteps).toBeGreaterThan(0);
});
it("does not rate puzzles with multiple solutions", () => {
const assessment = evaluateDifficulty({
...puzzle4,
givens: new Array<number>(16).fill(0),
solution: undefined,
});
expect(assessment.uniqueness).toBe("multiple");
expect(assessment.level).toBe("unrated");
expect(assessment.score).toBeNull();
});
it("never claims uniqueness after a bounded search is cut short", () => {
const assessment = evaluateDifficulty(puzzle4, { exactMaxNodes: 1 });
expect(assessment.uniqueness).toBe("unknown");
expect(assessment.score).toBeNull();
});
});
describe("variant generator", () => {
it("advertises only explicit, material variant definitions", () => {
expect(GENERATOR_VARIANTS.map(({ id }) => id)).toEqual([
"classic",
"diagonal",
"anti-knight",
"anti-king",
"non-consecutive",
"killer",
"thermo",
"arrow",
"kropki",
"xv",
"inequality",
"renban",
"palindrome",
]);
expect(
GENERATOR_VARIANTS.every(
({ description, supportedSizes }) =>
description.length > 0 && supportedSizes.length > 0,
),
).toBe(true);
});
it.each([
["classic", 4],
["diagonal", 4],
["anti-knight", 4],
["anti-king", 6],
["non-consecutive", 6],
["killer", 4],
["thermo", 4],
["kropki", 4],
["inequality", 4],
["renban", 4],
["palindrome", 4],
["arrow", 6],
["xv", 6],
] as const)("generates a unique %s puzzle", (variant, size) => {
const generated = generateVariant({
variant,
size,
seed: `test-${variant}`,
targetDifficulty: "beginner",
maxChecks: size * size,
solveTimeoutMs: 2_000,
});
const exact = solveExact(generated.puzzle, {
maxSolutions: 2,
timeoutMs: 5_000,
});
expect(exact.count).toBe(1);
expect(exact.truncated).toBe(false);
expect(exact.solutions[0]).toEqual(generated.puzzle.solution);
expect(generated.difficulty.uniqueness).toBe("unique");
if (variant === "classic") {
expect(generated.puzzle.constraints).toHaveLength(0);
} else if (variant === "diagonal") {
expect(
generated.puzzle.constraints.filter(({ type }) => type === "diagonal"),
).toHaveLength(2);
} else if (variant === "killer") {
expect(
generated.puzzle.constraints.some(({ type }) => type === "killer-cage"),
).toBe(true);
} else {
expect(
generated.puzzle.constraints.some(({ type }) => type === variant),
).toBe(true);
}
});
it.each(["classic", "killer", "thermo", "arrow"] as const)(
"is deterministic for %s generation",
(variant) => {
const size = variant === "arrow" ? 6 : 4;
const options = {
variant,
size,
seed: "repeatable",
targetClues: Math.ceil((size * size) / 2),
maxChecks: size * size,
} as const;
expect(generateVariant(options)).toEqual(generateVariant(options));
},
);
it.each(["diagonal", "anti-knight", "anti-king", "killer", "arrow"] as const)(
"keeps practical 9x9 %s generation bounded",
(variant) => {
const generated = generateVariant({
variant,
size: 9,
seed: `practical-${variant}`,
targetClues: 50,
maxChecks: 12,
solveMaxNodes: 500_000,
solveTimeoutMs: 2_000,
});
expect(generated.puzzle.size).toBe(9);
expect(generated.difficulty.uniqueness).toBe("unique");
},
);
it.each([
["anti-knight", 9],
["anti-king", 9],
["non-consecutive", 6],
] as const)(
"constructs supported global %s grids across deterministic seeds",
(variant, size) => {
for (let seed = 0; seed < 5; seed += 1) {
const generated = generateVariant({
variant,
size,
seed,
targetClues: size * size,
maxChecks: 1,
solveMaxNodes: 500_000,
solveTimeoutMs: 2_000,
});
expect(generated.difficulty.uniqueness).toBe("unique");
}
},
);
it("rejects sizes outside a variant's reliable advertised range", () => {
expect(() => generateVariant({ variant: "arrow", size: 4 })).toThrow(
/supports sizes 6, 9/,
);
expect(() =>
generateVariant({ variant: "non-consecutive", size: 9 }),
).toThrow(/supports sizes 6/);
});
it("labels generated rules only for the requested variant", () => {
const generated = generateVariant({
variant: "xv",
size: 6,
seed: "rules",
targetClues: 30,
maxChecks: 6,
});
expect(generated.puzzle.rules).toMatch(/numbered pair badge/i);
expect(generated.puzzle.rules).toMatch(/Only marked pairs/i);
expect(generated.puzzle.rules).not.toMatch(/Kropki|negative/i);
});
it("keeps the public variant ID type exhaustive", () => {
const ids: GeneratorVariant[] = GENERATOR_VARIANTS.map(({ id }) => id);
expect(new Set(ids).size).toBe(GENERATOR_VARIANTS.length);
});
});
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import {
digitCompletions,
matchingDigitCells,
matchingDigitCellsAt,
placedDigitAt,
toggledDigitHighlight,
} from "../../src/state/gameplayHelpers";
describe("digit completion helpers", () => {
it.each([4, 6, 9, 12, 16])(
"reports undone, done and overdone digits on a %i by %i grid",
(size) => {
const values = new Array<number>(size * size).fill(0);
values.fill(1, 0, size - 1);
values.fill(2, size, size * 2);
values.fill(3, size * 2, size * 3 + 1);
const progress = digitCompletions(size, values);
expect(progress).toHaveLength(size);
expect(progress[0]).toEqual({
digit: 1,
placed: size - 1,
target: size,
remaining: 1,
excess: 0,
status: "undone",
});
expect(progress[1]).toEqual({
digit: 2,
placed: size,
target: size,
remaining: 0,
excess: 0,
status: "done",
});
expect(progress[2]).toEqual({
digit: 3,
placed: size + 1,
target: size,
remaining: 0,
excess: 1,
status: "overdone",
});
},
);
it("ignores empty, invalid and out-of-grid values without mutating input", () => {
const values = [1, 1, 0, -1, 5, Number.NaN, ...new Array(12).fill(0), 1];
const before = [...values];
expect(digitCompletions(4, values)[0]).toMatchObject({
placed: 2,
remaining: 2,
status: "undone",
});
expect(values).toEqual(before);
});
it.each([0, 3, 17, 4.5, Number.NaN])(
"rejects unsupported puzzle size %s",
(size) => {
expect(() => digitCompletions(size, [])).toThrow(RangeError);
},
);
});
describe("digit match highlighting", () => {
const values = [4, 0, 2, 4, 0, 3, 4, 0, 2, 0, 3, 0, 4, 2, 0, 3];
it("resolves a placed digit and all of its matching cells", () => {
expect(placedDigitAt(4, values, 3)).toBe(4);
expect(matchingDigitCells(4, values, 4)).toEqual([0, 3, 6, 12]);
expect(matchingDigitCellsAt(4, values, 3)).toEqual([0, 3, 6, 12]);
});
it("treats empty, invalid and out-of-grid cells as no match", () => {
expect(placedDigitAt(4, values, 1)).toBeNull();
expect(placedDigitAt(4, values, -1)).toBeNull();
expect(placedDigitAt(4, values, 16)).toBeNull();
expect(matchingDigitCellsAt(4, values, 1)).toEqual([]);
expect(matchingDigitCells(4, values, 0)).toEqual([]);
expect(matchingDigitCells(4, values, 5)).toEqual([]);
});
it("toggles repeated Ctrl/Cmd-style activation and preserves empty clicks", () => {
expect(toggledDigitHighlight(4, values, 0, null)).toBe(4);
expect(toggledDigitHighlight(4, values, 3, 4)).toBeNull();
expect(toggledDigitHighlight(4, values, 2, 4)).toBe(2);
expect(toggledDigitHighlight(4, values, 1, 4)).toBe(4);
});
it("never returns cells beyond the declared grid", () => {
expect(matchingDigitCells(4, [...values, 4, 4], 4)).toEqual([0, 3, 6, 12]);
});
});