From c57a8d9f5741e5ce33248bc3aacaa6154502cda1 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sun, 26 Apr 2026 23:32:41 +0800 Subject: [PATCH] fix(chase): reset rng on round reset and harden model state safety Made-with: Cursor --- src/game/chase/model.ts | 50 ++++++++---- tests/chase/chaseModel.test.ts | 167 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 15 deletions(-) diff --git a/src/game/chase/model.ts b/src/game/chase/model.ts index 77d19de..47ce72f 100644 --- a/src/game/chase/model.ts +++ b/src/game/chase/model.ts @@ -40,17 +40,33 @@ function cloneRound(round: ChaseRound): ChaseGameState { }; } +function cloneSnapshot(snapshot: ModelSnapshot): ModelSnapshot { + const graph = { + nodes: Object.fromEntries( + Object.entries(snapshot.graph.nodes).map(([id, node]) => [ + id, + { ...node, neighbors: [...node.neighbors] }, + ]), + ), + edgeList: snapshot.graph.edgeList.map(([a, b]) => [a, b] as [NodeId, NodeId]), + }; + + return { + ...snapshot, + graph, + }; +} + export class ChaseGameModel { private state: ChaseGameState; private initialSnapshot: ModelSnapshot; + private readonly randomOverride?: () => number; private random: () => number; - private constructor(state: ChaseGameState, random: () => number) { + private constructor(state: ChaseGameState, random: () => number, randomOverride?: () => number) { this.state = state; - this.initialSnapshot = { - ...state.snapshot, - graph: state.snapshot.graph, - }; + this.initialSnapshot = cloneSnapshot(state.snapshot); + this.randomOverride = randomOverride; this.random = random; } @@ -61,13 +77,13 @@ export class ChaseGameModel { const round = generateChaseRound(input); const state = cloneRound(round); const rng = options.random ?? createRng(normalizeSeed(state.snapshot.seed)); - return new ChaseGameModel(state, rng); + return new ChaseGameModel(state, rng, options.random); } getState(): ChaseGameState { return { ...this.state, - snapshot: { ...this.state.snapshot }, + snapshot: cloneSnapshot(this.state.snapshot), }; } @@ -118,11 +134,9 @@ export class ChaseGameModel { retryRound(): ChaseGameState { const winCount = this.state.winCount; + this.resetRng(this.initialSnapshot.seed); this.state = { - snapshot: { - ...this.initialSnapshot, - graph: this.initialSnapshot.graph, - }, + snapshot: cloneSnapshot(this.initialSnapshot), winCount, turn: 0, }; @@ -134,13 +148,19 @@ export class ChaseGameModel { const winCount = this.state.winCount; const newState = cloneRound(round); this.state = { ...newState, winCount }; - this.initialSnapshot = { - ...newState.snapshot, - graph: newState.snapshot.graph, - }; + this.initialSnapshot = cloneSnapshot(newState.snapshot); + this.resetRng(newState.snapshot.seed); return this.getState(); } + private resetRng(seed: number): void { + if (this.randomOverride) { + this.random = this.randomOverride; + return; + } + this.random = createRng(normalizeSeed(seed)); + } + private moveGuardOneStep(): void { const guardNode = this.state.snapshot.graph.nodes[this.state.snapshot.guardNodeId]; if (!guardNode || guardNode.neighbors.length === 0) { diff --git a/tests/chase/chaseModel.test.ts b/tests/chase/chaseModel.test.ts index 39c45b0..689bb9c 100644 --- a/tests/chase/chaseModel.test.ts +++ b/tests/chase/chaseModel.test.ts @@ -68,6 +68,25 @@ describe("chase game model", () => { 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" }); @@ -172,6 +191,101 @@ describe("chase game model", () => { expect(state.turn).toBe(0); }); + 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: { @@ -226,4 +340,57 @@ describe("chase game model", () => { 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"); + }); });