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"; type ModelSnapshot = Omit & { status: GameStatus; }; interface ChaseGameState { snapshot: ModelSnapshot; winCount: number; turn: number; loseReason?: "caught" | "trapped"; } interface ChaseGameModelOptions { random?: () => number; } const GUARD_SHORTEST_RATE: Record = { easy: 0.6, normal: 0.85, hard: 1, }; function cloneRound(round: ChaseRound): ChaseGameState { return { snapshot: { ...round.snapshot, status: "playing", }, winCount: 0, turn: 0, loseReason: undefined, }; } function cloneSnapshot(snapshot: ModelSnapshot): ModelSnapshot { const graph = { nodes: Object.fromEntries( Object.entries(snapshot.graph.nodes).map(([id, node]) => [ id, { ...node, neighbors: [...node.neighbors] }, ]), ), edgeList: snapshot.graph.edgeList.map(([a, b]) => [a, b] as [NodeId, NodeId]), }; return { ...snapshot, graph, }; } export class ChaseGameModel { private state: ChaseGameState; private initialSnapshot: ModelSnapshot; private readonly randomOverride?: () => number; private random: () => number; private constructor(state: ChaseGameState, random: () => number, randomOverride?: () => number) { this.state = state; this.initialSnapshot = cloneSnapshot(state.snapshot); this.randomOverride = randomOverride; 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.snapshot.seed)); return new ChaseGameModel(state, rng, options.random); } getState(): ChaseGameState { return { ...this.state, snapshot: cloneSnapshot(this.state.snapshot), }; } getAvailableMoves(): NodeId[] { if (this.state.snapshot.status !== "playing") { return []; } const node = this.state.snapshot.graph.nodes[this.state.snapshot.thiefNodeId]; if (!node) { return []; } return node.neighbors.filter((id) => id !== this.state.snapshot.guardNodeId); } moveThief(target: NodeId): ChaseGameState { if (this.state.snapshot.status !== "playing") { return this.getState(); } const availableMoves = this.getAvailableMoves(); if (!availableMoves.includes(target)) { return this.getState(); } this.state.turn += 1; this.state.snapshot.thiefNodeId = target; if (this.state.snapshot.thiefNodeId === this.state.snapshot.exitNodeId) { this.state.snapshot.status = "win"; this.state.winCount += 1; this.state.loseReason = undefined; return this.getState(); } this.moveGuardOneStep(); if (this.state.snapshot.guardNodeId === this.state.snapshot.thiefNodeId) { this.state.snapshot.status = "lose"; this.state.loseReason = "caught"; return this.getState(); } if (this.getAvailableMoves().length === 0) { this.state.snapshot.status = "lose"; this.state.loseReason = "trapped"; } return this.getState(); } retryRound(): ChaseGameState { const winCount = this.state.winCount; this.resetRng(this.initialSnapshot.seed); this.state = { snapshot: cloneSnapshot(this.initialSnapshot), winCount, turn: 0, loseReason: undefined, }; return this.getState(); } newRound(seed: number | string, difficulty: Difficulty): ChaseGameState { const round = generateChaseRound({ seed, difficulty }); const winCount = this.state.winCount; const newState = cloneRound(round); this.state = { ...newState, winCount }; this.initialSnapshot = cloneSnapshot(newState.snapshot); this.resetRng(newState.snapshot.seed); return this.getState(); } private resetRng(seed: number): void { if (this.randomOverride) { this.random = this.randomOverride; return; } this.random = createRng(normalizeSeed(seed)); } private moveGuardOneStep(): void { const guardNode = this.state.snapshot.graph.nodes[this.state.snapshot.guardNodeId]; if (!guardNode || guardNode.neighbors.length === 0) { return; } const movesWithDistance = guardNode.neighbors.map((neighborId) => ({ nodeId: neighborId, distance: shortestPathLength( this.state.snapshot.graph, neighborId, this.state.snapshot.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.snapshot.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.snapshot.guardNodeId = nextMove.nodeId; } }