diff --git a/src/game/chase/model.ts b/src/game/chase/model.ts index 17ff41d..77d19de 100644 --- a/src/game/chase/model.ts +++ b/src/game/chase/model.ts @@ -9,17 +9,14 @@ import type { Difficulty, NodeId } from "./types"; type GameStatus = "playing" | "win" | "lose"; -interface ChaseGameState { - seed: number; - difficulty: Difficulty; +type ModelSnapshot = Omit & { status: GameStatus; +}; + +interface ChaseGameState { + snapshot: ModelSnapshot; winCount: number; - thiefNodeId: NodeId; - guardNodeId: NodeId; - thiefStartNodeId: NodeId; - guardStartNodeId: NodeId; - exitNodeId: NodeId; - graph: ChaseRound["snapshot"]["graph"]; + turn: number; } interface ChaseGameModelOptions { @@ -34,25 +31,26 @@ const GUARD_SHORTEST_RATE: Record = { function cloneRound(round: ChaseRound): ChaseGameState { return { - seed: round.snapshot.seed, - difficulty: round.snapshot.difficulty, - status: round.snapshot.status, + snapshot: { + ...round.snapshot, + status: "playing", + }, winCount: 0, - thiefNodeId: round.snapshot.thiefNodeId, - guardNodeId: round.snapshot.guardNodeId, - thiefStartNodeId: round.snapshot.thiefStartNodeId, - guardStartNodeId: round.snapshot.guardStartNodeId, - exitNodeId: round.snapshot.exitNodeId, - graph: round.snapshot.graph, + turn: 0, }; } export class ChaseGameModel { private state: ChaseGameState; + private initialSnapshot: ModelSnapshot; private random: () => number; private constructor(state: ChaseGameState, random: () => number) { this.state = state; + this.initialSnapshot = { + ...state.snapshot, + graph: state.snapshot.graph, + }; this.random = random; } @@ -62,29 +60,32 @@ export class ChaseGameModel { ): ChaseGameModel { const round = generateChaseRound(input); const state = cloneRound(round); - const rng = options.random ?? createRng(normalizeSeed(state.seed)); + const rng = options.random ?? createRng(normalizeSeed(state.snapshot.seed)); return new ChaseGameModel(state, rng); } getState(): ChaseGameState { - return { ...this.state }; + return { + ...this.state, + snapshot: { ...this.state.snapshot }, + }; } getAvailableMoves(): NodeId[] { - if (this.state.status !== "playing") { + if (this.state.snapshot.status !== "playing") { return []; } - const node = this.state.graph.nodes[this.state.thiefNodeId]; + const node = this.state.snapshot.graph.nodes[this.state.snapshot.thiefNodeId]; if (!node) { return []; } - return node.neighbors.filter((id) => id !== this.state.guardNodeId); + return node.neighbors.filter((id) => id !== this.state.snapshot.guardNodeId); } moveThief(target: NodeId): ChaseGameState { - if (this.state.status !== "playing") { + if (this.state.snapshot.status !== "playing") { return this.getState(); } @@ -93,43 +94,55 @@ export class ChaseGameModel { return this.getState(); } - this.state.thiefNodeId = target; - if (this.state.thiefNodeId === this.state.exitNodeId) { - this.state.status = "win"; + this.state.turn += 1; + this.state.snapshot.thiefNodeId = target; + if (this.state.snapshot.thiefNodeId === this.state.snapshot.exitNodeId) { + this.state.snapshot.status = "win"; this.state.winCount += 1; return this.getState(); } this.moveGuardOneStep(); - if (this.state.guardNodeId === this.state.thiefNodeId) { - this.state.status = "lose"; + if (this.state.snapshot.guardNodeId === this.state.snapshot.thiefNodeId) { + this.state.snapshot.status = "lose"; return this.getState(); } if (this.getAvailableMoves().length === 0) { - this.state.status = "lose"; + this.state.snapshot.status = "lose"; } return this.getState(); } retryRound(): ChaseGameState { - return this.newRound(this.state.seed, this.state.difficulty); + const winCount = this.state.winCount; + this.state = { + snapshot: { + ...this.initialSnapshot, + graph: this.initialSnapshot.graph, + }, + winCount, + turn: 0, + }; + return this.getState(); } newRound(seed: number | string, difficulty: Difficulty): ChaseGameState { const round = generateChaseRound({ seed, difficulty }); const winCount = this.state.winCount; - this.state = { - ...cloneRound(round), - winCount, + const newState = cloneRound(round); + this.state = { ...newState, winCount }; + this.initialSnapshot = { + ...newState.snapshot, + graph: newState.snapshot.graph, }; return this.getState(); } private moveGuardOneStep(): void { - const guardNode = this.state.graph.nodes[this.state.guardNodeId]; + const guardNode = this.state.snapshot.graph.nodes[this.state.snapshot.guardNodeId]; if (!guardNode || guardNode.neighbors.length === 0) { return; } @@ -137,9 +150,9 @@ export class ChaseGameModel { const movesWithDistance = guardNode.neighbors.map((neighborId) => ({ nodeId: neighborId, distance: shortestPathLength( - this.state.graph, + this.state.snapshot.graph, neighborId, - this.state.thiefNodeId, + this.state.snapshot.thiefNodeId, ), })); @@ -153,13 +166,13 @@ export class ChaseGameModel { const shortestMoves = finiteMoves.filter((item) => item.distance === shortestDistance); const nonShortestMoves = finiteMoves.filter((item) => item.distance !== shortestDistance); - const shortestRate = GUARD_SHORTEST_RATE[this.state.difficulty]; + const shortestRate = GUARD_SHORTEST_RATE[this.state.snapshot.difficulty]; let nextMove = shortestMoves[0]; if (shortestRate < 1 && this.random() > shortestRate && nonShortestMoves.length > 0) { const idx = Math.floor(this.random() * nonShortestMoves.length); nextMove = nonShortestMoves[idx]; } - this.state.guardNodeId = nextMove.nodeId; + this.state.snapshot.guardNodeId = nextMove.nodeId; } } diff --git a/tests/chase/chaseModel.test.ts b/tests/chase/chaseModel.test.ts index db95006..39c45b0 100644 --- a/tests/chase/chaseModel.test.ts +++ b/tests/chase/chaseModel.test.ts @@ -58,12 +58,13 @@ describe("chase game model", () => { }); const state = model.getState(); - expect(state.seed).toBe(123); - expect(state.difficulty).toBe("normal"); - expect(state.status).toBe("playing"); - expect(state.thiefNodeId).toBe("B"); - expect(state.guardNodeId).toBe("D"); - expect(state.exitNodeId).toBe("C"); + 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"]); }); @@ -73,9 +74,10 @@ describe("chase game model", () => { model.moveThief("C"); const state = model.getState(); - expect(state.status).toBe("win"); + expect(state.snapshot.status).toBe("win"); expect(state.winCount).toBe(1); - expect(state.thiefNodeId).toBe("C"); + expect(state.snapshot.thiefNodeId).toBe("C"); + expect(state.turn).toBe(1); }); it("guard moves after thief and can cause lose by capture", () => { @@ -95,9 +97,10 @@ describe("chase game model", () => { model.moveThief("B"); const state = model.getState(); - expect(state.guardNodeId).toBe("B"); - expect(state.thiefNodeId).toBe("B"); - expect(state.status).toBe("lose"); + 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", () => { @@ -129,29 +132,26 @@ describe("chase game model", () => { const model = ChaseGameModel.createWithSeed({ seed: 33, difficulty: "normal" }); model.moveThief("B"); const state = model.getState(); - expect(state.status).toBe("lose"); + expect(state.snapshot.status).toBe("lose"); expect(model.getAvailableMoves()).toEqual([]); + expect(state.turn).toBe(1); }); - it("retryRound regenerates same seed/difficulty but keeps winCount", () => { - mockedGenerateChaseRound - .mockReturnValueOnce(makeRound(44, "easy")) - .mockReturnValueOnce({ - ...makeRound(44, "easy"), - snapshot: { ...makeRound(44, "easy").snapshot, thiefNodeId: "A" }, - }); + 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.seed).toBe(44); - expect(state.difficulty).toBe("easy"); - expect(state.thiefNodeId).toBe("A"); - expect(state.status).toBe("playing"); + 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", () => { @@ -165,10 +165,11 @@ describe("chase game model", () => { model.newRound(99, "hard"); const state = model.getState(); - expect(state.seed).toBe(99); - expect(state.difficulty).toBe("hard"); - expect(state.status).toBe("playing"); + 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", () => { @@ -207,7 +208,7 @@ describe("chase game model", () => { { random: () => 0.99 }, ); hardModel.moveThief("C"); - expect(hardModel.getState().guardNodeId).toBe("C"); + expect(hardModel.getState().snapshot.guardNodeId).toBe("C"); mockedGenerateChaseRound.mockReturnValue({ ...baseRound, @@ -223,6 +224,6 @@ describe("chase game model", () => { { random: () => 0.95 }, ); easyModel.moveThief("C"); - expect(easyModel.getState().guardNodeId).toBe("E"); + expect(easyModel.getState().snapshot.guardNodeId).toBe("E"); }); });