Browse Source

fix(chase): reset rng on round reset and harden model state safety

Made-with: Cursor
master
npmrun 2 weeks ago
parent
commit
c57a8d9f57
  1. 50
      src/game/chase/model.ts
  2. 167
      tests/chase/chaseModel.test.ts

50
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) {

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

Loading…
Cancel
Save