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