diff --git a/src/game/chase/hexGraph.ts b/src/game/chase/hexGraph.ts new file mode 100644 index 0000000..6361d78 --- /dev/null +++ b/src/game/chase/hexGraph.ts @@ -0,0 +1,71 @@ +import type { GameGraph, NodeId } from "./types"; + +export function axialNeighbors(q: number, r: number): Array<[number, number]> { + return [ + [q + 1, r], + [q + 1, r - 1], + [q, r - 1], + [q - 1, r], + [q - 1, r + 1], + [q, r + 1], + ]; +} + +export function shortestPathLength( + graph: GameGraph, + from: NodeId, + to: NodeId, +): number { + if (from === to) { + return 0; + } + + if (!graph.nodes[from] || !graph.nodes[to]) { + return Infinity; + } + + const queue: Array<[NodeId, number]> = [[from, 0]]; + const visited = new Set([from]); + + while (queue.length > 0) { + const [current, distance] = queue.shift()!; + const node = graph.nodes[current]; + + for (const neighbor of node.neighbors) { + if (neighbor === to) { + return distance + 1; + } + if (!visited.has(neighbor) && graph.nodes[neighbor]) { + visited.add(neighbor); + queue.push([neighbor, distance + 1]); + } + } + } + + return Infinity; +} + +export function isConnected(graph: GameGraph): boolean { + const nodeIds = Object.keys(graph.nodes); + if (nodeIds.length <= 1) { + return true; + } + + const start = nodeIds[0]; + const visited = new Set([start]); + const queue: NodeId[] = [start]; + + while (queue.length > 0) { + const current = queue.shift()!; + const node = graph.nodes[current]; + + for (const neighbor of node.neighbors) { + if (!visited.has(neighbor) && graph.nodes[neighbor]) { + visited.add(neighbor); + queue.push(neighbor); + } + } + } + + return visited.size === nodeIds.length; +} diff --git a/tests/chase/hexGraph.test.ts b/tests/chase/hexGraph.test.ts new file mode 100644 index 0000000..d8fd6de --- /dev/null +++ b/tests/chase/hexGraph.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { + axialNeighbors, + isConnected, + shortestPathLength, +} from "../../src/game/chase/hexGraph"; +import type { GameGraph } from "../../src/game/chase/types"; + +function buildGraph(nodes: GameGraph["nodes"]): GameGraph { + return { + nodes, + edgeList: [], + }; +} + +describe("hex graph utilities", () => { + it("returns 6 axial neighbors", () => { + const neighbors = axialNeighbors(0, 0); + expect(neighbors).toEqual([ + [1, 0], + [1, -1], + [0, -1], + [-1, 0], + [-1, 1], + [0, 1], + ]); + }); + + it("finds bfs shortest path length", () => { + const graph = buildGraph({ + A: { id: "A", q: 0, r: 0, neighbors: ["B"] }, + B: { id: "B", q: 1, r: 0, neighbors: ["A", "C"] }, + C: { id: "C", q: 2, r: 0, neighbors: ["B", "D"] }, + D: { id: "D", q: 3, r: 0, neighbors: ["C"] }, + }); + + expect(shortestPathLength(graph, "A", "D")).toBe(3); + expect(shortestPathLength(graph, "A", "A")).toBe(0); + }); + + it("returns Infinity when target is unreachable", () => { + const graph = buildGraph({ + A: { id: "A", q: 0, r: 0, neighbors: ["B"] }, + B: { id: "B", q: 1, r: 0, neighbors: ["A"] }, + C: { id: "C", q: 5, r: 5, neighbors: [] }, + }); + + expect(shortestPathLength(graph, "A", "C")).toBe(Infinity); + }); + + it("checks graph connectivity", () => { + const connectedGraph = buildGraph({ + A: { id: "A", q: 0, r: 0, neighbors: ["B"] }, + B: { id: "B", q: 1, r: 0, neighbors: ["A", "C"] }, + C: { id: "C", q: 2, r: 0, neighbors: ["B"] }, + }); + + const disconnectedGraph = buildGraph({ + A: { id: "A", q: 0, r: 0, neighbors: ["B"] }, + B: { id: "B", q: 1, r: 0, neighbors: ["A"] }, + C: { id: "C", q: 5, r: 5, neighbors: [] }, + }); + + expect(isConnected(connectedGraph)).toBe(true); + expect(isConnected(disconnectedGraph)).toBe(false); + }); +});