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("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("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"); }); });