import { beforeEach, describe, expect, it, vi } from "vitest"; import { ChaseGameModel } from "../../src/game/chase/model"; import type { Difficulty, GameGraph } from "../../src/game/chase/types"; const mockedGenerateChaseRound = vi.fn(); vi.mock("../../src/game/chase/generator", () => ({ generateChaseRound: (...args: unknown[]) => mockedGenerateChaseRound(...args), })); function createLineGraph(): GameGraph { return { nodes: { A: { id: "A", q: 0, r: 0, neighbors: ["B"] }, B: { id: "B", q: 1, r: 0, neighbors: ["A", "C"] }, C: { id: "C", q: 2, r: 0, neighbors: ["B", "D"] }, D: { id: "D", q: 3, r: 0, neighbors: ["C"] }, }, edgeList: [ ["A", "B"], ["B", "C"], ["C", "D"], ], }; } function makeRound(seed: number, difficulty: Difficulty) { return { snapshot: { seed, difficulty, graph: createLineGraph(), thiefStartNodeId: "B", guardStartNodeId: "D", thiefNodeId: "B", guardNodeId: "D", exitNodeId: "C", status: "playing" as const, }, meta: { hasEscapePath: true, pathLength: 1, attemptsUsed: 1, }, }; } describe("chase game model", () => { beforeEach(() => { mockedGenerateChaseRound.mockReset(); }); it("createWithSeed builds model from generator snapshot contract", () => { mockedGenerateChaseRound.mockReturnValue(makeRound(123, "normal")); const model = ChaseGameModel.createWithSeed({ seed: 123, difficulty: "normal", }); const state = model.getState(); expect(state.snapshot.seed).toBe(123); expect(state.snapshot.difficulty).toBe("normal"); expect(state.snapshot.status).toBe("playing"); expect(state.snapshot.thiefNodeId).toBe("B"); expect(state.snapshot.guardNodeId).toBe("D"); expect(state.snapshot.exitNodeId).toBe("C"); expect(state.turn).toBe(0); expect(model.getAvailableMoves()).toEqual(["A", "C"]); }); it("getState returns a defensive deep copy of snapshot", () => { mockedGenerateChaseRound.mockReturnValue(makeRound(123, "normal")); const model = ChaseGameModel.createWithSeed({ seed: 123, difficulty: "normal" }); const state = model.getState(); state.snapshot.thiefNodeId = "A"; state.snapshot.graph.nodes.B.neighbors.push("D"); state.snapshot.graph.edgeList.push(["A", "D"]); const afterMutation = model.getState(); expect(afterMutation.snapshot.thiefNodeId).toBe("B"); expect(afterMutation.snapshot.graph.nodes.B.neighbors).toEqual(["A", "C"]); expect(afterMutation.snapshot.graph.edgeList).toEqual([ ["A", "B"], ["B", "C"], ["C", "D"], ]); }); it("thief move to exit wins and increments winCount", () => { mockedGenerateChaseRound.mockReturnValue(makeRound(11, "normal")); const model = ChaseGameModel.createWithSeed({ seed: 11, difficulty: "normal" }); model.moveThief("C"); const state = model.getState(); expect(state.snapshot.status).toBe("win"); expect(state.winCount).toBe(1); expect(state.snapshot.thiefNodeId).toBe("C"); expect(state.turn).toBe(1); }); it("guard moves after thief and can cause lose by capture", () => { mockedGenerateChaseRound.mockReturnValue({ ...makeRound(22, "hard"), snapshot: { ...makeRound(22, "hard").snapshot, thiefStartNodeId: "A", thiefNodeId: "A", exitNodeId: "D", guardStartNodeId: "C", guardNodeId: "C", }, }); const model = ChaseGameModel.createWithSeed({ seed: 22, difficulty: "hard" }); model.moveThief("B"); const state = model.getState(); expect(state.snapshot.guardNodeId).toBe("B"); expect(state.snapshot.thiefNodeId).toBe("B"); expect(state.snapshot.status).toBe("lose"); expect(state.turn).toBe(1); }); it("loses when thief has no legal move after guard turn", () => { mockedGenerateChaseRound.mockReturnValue({ snapshot: { seed: 33, difficulty: "normal" as const, graph: { nodes: { A: { id: "A", q: 0, r: 0, neighbors: ["B"] }, B: { id: "B", q: 1, r: 0, neighbors: ["A", "C"] }, C: { id: "C", q: 2, r: 0, neighbors: ["B"] }, }, edgeList: [ ["A", "B"], ["B", "C"], ], }, thiefStartNodeId: "A", guardStartNodeId: "C", thiefNodeId: "A", guardNodeId: "C", exitNodeId: "C", status: "playing" as const, }, meta: { hasEscapePath: true, pathLength: 2, attemptsUsed: 1 }, }); const model = ChaseGameModel.createWithSeed({ seed: 33, difficulty: "normal" }); model.moveThief("B"); const state = model.getState(); expect(state.snapshot.status).toBe("lose"); expect(model.getAvailableMoves()).toEqual([]); expect(state.turn).toBe(1); }); it("retryRound restores the same initial round snapshot and keeps winCount", () => { mockedGenerateChaseRound.mockReturnValueOnce(makeRound(44, "easy")); const model = ChaseGameModel.createWithSeed({ seed: 44, difficulty: "easy" }); const initialState = model.getState(); model.moveThief("C"); expect(model.getState().winCount).toBe(1); model.retryRound(); const state = model.getState(); expect(state.snapshot).toEqual(initialState.snapshot); expect(state.snapshot.status).toBe("playing"); expect(state.winCount).toBe(1); expect(state.turn).toBe(0); expect(mockedGenerateChaseRound).toHaveBeenCalledTimes(1); }); it("newRound switches seed/difficulty and keeps winCount", () => { mockedGenerateChaseRound .mockReturnValueOnce(makeRound(55, "easy")) .mockReturnValueOnce(makeRound(99, "hard")); const model = ChaseGameModel.createWithSeed({ seed: 55, difficulty: "easy" }); model.moveThief("C"); expect(model.getState().winCount).toBe(1); model.newRound(99, "hard"); const state = model.getState(); expect(state.snapshot.seed).toBe(99); expect(state.snapshot.difficulty).toBe("hard"); expect(state.snapshot.status).toBe("playing"); expect(state.winCount).toBe(1); expect(state.turn).toBe(0); }); it("newRound applies the exact input seed and difficulty to snapshot", () => { mockedGenerateChaseRound .mockReturnValueOnce(makeRound(70, "normal")) .mockReturnValueOnce(makeRound(2027, "easy")); const model = ChaseGameModel.createWithSeed({ seed: 70, difficulty: "normal" }); model.newRound(2027, "easy"); const state = model.getState(); expect(state.snapshot.seed).toBe(2027); expect(state.snapshot.difficulty).toBe("easy"); }); it("retryRound resets rng so same move sequence is reproducible", () => { const easyRound = { snapshot: { seed: 1, difficulty: "easy" as const, graph: { nodes: { A: { id: "A", q: 0, r: 0, neighbors: ["B"] }, B: { id: "B", q: 1, r: 0, neighbors: ["A", "C", "E"] }, C: { id: "C", q: 2, r: 0, neighbors: ["B", "D"] }, D: { id: "D", q: 3, r: 0, neighbors: ["C"] }, E: { id: "E", q: 1, r: 1, neighbors: ["B"] }, }, edgeList: [ ["A", "B"], ["B", "C"], ["C", "D"], ["B", "E"], ], }, thiefStartNodeId: "D", guardStartNodeId: "B", thiefNodeId: "D", guardNodeId: "B", exitNodeId: "A", status: "playing" as const, }, meta: { hasEscapePath: true, pathLength: 3, attemptsUsed: 1 }, }; mockedGenerateChaseRound.mockReturnValue(easyRound); const model = ChaseGameModel.createWithSeed({ seed: 1, difficulty: "easy" }); model.moveThief("C"); const guardAfterFirstRun = model.getState().snapshot.guardNodeId; model.retryRound(); model.moveThief("C"); const guardAfterRetry = model.getState().snapshot.guardNodeId; expect(guardAfterFirstRun).toBe("A"); expect(guardAfterRetry).toBe("A"); }); it("newRound reseeds rng so guard behavior follows new seed", () => { const roundForSeed1 = { snapshot: { seed: 1, difficulty: "easy" as const, graph: { nodes: { A: { id: "A", q: 0, r: 0, neighbors: ["B"] }, B: { id: "B", q: 1, r: 0, neighbors: ["A", "C", "E"] }, C: { id: "C", q: 2, r: 0, neighbors: ["B", "D"] }, D: { id: "D", q: 3, r: 0, neighbors: ["C"] }, E: { id: "E", q: 1, r: 1, neighbors: ["B"] }, }, edgeList: [ ["A", "B"], ["B", "C"], ["C", "D"], ["B", "E"], ], }, thiefStartNodeId: "D", guardStartNodeId: "B", thiefNodeId: "D", guardNodeId: "B", exitNodeId: "A", status: "playing" as const, }, meta: { hasEscapePath: true, pathLength: 3, attemptsUsed: 1 }, }; const roundForSeed12 = { ...roundForSeed1, snapshot: { ...roundForSeed1.snapshot, seed: 12, }, }; mockedGenerateChaseRound .mockReturnValueOnce(roundForSeed1) .mockReturnValueOnce(roundForSeed12); const model = ChaseGameModel.createWithSeed({ seed: 1, difficulty: "easy" }); model.moveThief("C"); const guardWithSeed1 = model.getState().snapshot.guardNodeId; model.newRound(12, "easy"); model.moveThief("C"); const guardWithSeed12 = model.getState().snapshot.guardNodeId; expect(guardWithSeed1).toBe("A"); expect(guardWithSeed12).toBe("C"); }); it("guard strategy follows difficulty probability rule", () => { const baseRound = { snapshot: { seed: 77, difficulty: "hard" as const, graph: { nodes: { A: { id: "A", q: 0, r: 0, neighbors: ["B"] }, B: { id: "B", q: 1, r: 0, neighbors: ["A", "C", "E"] }, C: { id: "C", q: 2, r: 0, neighbors: ["B", "D"] }, D: { id: "D", q: 3, r: 0, neighbors: ["C"] }, E: { id: "E", q: 1, r: 1, neighbors: ["B"] }, }, edgeList: [ ["A", "B"], ["B", "C"], ["C", "D"], ["B", "E"], ], }, thiefStartNodeId: "D", guardStartNodeId: "B", thiefNodeId: "D", guardNodeId: "B", exitNodeId: "A", status: "playing" as const, }, meta: { hasEscapePath: true, pathLength: 3, attemptsUsed: 1 }, }; mockedGenerateChaseRound.mockReturnValue(baseRound); const hardModel = ChaseGameModel.createWithSeed( { seed: 77, difficulty: "hard" }, { random: () => 0.99 }, ); hardModel.moveThief("C"); expect(hardModel.getState().snapshot.guardNodeId).toBe("C"); mockedGenerateChaseRound.mockReturnValue({ ...baseRound, snapshot: { ...baseRound.snapshot, difficulty: "easy", thiefNodeId: "D", guardNodeId: "B", }, }); const easyModel = ChaseGameModel.createWithSeed( { seed: 77, difficulty: "easy" }, { random: () => 0.95 }, ); easyModel.moveThief("C"); expect(easyModel.getState().snapshot.guardNodeId).toBe("E"); }); it("normal difficulty boundary uses shortest at 0.85 and non-shortest above", () => { const round = { snapshot: { seed: 88, difficulty: "normal" as const, graph: { nodes: { A: { id: "A", q: 0, r: 0, neighbors: ["B"] }, B: { id: "B", q: 1, r: 0, neighbors: ["A", "C", "E"] }, C: { id: "C", q: 2, r: 0, neighbors: ["B", "D"] }, D: { id: "D", q: 3, r: 0, neighbors: ["C"] }, E: { id: "E", q: 1, r: 1, neighbors: ["B"] }, }, edgeList: [ ["A", "B"], ["B", "C"], ["C", "D"], ["B", "E"], ], }, thiefStartNodeId: "D", guardStartNodeId: "B", thiefNodeId: "D", guardNodeId: "B", exitNodeId: "A", status: "playing" as const, }, meta: { hasEscapePath: true, pathLength: 3, attemptsUsed: 1 }, }; mockedGenerateChaseRound.mockReturnValue(round); const atBoundary = ChaseGameModel.createWithSeed( { seed: 88, difficulty: "normal" }, { random: () => 0.85 }, ); atBoundary.moveThief("C"); expect(atBoundary.getState().snapshot.guardNodeId).toBe("C"); mockedGenerateChaseRound.mockReturnValue(round); let call = 0; const aboveBoundary = ChaseGameModel.createWithSeed( { seed: 88, difficulty: "normal" }, { random: () => { call += 1; return call === 1 ? 0.8501 : 0.9; }, }, ); aboveBoundary.moveThief("C"); expect(aboveBoundary.getState().snapshot.guardNodeId).toBe("E"); }); });