import { describe, expect, it } from "vitest"; import { shortestPath, shortestPathLength } from "../../src/stages/games/chase/logic/hexGraph"; import { generateChaseRound } from "../../src/stages/games/chase/logic/generator"; import { thiefHasWinningStrategy } from "../../src/stages/games/chase/logic/solvability"; import type { Difficulty, GameGraph } from "../../src/stages/games/chase/logic/types"; describe("chase round generator", () => { it("is deterministic for same seed and difficulty", () => { const options = { seed: 20260426, difficulty: "normal" as Difficulty }; const roundA = generateChaseRound(options); const roundB = generateChaseRound(options); expect(roundA).toEqual(roundB); }); it("matches golden snapshot for fixed seed", () => { const round = generateChaseRound({ seed: 424242, difficulty: "easy" }); expect({ seed: round.snapshot.seed, difficulty: round.snapshot.difficulty, thiefStartNodeId: round.snapshot.thiefStartNodeId, guardStartNodeId: round.snapshot.guardStartNodeId, thiefNodeId: round.snapshot.thiefNodeId, guardNodeId: round.snapshot.guardNodeId, exitNodeId: round.snapshot.exitNodeId, status: round.snapshot.status, hasEscapePath: round.meta.hasEscapePath, nodeCount: Object.keys(round.snapshot.graph.nodes).length, edgeCount: round.snapshot.graph.edgeList.length, firstFiveNodeIds: Object.keys(round.snapshot.graph.nodes).slice(0, 5), }).toMatchInlineSnapshot(` { "difficulty": "easy", "edgeCount": 25, "exitNodeId": "-2,0", "firstFiveNodeIds": [ "0,0", "0,1", "0,-1", "-1,1", "-1,2", ], "guardNodeId": "-2,1", "guardStartNodeId": "-2,1", "hasEscapePath": true, "nodeCount": 24, "seed": 2654860011, "status": "playing", "thiefNodeId": "2,-1", "thiefStartNodeId": "2,-1", } `); }); it("generates graph node count between 20 and 30", () => { const round = generateChaseRound({ seed: 11, difficulty: "normal" }); const nodeCount = Object.keys(round.snapshot.graph.nodes).length; expect(nodeCount).toBeGreaterThanOrEqual(20); expect(nodeCount).toBeLessThanOrEqual(30); }); it("ensures an escape path exists", () => { const round = generateChaseRound({ seed: 99, difficulty: "hard" }); const pathLength = shortestPathLength( round.snapshot.graph, round.snapshot.thiefNodeId, round.snapshot.exitNodeId, ); expect(round.meta.hasEscapePath).toBe(true); expect(pathLength).toBeGreaterThan(0); expect(pathLength).toBeLessThan(Infinity); }); it("ensures thief and guard do not start on the same node", () => { const round = generateChaseRound({ seed: 123456, difficulty: "normal" }); expect(round.snapshot.thiefStartNodeId).not.toBe(round.snapshot.guardStartNodeId); }); it("stays playable and stable across many seeds", () => { for (let seed = 1; seed <= 60; seed += 1) { const round = generateChaseRound({ seed, difficulty: "normal" }); const nodeCount = Object.keys(round.snapshot.graph.nodes).length; expect(nodeCount).toBeGreaterThanOrEqual(20); expect(nodeCount).toBeLessThanOrEqual(30); expect(round.meta.hasEscapePath).toBe(true); expect(round.snapshot.thiefStartNodeId).not.toBe( round.snapshot.guardStartNodeId, ); } }); it("places guard strictly between thief and exit on the canonical shortest path", () => { for (let seed = 1; seed <= 40; seed += 1) { for (const difficulty of ["easy", "normal", "hard"] as const) { const round = generateChaseRound({ seed, difficulty }); const path = shortestPath( round.snapshot.graph, round.snapshot.thiefStartNodeId, round.snapshot.exitNodeId, ); expect(path.length).toBeGreaterThanOrEqual(3); const inner = path.slice(1, -1); expect(inner).toContain(round.snapshot.guardStartNodeId); expect(round.meta.pathLength).toBeGreaterThanOrEqual( difficulty === "easy" ? 3 : difficulty === "hard" ? 6 : 4, ); } } }); it("throws after retry limit when all attempts are unplayable", () => { let attemptCount = 0; const unplayableStrategy = (seed: number, difficulty: Difficulty) => { attemptCount += 1; return { snapshot: { seed, difficulty, graph: { nodes: {}, edgeList: [] }, thiefStartNodeId: "A", guardStartNodeId: "A", thiefNodeId: "A", guardNodeId: "A", exitNodeId: "A", status: "playing" as const, }, meta: { hasEscapePath: false, pathLength: 0, attemptsUsed: 1, }, }; }; expect(() => generateChaseRound( { seed: 7, difficulty: "normal" }, { generateAttempt: unplayableStrategy }, ), ).toThrow("Unable to generate playable chase round within retry limit"); expect(attemptCount).toBe(80); }); it("rejects rounds that are unwinnable against optimal guard", () => { const trapGraph: GameGraph = { nodes: { L: { id: "L", q: 0, r: 0, neighbors: ["N"] }, N: { id: "N", q: 1, r: 0, neighbors: ["L", "E"] }, E: { id: "E", q: 2, r: 0, neighbors: ["N"] }, }, edgeList: [ ["L", "N"], ["N", "E"], ], }; expect(thiefHasWinningStrategy(trapGraph, "L", "N", "E")).toBe(false); let attemptCount = 0; const corridorTrap = (seed: number, difficulty: Difficulty) => { attemptCount += 1; return { snapshot: { seed, difficulty, graph: trapGraph, thiefStartNodeId: "L", guardStartNodeId: "N", thiefNodeId: "L", guardNodeId: "N", exitNodeId: "E", status: "playing" as const, }, meta: { hasEscapePath: true, pathLength: 2, attemptsUsed: 1, }, }; }; expect(() => generateChaseRound({ seed: 1, difficulty: "normal" }, { generateAttempt: corridorTrap }), ).toThrow("Unable to generate playable chase round within retry limit"); expect(attemptCount).toBe(80); }); });