Browse Source

feat(chase): implement seed-based round generator with playability checks

Made-with: Cursor
master
npmrun 2 weeks ago
parent
commit
487d62864e
  1. 204
      src/game/chase/generator.ts
  2. 62
      tests/chase/chaseGenerator.test.ts

204
src/game/chase/generator.ts

@ -0,0 +1,204 @@
import { axialNeighbors, isConnected, shortestPathLength } from "./hexGraph";
import { createRng, normalizeSeed } from "./rng";
import type { Difficulty, GameGraph, GraphNode, NodeId } from "./types";
const MAX_ATTEMPTS = 40;
type CoordKey = `${number},${number}`;
export interface GenerateChaseRoundInput {
seed: number | string;
difficulty: Difficulty;
}
export interface ChaseRound {
graph: GameGraph;
startNodeId: NodeId;
exitNodeId: NodeId;
meta: {
hasEscapePath: boolean;
pathLength: number;
attemptsUsed: number;
};
}
function coordKey(q: number, r: number): CoordKey {
return `${q},${r}`;
}
function parseCoord(id: NodeId): [number, number] {
const [qStr, rStr] = id.split(",");
return [Number(qStr), Number(rStr)];
}
function randomInt(rng: () => number, min: number, maxInclusive: number): number {
return Math.floor(rng() * (maxInclusive - min + 1)) + min;
}
function difficultyNodeRange(difficulty: Difficulty): [number, number] {
if (difficulty === "easy") {
return [20, 24];
}
if (difficulty === "hard") {
return [24, 30];
}
return [22, 27];
}
function minimumPathByDifficulty(difficulty: Difficulty): number {
if (difficulty === "easy") {
return 3;
}
if (difficulty === "hard") {
return 6;
}
return 4;
}
function linkBidirectional(nodes: Record<NodeId, GraphNode>, a: NodeId, b: NodeId): void {
if (!nodes[a].neighbors.includes(b)) {
nodes[a].neighbors.push(b);
}
if (!nodes[b].neighbors.includes(a)) {
nodes[b].neighbors.push(a);
}
}
function buildEdgeList(nodes: Record<NodeId, GraphNode>): Array<[NodeId, NodeId]> {
const edges: Array<[NodeId, NodeId]> = [];
const seen = new Set<string>();
for (const nodeId of Object.keys(nodes)) {
for (const neighborId of nodes[nodeId].neighbors) {
const a = nodeId < neighborId ? nodeId : neighborId;
const b = nodeId < neighborId ? neighborId : nodeId;
const key = `${a}|${b}`;
if (!seen.has(key)) {
seen.add(key);
edges.push([a, b]);
}
}
}
return edges;
}
function getFarthestNode(graph: GameGraph, from: NodeId): { id: NodeId; distance: number } {
const queue: Array<[NodeId, number]> = [[from, 0]];
let head = 0;
const visited = new Set<NodeId>([from]);
let farthest = { id: from, distance: 0 };
while (head < queue.length) {
const [current, distance] = queue[head];
head += 1;
if (distance > farthest.distance) {
farthest = { id: current, distance };
}
for (const neighbor of graph.nodes[current].neighbors) {
if (!visited.has(neighbor) && graph.nodes[neighbor]) {
visited.add(neighbor);
queue.push([neighbor, distance + 1]);
}
}
}
return farthest;
}
function generateGraphAttempt(seed: number, difficulty: Difficulty): ChaseRound {
const rng = createRng(seed);
const [minNodes, maxNodes] = difficultyNodeRange(difficulty);
const targetNodeCount = randomInt(rng, minNodes, maxNodes);
const nodes: Record<NodeId, GraphNode> = {};
const originId = coordKey(0, 0);
nodes[originId] = { id: originId, q: 0, r: 0, neighbors: [] };
const existingNodeIds: NodeId[] = [originId];
while (existingNodeIds.length < targetNodeCount) {
const baseId = existingNodeIds[randomInt(rng, 0, existingNodeIds.length - 1)];
const baseNode = nodes[baseId];
const candidates = axialNeighbors(baseNode.q, baseNode.r).filter(([q, r]) => {
const id = coordKey(q, r);
return !nodes[id];
});
if (candidates.length === 0) {
continue;
}
const [q, r] = candidates[randomInt(rng, 0, candidates.length - 1)];
const newId = coordKey(q, r);
nodes[newId] = { id: newId, q, r, neighbors: [] };
existingNodeIds.push(newId);
linkBidirectional(nodes, baseId, newId);
}
const extraEdges = Math.floor(targetNodeCount * 0.2);
for (let i = 0; i < extraEdges; i += 1) {
const nodeId = existingNodeIds[randomInt(rng, 0, existingNodeIds.length - 1)];
const [q, r] = parseCoord(nodeId);
const adjacentExisting = axialNeighbors(q, r)
.map(([nq, nr]) => coordKey(nq, nr))
.filter((neighborId) => nodes[neighborId] && neighborId !== nodeId);
if (adjacentExisting.length === 0) {
continue;
}
const neighborId =
adjacentExisting[randomInt(rng, 0, adjacentExisting.length - 1)];
linkBidirectional(nodes, nodeId, neighborId);
}
const graph: GameGraph = {
nodes,
edgeList: buildEdgeList(nodes),
};
const startNodeId = existingNodeIds[randomInt(rng, 0, existingNodeIds.length - 1)];
const farthest = getFarthestNode(graph, startNodeId);
const exitNodeId = farthest.id;
const pathLength = shortestPathLength(graph, startNodeId, exitNodeId);
return {
graph,
startNodeId,
exitNodeId,
meta: {
hasEscapePath: Number.isFinite(pathLength) && pathLength > 0,
pathLength,
attemptsUsed: 1,
},
};
}
export function generateChaseRound(input: GenerateChaseRoundInput): ChaseRound {
const baseSeed = normalizeSeed(input.seed);
const minPathLength = minimumPathByDifficulty(input.difficulty);
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
const attemptSeed = normalizeSeed(baseSeed + attempt * 0x9e3779b9);
const round = generateGraphAttempt(attemptSeed, input.difficulty);
const isPlayable =
isConnected(round.graph) &&
round.meta.hasEscapePath &&
round.meta.pathLength >= minPathLength;
if (isPlayable) {
return {
...round,
meta: {
...round.meta,
attemptsUsed: attempt + 1,
},
};
}
}
throw new Error("Unable to generate playable chase round within retry limit");
}

