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
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;
|
|
}
|
|
}
|
|
|