From 487d62864e07056f3f06a855f77706a24bccb52b Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sun, 26 Apr 2026 23:18:35 +0800 Subject: [PATCH] feat(chase): implement seed-based round generator with playability checks Made-with: Cursor --- src/game/chase/generator.ts | 204 +++++++++++++++++++++++++++++++++++++ tests/chase/chaseGenerator.test.ts | 62 +++++++++++ 2 files changed, 266 insertions(+) create mode 100644 src/game/chase/generator.ts create mode 100644 tests/chase/chaseGenerator.test.ts diff --git a/src/game/chase/generator.ts b/src/game/chase/generator.ts new file mode 100644 index 0000000..430d5fc --- /dev/null +++ b/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, 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): Array<[NodeId, NodeId]> { + const edges: Array<[NodeId, NodeId]> = []; + const seen = new Set(); + + 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([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 = {}; + 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"); +} diff --git a/tests/chase/chaseGenerator.test.ts b/tests/chase/chaseGenerator.test.ts new file mode 100644 index 0000000..3c795c0 --- /dev/null +++ b/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); + }); +});