62
tests/chase/chaseGenerator.test.ts

@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { shortestPathLength } from "../../src/game/chase/hexGraph";
import { generateChaseRound } from "../../src/game/chase/generator";
import type { Difficulty } from "../../src/game/chase/types";
describe("chase round generator", () => {
it("is deterministic for same seed and difficulty", () => {
const options = { seed: 20260426, difficulty: "normal" as Difficulty };
const roundA = generateChaseRound(options);
const roundB = generateChaseRound(options);
expect(roundA).toEqual(roundB);
});
it("matches golden snapshot for fixed seed", () => {
const round = generateChaseRound({ seed: 424242, difficulty: "easy" });
expect({
startNodeId: round.startNodeId,
exitNodeId: round.exitNodeId,
hasEscapePath: round.meta.hasEscapePath,
nodeCount: Object.keys(round.graph.nodes).length,
edgeCount: round.graph.edgeList.length,
firstFiveNodeIds: Object.keys(round.graph.nodes).slice(0, 5),
}).toMatchInlineSnapshot(`
{
"edgeCount": 19,
"exitNodeId": "0,-1",
"firstFiveNodeIds": [
"0,0",
"1,-1",
"2,-2",
"2,-1",
"1,-2",
],
"hasEscapePath": true,
"nodeCount": 20,
"startNodeId": "4,-5",
}
`);
});
it("generates graph node count between 20 and 30", () => {
const round = generateChaseRound({ seed: 11, difficulty: "normal" });
const nodeCount = Object.keys(round.graph.nodes).length;
expect(nodeCount).toBeGreaterThanOrEqual(20);
expect(nodeCount).toBeLessThanOrEqual(30);
});
it("ensures an escape path exists", () => {
const round = generateChaseRound({ seed: 99, difficulty: "hard" });
const pathLength = shortestPathLength(
round.graph,
round.startNodeId,
round.exitNodeId,
);
expect(round.meta.hasEscapePath).toBe(true);
expect(pathLength).toBeGreaterThan(0);
expect(pathLength).toBeLessThan(Infinity);
});
});
Loading…
Cancel
Save