2 changed files with 393 additions and 0 deletions
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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…
Reference in new issue