You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

204 lines
5.6 KiB

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<ChaseRound["snapshot"], "status"> & {
status: GameStatus;
};
interface ChaseGameState {
snapshot: ModelSnapshot;
winCount: number;
turn: number;
loseReason?: "caught" | "trapped";
}
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 {
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;
}
}