From ee2415695888319352ed1d2bd70f6be479c1c1e6 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sun, 26 Apr 2026 23:27:45 +0800 Subject: [PATCH] feat(chase): implement turn-based chase model with retry/new round flow Made-with: Cursor --- src/game/chase/model.ts | 165 +++++++++++++++++++++++++++++ tests/chase/chaseModel.test.ts | 228 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 393 insertions(+) create mode 100644 src/game/chase/model.ts create mode 100644 tests/chase/chaseModel.test.ts diff --git a/src/game/chase/model.ts b/src/game/chase/model.ts new file mode 100644 index 0000000..17ff41d --- /dev/null +++ b/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 = { + 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; + } +} diff --git a/tests/chase/chaseModel.test.ts b/tests/chase/chaseModel.test.ts new file mode 100644 index 0000000..db95006 --- /dev/null +++ b/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"); + }); +});