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