Browse Source

feat(chase): implement turn-based chase model with retry/new round flow

Made-with: Cursor
master
npmrun 2 weeks ago
parent
commit
ee24156958
  1. 165
      src/game/chase/model.ts
  2. 228
      tests/chase/chaseModel.test.ts

165
src/game/chase/model.ts

@ -0,0 +1,165 @@
import { shortestPathLength } from "./hexGraph";
import {
generateChaseRound,
type ChaseRound,
type GenerateChaseRoundInput,
} from "./generator";
import { createRng, normalizeSeed, type Rng } from "./rng";
import type { Difficulty, NodeId } from "./types";
type GameStatus = "playing" | "win" | "lose";
interface ChaseGameState {
seed: number;
difficulty: Difficulty;
status: GameStatus;
winCount: number;
thiefNodeId: NodeId;
guardNodeId: NodeId;
thiefStartNodeId: NodeId;
guardStartNodeId: NodeId;
exitNodeId: NodeId;
graph: ChaseRound["snapshot"]["graph"];
}
interface ChaseGameModelOptions {
random?: () => number;
}
const GUARD_SHORTEST_RATE: Record<Difficulty, number> = {
easy: 0.6,
normal: 0.85,
hard: 1,
};
function cloneRound(round: ChaseRound): ChaseGameState {
return {
seed: round.snapshot.seed,
difficulty: round.snapshot.difficulty,
status: round.snapshot.status,
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,
};
}
export class ChaseGameModel {
private state: ChaseGameState;
private random: () => number;
private constructor(state: ChaseGameState, random: () => number) {
this.state = state;
this.random = random;
}
static createWithSeed(
input: GenerateChaseRoundInput,
options: ChaseGameModelOptions = {},
): ChaseGameModel {
const round = generateChaseRound(input);
const state = cloneRound(round);
const rng = options.random ?? createRng(normalizeSeed(state.seed));
return new ChaseGameModel(state, rng);
}
getState(): ChaseGameState {
return { ...this.state };
}
getAvailableMoves(): NodeId[] {
if (this.state.status !== "playing") {
return [];
}
const node = this.state.graph.nodes[this.state.thiefNodeId];
if (!node) {
return [];
}
return node.neighbors.filter((id) => id !== this.state.guardNodeId);
}
moveThief(target: NodeId): ChaseGameState {
if (this.state.status !== "playing") {
return this.getState();
}
const availableMoves = this.getAvailableMoves();
if (!availableMoves.includes(target)) {
return this.getState();
}
this.state.thiefNodeId = target;
if (this.state.thiefNodeId === this.state.exitNodeId) {
this.state.status = "win";
this.state.winCount += 1;
return this.getState();
}
this.moveGuardOneStep();
if (this.state.guardNodeId === this.state.thiefNodeId) {
this.state.status = "lose";
return this.getState();
}
if (this.getAvailableMoves().length === 0) {
this.state.status = "lose";
}
return this.getState();
}
retryRound(): ChaseGameState {
return this.newRound(this.state.seed, this.state.difficulty);
}
newRound(seed: number | string, difficulty: Difficulty): ChaseGameState {
const round = generateChaseRound({ seed, difficulty });
const winCount = this.state.winCount;
this.state = {
...cloneRound(round),
winCount,
};
return this.getState();
}
private moveGuardOneStep(): void {
const guardNode = this.state.graph.nodes[this.state.guardNodeId];
if (!guardNode || guardNode.neighbors.length === 0) {
return;
}
const movesWithDistance = guardNode.neighbors.map((neighborId) => ({
nodeId: neighborId,
distance: shortestPathLength(
this.state.graph,
neighborId,
this.state.thiefNodeId,
),
}));
const finiteMoves = movesWithDistance.filter((item) => Number.isFinite(item.distance));
if (finiteMoves.length === 0) {
return;
}
finiteMoves.sort((a, b) => a.distance - b.distance || a.nodeId.localeCompare(b.nodeId));
const shortestDistance = finiteMoves[0].distance;
const shortestMoves = finiteMoves.filter((item) => item.distance === shortestDistance);
const nonShortestMoves = finiteMoves.filter((item) => item.distance !== shortestDistance);
const shortestRate = GUARD_SHORTEST_RATE[this.state.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;
}
}

228
tests/chase/chaseModel.test.ts

@ -0,0 +1,228 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ChaseGameModel } from "../../src/game/chase/model";
import type { Difficulty, GameGraph } from "../../src/game/chase/types";
const mockedGenerateChaseRound = vi.fn();
vi.mock("../../src/game/chase/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.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(model.getAvailableMoves()).toEqual(["A", "C"]);
});
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.status).toBe("win");
expect(state.winCount).toBe(1);
expect(state.thiefNodeId).toBe("C");
});
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.guardNodeId).toBe("B");
expect(state.thiefNodeId).toBe("B");
expect(state.status).toBe("lose");
});
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.status).toBe("lose");
expect(model.getAvailableMoves()).toEqual([]);
});
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" },
});
const model = ChaseGameModel.createWithSeed({ seed: 44, difficulty: "easy" });
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.winCount).toBe(1);
});
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.seed).toBe(99);
expect(state.difficulty).toBe("hard");
expect(state.status).toBe("playing");
expect(state.winCount).toBe(1);
});
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().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().guardNodeId).toBe("E");
});
});
Loading…
Cancel
Save