You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
492 lines
16 KiB
492 lines
16 KiB
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { ChaseGameModel } from "../../src/stages/games/chase/logic/model";
|
|
import type { Difficulty, GameGraph } from "../../src/stages/games/chase/logic/types";
|
|
|
|
const mockedGenerateChaseRound = vi.fn();
|
|
|
|
vi.mock("../../src/stages/games/chase/logic/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("retryRound keeps exactly same graph and start positions", () => {
|
|
mockedGenerateChaseRound.mockReturnValueOnce(makeRound(88, "normal"));
|
|
const model = ChaseGameModel.createWithSeed({ seed: 88, difficulty: "normal" });
|
|
const initial = model.getState().snapshot;
|
|
|
|
model.moveThief("A");
|
|
model.retryRound();
|
|
const retried = model.getState().snapshot;
|
|
|
|
expect(retried.graph).toEqual(initial.graph);
|
|
expect(retried.thiefStartNodeId).toBe(initial.thiefStartNodeId);
|
|
expect(retried.guardStartNodeId).toBe(initial.guardStartNodeId);
|
|
expect(retried.exitNodeId).toBe(initial.exitNodeId);
|
|
expect(retried.thiefNodeId).toBe(initial.thiefNodeId);
|
|
expect(retried.guardNodeId).toBe(initial.guardNodeId);
|
|
});
|
|
|
|
it("clears loseReason after retryRound", () => {
|
|
mockedGenerateChaseRound.mockReturnValueOnce({
|
|
...makeRound(99, "normal"),
|
|
snapshot: {
|
|
...makeRound(99, "normal").snapshot,
|
|
thiefStartNodeId: "A",
|
|
thiefNodeId: "A",
|
|
guardStartNodeId: "C",
|
|
guardNodeId: "C",
|
|
exitNodeId: "D",
|
|
},
|
|
});
|
|
const model = ChaseGameModel.createWithSeed({ seed: 99, difficulty: "normal" });
|
|
model.moveThief("B");
|
|
expect(model.getState().loseReason).toBe("caught");
|
|
|
|
model.retryRound();
|
|
expect(model.getState().loseReason).toBeUndefined();
|
|
});
|
|
|
|
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("newRound with new seed produces a new round snapshot", () => {
|
|
const base = makeRound(300, "normal");
|
|
const next = makeRound(301, "normal");
|
|
next.snapshot.graph.nodes.E = { id: "E", q: 4, r: 0, neighbors: [] };
|
|
next.snapshot.thiefStartNodeId = "A";
|
|
next.snapshot.guardStartNodeId = "C";
|
|
next.snapshot.exitNodeId = "D";
|
|
next.snapshot.thiefNodeId = "A";
|
|
next.snapshot.guardNodeId = "C";
|
|
|
|
mockedGenerateChaseRound.mockReturnValueOnce(base).mockReturnValueOnce(next);
|
|
const model = ChaseGameModel.createWithSeed({ seed: 300, difficulty: "normal" });
|
|
const before = model.getState().snapshot;
|
|
|
|
model.newRound(301, "normal");
|
|
const after = model.getState().snapshot;
|
|
|
|
expect(after.seed).toBe(301);
|
|
expect(after).not.toEqual(before);
|
|
expect(Object.keys(after.graph.nodes)).toContain("E");
|
|
expect(after.thiefStartNodeId).toBe("A");
|
|
});
|
|
|
|
it("clears loseReason after newRound", () => {
|
|
mockedGenerateChaseRound
|
|
.mockReturnValueOnce({
|
|
...makeRound(120, "hard"),
|
|
snapshot: {
|
|
...makeRound(120, "hard").snapshot,
|
|
thiefStartNodeId: "A",
|
|
thiefNodeId: "A",
|
|
guardStartNodeId: "C",
|
|
guardNodeId: "C",
|
|
exitNodeId: "D",
|
|
},
|
|
})
|
|
.mockReturnValueOnce(makeRound(121, "normal"));
|
|
|
|
const model = ChaseGameModel.createWithSeed({ seed: 120, difficulty: "hard" });
|
|
model.moveThief("B");
|
|
expect(model.getState().loseReason).toBe("caught");
|
|
|
|
model.newRound(121, "normal");
|
|
expect(model.getState().loseReason).toBeUndefined();
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|
|
|