Browse Source

fix(chase): restore snapshot state shape and retry semantics

Made-with: Cursor
master
npmrun 2 weeks ago
parent
commit
fc14e1deb8
  1. 91
      src/game/chase/model.ts
  2. 57
      tests/chase/chaseModel.test.ts

91
src/game/chase/model.ts

@ -9,17 +9,14 @@ import type { Difficulty, NodeId } from "./types";
type GameStatus = "playing" | "win" | "lose"; type GameStatus = "playing" | "win" | "lose";
interface ChaseGameState { type ModelSnapshot = Omit<ChaseRound["snapshot"], "status"> & {
seed: number;
difficulty: Difficulty;
status: GameStatus; status: GameStatus;
};
interface ChaseGameState {
snapshot: ModelSnapshot;
winCount: number; winCount: number;
thiefNodeId: NodeId; turn: number;
guardNodeId: NodeId;
thiefStartNodeId: NodeId;
guardStartNodeId: NodeId;
exitNodeId: NodeId;
graph: ChaseRound["snapshot"]["graph"];
} }
interface ChaseGameModelOptions { interface ChaseGameModelOptions {
@ -34,25 +31,26 @@ const GUARD_SHORTEST_RATE: Record<Difficulty, number> = {
function cloneRound(round: ChaseRound): ChaseGameState { function cloneRound(round: ChaseRound): ChaseGameState {
return { return {
seed: round.snapshot.seed, snapshot: {
difficulty: round.snapshot.difficulty, ...round.snapshot,
status: round.snapshot.status, status: "playing",
},
winCount: 0, winCount: 0,
thiefNodeId: round.snapshot.thiefNodeId, turn: 0,
guardNodeId: round.snapshot.guardNodeId,
thiefStartNodeId: round.snapshot.thiefStartNodeId,
guardStartNodeId: round.snapshot.guardStartNodeId,
exitNodeId: round.snapshot.exitNodeId,
graph: round.snapshot.graph,
}; };
} }
export class ChaseGameModel { export class ChaseGameModel {
private state: ChaseGameState; private state: ChaseGameState;
private initialSnapshot: ModelSnapshot;
private random: () => number; private random: () => number;
private constructor(state: ChaseGameState, random: () => number) { private constructor(state: ChaseGameState, random: () => number) {
this.state = state; this.state = state;
this.initialSnapshot = {
...state.snapshot,
graph: state.snapshot.graph,
};
this.random = random; this.random = random;
} }
@ -62,29 +60,32 @@ export class ChaseGameModel {
): ChaseGameModel { ): ChaseGameModel {
const round = generateChaseRound(input); const round = generateChaseRound(input);
const state = cloneRound(round); 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); return new ChaseGameModel(state, rng);
} }
getState(): ChaseGameState { getState(): ChaseGameState {
return { ...this.state }; return {
...this.state,
snapshot: { ...this.state.snapshot },
};
} }
getAvailableMoves(): NodeId[] { getAvailableMoves(): NodeId[] {
if (this.state.status !== "playing") { if (this.state.snapshot.status !== "playing") {
return []; return [];
} }
const node = this.state.graph.nodes[this.state.thiefNodeId]; const node = this.state.snapshot.graph.nodes[this.state.snapshot.thiefNodeId];
if (!node) { if (!node) {
return []; return [];
} }
return node.neighbors.filter((id) => id !== this.state.guardNodeId); return node.neighbors.filter((id) => id !== this.state.snapshot.guardNodeId);
} }
moveThief(target: NodeId): ChaseGameState { moveThief(target: NodeId): ChaseGameState {
if (this.state.status !== "playing") { if (this.state.snapshot.status !== "playing") {
return this.getState(); return this.getState();
} }
@ -93,43 +94,55 @@ export class ChaseGameModel {
return this.getState(); return this.getState();
} }
this.state.thiefNodeId = target; this.state.turn += 1;
if (this.state.thiefNodeId === this.state.exitNodeId) { this.state.snapshot.thiefNodeId = target;
this.state.status = "win"; if (this.state.snapshot.thiefNodeId === this.state.snapshot.exitNodeId) {
this.state.snapshot.status = "win";
this.state.winCount += 1; this.state.winCount += 1;
return this.getState(); return this.getState();
} }
this.moveGuardOneStep(); this.moveGuardOneStep();
if (this.state.guardNodeId === this.state.thiefNodeId) { if (this.state.snapshot.guardNodeId === this.state.snapshot.thiefNodeId) {
this.state.status = "lose"; this.state.snapshot.status = "lose";
return this.getState(); return this.getState();
} }
if (this.getAvailableMoves().length === 0) { if (this.getAvailableMoves().length === 0) {
this.state.status = "lose"; this.state.snapshot.status = "lose";
} }
return this.getState(); return this.getState();
} }
retryRound(): ChaseGameState { 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 { newRound(seed: number | string, difficulty: Difficulty): ChaseGameState {
const round = generateChaseRound({ seed, difficulty }); const round = generateChaseRound({ seed, difficulty });
const winCount = this.state.winCount; const winCount = this.state.winCount;
this.state = { const newState = cloneRound(round);
...cloneRound(round), this.state = { ...newState, winCount };
winCount, this.initialSnapshot = {
...newState.snapshot,
graph: newState.snapshot.graph,
}; };
return this.getState(); return this.getState();
} }
private moveGuardOneStep(): void { 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) { if (!guardNode || guardNode.neighbors.length === 0) {
return; return;
} }
@ -137,9 +150,9 @@ export class ChaseGameModel {
const movesWithDistance = guardNode.neighbors.map((neighborId) => ({ const movesWithDistance = guardNode.neighbors.map((neighborId) => ({
nodeId: neighborId, nodeId: neighborId,
distance: shortestPathLength( distance: shortestPathLength(
this.state.graph, this.state.snapshot.graph,
neighborId, neighborId,
this.state.thiefNodeId, this.state.snapshot.thiefNodeId,
), ),
})); }));
@ -153,13 +166,13 @@ export class ChaseGameModel {
const shortestMoves = finiteMoves.filter((item) => item.distance === shortestDistance); const shortestMoves = finiteMoves.filter((item) => item.distance === shortestDistance);
const nonShortestMoves = 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]; let nextMove = shortestMoves[0];
if (shortestRate < 1 && this.random() > shortestRate && nonShortestMoves.length > 0) { if (shortestRate < 1 && this.random() > shortestRate && nonShortestMoves.length > 0) {
const idx = Math.floor(this.random() * nonShortestMoves.length); const idx = Math.floor(this.random() * nonShortestMoves.length);
nextMove = nonShortestMoves[idx]; nextMove = nonShortestMoves[idx];
} }
this.state.guardNodeId = nextMove.nodeId; this.state.snapshot.guardNodeId = nextMove.nodeId;
} }
} }

