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