2 changed files with 266 additions and 0 deletions
@ -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"); |
|||
} |
|||
@ -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…
Reference in new issue