57
tests/chase/chaseModel.test.ts

@ -58,12 +58,13 @@ describe("chase game model", () => {
}); });
const state = model.getState(); const state = model.getState();
expect(state.seed).toBe(123); expect(state.snapshot.seed).toBe(123);
expect(state.difficulty).toBe("normal"); expect(state.snapshot.difficulty).toBe("normal");
expect(state.status).toBe("playing"); expect(state.snapshot.status).toBe("playing");
expect(state.thiefNodeId).toBe("B"); expect(state.snapshot.thiefNodeId).toBe("B");
expect(state.guardNodeId).toBe("D"); expect(state.snapshot.guardNodeId).toBe("D");
expect(state.exitNodeId).toBe("C"); expect(state.snapshot.exitNodeId).toBe("C");
expect(state.turn).toBe(0);
expect(model.getAvailableMoves()).toEqual(["A", "C"]); expect(model.getAvailableMoves()).toEqual(["A", "C"]);
}); });
@ -73,9 +74,10 @@ describe("chase game model", () => {
model.moveThief("C"); model.moveThief("C");
const state = model.getState(); const state = model.getState();
expect(state.status).toBe("win"); expect(state.snapshot.status).toBe("win");
expect(state.winCount).toBe(1); 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", () => { it("guard moves after thief and can cause lose by capture", () => {
@ -95,9 +97,10 @@ describe("chase game model", () => {
model.moveThief("B"); model.moveThief("B");
const state = model.getState(); const state = model.getState();
expect(state.guardNodeId).toBe("B"); expect(state.snapshot.guardNodeId).toBe("B");
expect(state.thiefNodeId).toBe("B"); expect(state.snapshot.thiefNodeId).toBe("B");
expect(state.status).toBe("lose"); expect(state.snapshot.status).toBe("lose");
expect(state.turn).toBe(1);
}); });
it("loses when thief has no legal move after guard turn", () => { 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" }); const model = ChaseGameModel.createWithSeed({ seed: 33, difficulty: "normal" });
model.moveThief("B"); model.moveThief("B");
const state = model.getState(); const state = model.getState();
expect(state.status).toBe("lose"); expect(state.snapshot.status).toBe("lose");
expect(model.getAvailableMoves()).toEqual([]); expect(model.getAvailableMoves()).toEqual([]);
expect(state.turn).toBe(1);
}); });
it("retryRound regenerates same seed/difficulty but keeps winCount", () => { it("retryRound restores the same initial round snapshot and keeps winCount", () => {
mockedGenerateChaseRound mockedGenerateChaseRound.mockReturnValueOnce(makeRound(44, "easy"));
.mockReturnValueOnce(makeRound(44, "easy"))
.mockReturnValueOnce({
...makeRound(44, "easy"),
snapshot: { ...makeRound(44, "easy").snapshot, thiefNodeId: "A" },
});
const model = ChaseGameModel.createWithSeed({ seed: 44, difficulty: "easy" }); const model = ChaseGameModel.createWithSeed({ seed: 44, difficulty: "easy" });
const initialState = model.getState();
model.moveThief("C"); model.moveThief("C");
expect(model.getState().winCount).toBe(1); expect(model.getState().winCount).toBe(1);
model.retryRound(); model.retryRound();
const state = model.getState(); const state = model.getState();
expect(state.seed).toBe(44); expect(state.snapshot).toEqual(initialState.snapshot);
expect(state.difficulty).toBe("easy"); expect(state.snapshot.status).toBe("playing");
expect(state.thiefNodeId).toBe("A");
expect(state.status).toBe("playing");
expect(state.winCount).toBe(1); expect(state.winCount).toBe(1);
expect(state.turn).toBe(0);
expect(mockedGenerateChaseRound).toHaveBeenCalledTimes(1);
}); });
it("newRound switches seed/difficulty and keeps winCount", () => { it("newRound switches seed/difficulty and keeps winCount", () => {
@ -165,10 +165,11 @@ describe("chase game model", () => {
model.newRound(99, "hard"); model.newRound(99, "hard");
const state = model.getState(); const state = model.getState();
expect(state.seed).toBe(99); expect(state.snapshot.seed).toBe(99);
expect(state.difficulty).toBe("hard"); expect(state.snapshot.difficulty).toBe("hard");
expect(state.status).toBe("playing"); expect(state.snapshot.status).toBe("playing");
expect(state.winCount).toBe(1); expect(state.winCount).toBe(1);
expect(state.turn).toBe(0);
}); });
it("guard strategy follows difficulty probability rule", () => { it("guard strategy follows difficulty probability rule", () => {
@ -207,7 +208,7 @@ describe("chase game model", () => {
{ random: () => 0.99 }, { random: () => 0.99 },
); );
hardModel.moveThief("C"); hardModel.moveThief("C");
expect(hardModel.getState().guardNodeId).toBe("C"); expect(hardModel.getState().snapshot.guardNodeId).toBe("C");
mockedGenerateChaseRound.mockReturnValue({ mockedGenerateChaseRound.mockReturnValue({
...baseRound, ...baseRound,
@ -223,6 +224,6 @@ describe("chase game model", () => {
{ random: () => 0.95 }, { random: () => 0.95 },
); );
easyModel.moveThief("C"); easyModel.moveThief("C");
expect(easyModel.getState().guardNodeId).toBe("E"); expect(easyModel.getState().snapshot.guardNodeId).toBe("E");
}); });
}); });

Loading…
Cancel
Save