import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { NodeIO } from "@gltf-transform/core"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { MeshoptDecoder } from "meshoptimizer"; const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); const defaultPlayerbotsRoot = path.resolve(projectRoot, "..", "wow-pvt-server-backups-2", "azerothcore-wotlk", "modules", "mod-playerbots"); const argumentIndex = process.argv.indexOf("--playerbots-root"); const playerbotsRoot = path.resolve(argumentIndex >= 0 ? process.argv[argumentIndex + 1] : defaultPlayerbotsRoot); const sqlRoot = path.join(playerbotsRoot, "data", "sql", "playerbots", "base"); const catalogPath = path.join(projectRoot, "src", "game", "generated", "dungeonCampaignCatalog.json"); const outputPath = path.join(projectRoot, "src", "game", "generated", "dungeonRouteCatalog.generated.json"); const auditPath = path.join(projectRoot, "dungeon-pipeline", "DUNGEON_ROUTE_GENERATION_AUDIT.json"); const gltfIo = new NodeIO() .registerExtensions(ALL_EXTENSIONS) .registerDependencies({ "meshopt.decoder": MeshoptDecoder }); function tuples(fileName, pattern, convert) { return readFileSync(path.join(sqlRoot, fileName), "utf8") .split(/\r?\n/) .map((line) => pattern.exec(line.trim())) .filter(Boolean) .map(convert); } const number = (value) => Number(value); const nodes = tuples( "playerbots_travelnode.sql", /^\((\d+), '((?:[^'\\]|\\.)*)', (\d+), (-?[\d.eE+]+), (-?[\d.eE+]+), (-?[\d.eE+]+), (?:NULL|\d+)\)[,;]?$/, (match) => ({ id: number(match[1]), mapId: number(match[3]), wow: [number(match[4]), number(match[5]), number(match[6])] }), ); const links = tuples( "playerbots_travelnode_link.sql", /^\((\d+), (\d+), \d+, \d+, (-?[\d.eE+]+),/, (match) => ({ from: number(match[1]), to: number(match[2]), distance: Math.max(0.001, number(match[3])) }), ); const nodesById = new Map(nodes.map((node) => [node.id, node])); function transformPoint(dungeon, wow) { const base = dungeon.transform.fromWow === "three(x,y,z)=(wow.y,wow.z,wow.x)" ? [wow[1], wow[2], wow[0]] : [-wow[0], wow[2], wow[1]]; const scale = dungeon.transform.scale ?? 1; return [ base[0] * scale + (dungeon.transform.position?.[0] ?? 0), base[1] * scale + (dungeon.transform.position?.[1] ?? 0), base[2] * scale + (dungeon.transform.position?.[2] ?? 0), ]; } function distance(left, right) { return Math.hypot(right[0] - left[0], right[1] - left[1], right[2] - left[2]); } function nearestNode(candidates, point) { return candidates.reduce((best, candidate) => { const candidateDistance = distance(candidate.three, point); return !best || candidateDistance < best.distance || (candidateDistance === best.distance && candidate.id < best.node.id) ? { node: candidate, distance: candidateDistance } : best; }, null)?.node ?? null; } function shortestNodePath(mapNodes, mapLinks, startId, endId) { if (startId === endId) return [startId]; const allowed = new Set(mapNodes.map((node) => node.id)); const adjacency = new Map(mapNodes.map((node) => [node.id, []])); for (const link of mapLinks) { if (allowed.has(link.from) && allowed.has(link.to)) adjacency.get(link.from).push(link); } for (const values of adjacency.values()) values.sort((left, right) => left.to - right.to); const costs = new Map([[startId, 0]]); const previous = new Map(); const pending = new Set(allowed); while (pending.size) { let current = null; let currentCost = Number.POSITIVE_INFINITY; for (const id of pending) { const cost = costs.get(id) ?? Number.POSITIVE_INFINITY; if (cost < currentCost || (cost === currentCost && (current === null || id < current))) { current = id; currentCost = cost; } } if (current === null || !Number.isFinite(currentCost)) break; pending.delete(current); if (current === endId) break; for (const edge of adjacency.get(current) ?? []) { if (!pending.has(edge.to)) continue; const nextCost = currentCost + edge.distance; if (nextCost < (costs.get(edge.to) ?? Number.POSITIVE_INFINITY)) { costs.set(edge.to, nextCost); previous.set(edge.to, current); } } } if (!previous.has(endId)) return null; const result = [endId]; while (result[0] !== startId) result.unshift(previous.get(result[0])); return result; } function transformByMatrix(point, matrix) { return [ matrix[0] * point[0] + matrix[4] * point[1] + matrix[8] * point[2] + matrix[12], matrix[1] * point[0] + matrix[5] * point[1] + matrix[9] * point[2] + matrix[13], matrix[2] * point[0] + matrix[6] * point[1] + matrix[10] * point[2] + matrix[14], ]; } async function loadNavigationGraph(dungeon) { const navigationUrl = dungeon.assets?.navigation?.url; if (!navigationUrl) throw new Error(`${dungeon.id}: missing navigation asset.`); const navigationFile = path.join(projectRoot, "public", navigationUrl.replace(/^\//, "")); const document = await gltfIo.read(navigationFile); const welded = new Map(); const triangles = []; const edgeOwners = new Map(); const vertexId = (point) => { const key = point.map((value) => Math.round(value * 1_000)).join(":"); if (!welded.has(key)) welded.set(key, welded.size); return welded.get(key); }; for (const node of document.getRoot().listNodes()) { const mesh = node.getMesh(); if (!mesh) continue; const matrix = node.getWorldMatrix(); for (const primitive of mesh.listPrimitives()) { const positionAccessor = primitive.getAttribute("POSITION"); const positions = positionAccessor?.getArray(); const sourceIndices = primitive.getIndices()?.getArray(); if (!positions) continue; const indices = sourceIndices ? Array.from(sourceIndices) : Array.from({ length: positions.length / 3 }, (_, index) => index); for (let offset = 0; offset + 2 < indices.length; offset += 3) { const points = [indices[offset], indices[offset + 1], indices[offset + 2]].map((index) => transformByMatrix( positionAccessor.getElement(index, []), matrix, )); const ids = points.map(vertexId); const id = triangles.length; triangles.push({ centroid: points.reduce((sum, point) => [sum[0] + point[0] / 3, sum[1] + point[1] / 3, sum[2] + point[2] / 3], [0, 0, 0]), neighbors: new Set(), }); for (const edge of [[ids[0], ids[1]], [ids[1], ids[2]], [ids[2], ids[0]]]) { const key = [...edge].sort((left, right) => left - right).join(":"); const previous = edgeOwners.get(key); if (previous !== undefined) { triangles[id].neighbors.add(previous); triangles[previous].neighbors.add(id); } else edgeOwners.set(key, id); } } } } if (!triangles.length) throw new Error(`${dungeon.id}: navigation asset contains no triangles.`); const nearest = (point) => { let id = -1; let snapDistance = Number.POSITIVE_INFINITY; triangles.forEach((triangle, index) => { const candidate = distance(point, triangle.centroid); if (candidate < snapDistance) { id = index; snapDistance = candidate; } }); return { id, distance: snapDistance, point: triangles[id].centroid }; }; for (const link of dungeon.navigationLinks ?? []) { const start = nearest(link.start); const end = nearest(link.end); if (start.distance <= 5 && end.distance <= 5) { triangles[start.id].neighbors.add(end.id); if (link.bidirectional !== false) triangles[end.id].neighbors.add(start.id); } } return { triangles, nearest }; } class MinimumHeap { values = []; push(value) { this.values.push(value); let index = this.values.length - 1; while (index > 0) { const parent = (index - 1) >> 1; if (this.values[parent].cost <= value.cost) break; this.values[index] = this.values[parent]; index = parent; } this.values[index] = value; } pop() { if (!this.values.length) return null; const first = this.values[0]; const last = this.values.pop(); if (this.values.length) { let index = 0; while (true) { const left = index * 2 + 1; const right = left + 1; if (left >= this.values.length) break; const child = right < this.values.length && this.values[right].cost < this.values[left].cost ? right : left; if (this.values[child].cost >= last.cost) break; this.values[index] = this.values[child]; index = child; } this.values[index] = last; } return first; } } function navigationTrianglePath(graph, startId, endId) { if (startId === endId) return [startId]; const costs = new Map([[startId, 0]]); const previous = new Map(); const pending = new MinimumHeap(); pending.push({ id: startId, cost: 0 }); while (pending.values.length) { const current = pending.pop(); const known = costs.get(current.id); if (current.cost > known + 1e-6) continue; if (current.id === endId) break; const origin = graph.triangles[current.id].centroid; for (const neighborId of graph.triangles[current.id].neighbors) { const nextCost = known + distance(origin, graph.triangles[neighborId].centroid); if (nextCost >= (costs.get(neighborId) ?? Number.POSITIVE_INFINITY)) continue; costs.set(neighborId, nextCost); previous.set(neighborId, current.id); pending.push({ id: neighborId, cost: nextCost }); } } if (!previous.has(endId)) return null; const result = [endId]; while (result[0] !== startId) result.unshift(previous.get(result[0])); return result; } function segmentDistanceSquared(point, start, end) { const delta = [end[0] - start[0], end[1] - start[1], end[2] - start[2]]; const lengthSquared = delta[0] ** 2 + delta[1] ** 2 + delta[2] ** 2; if (lengthSquared <= 1e-9) return distance(point, start) ** 2; const t = Math.max(0, Math.min(1, ( (point[0] - start[0]) * delta[0] + (point[1] - start[1]) * delta[1] + (point[2] - start[2]) * delta[2] ) / lengthSquared)); return distance(point, [start[0] + delta[0] * t, start[1] + delta[1] * t, start[2] + delta[2] * t]) ** 2; } function simplify(points, tolerance = 0.75) { if (points.length <= 2) return points; let furthest = -1; let furthestDistance = tolerance ** 2; for (let index = 1; index < points.length - 1; index += 1) { const candidate = segmentDistanceSquared(points[index], points[0], points[points.length - 1]); if (candidate > furthestDistance) { furthest = index; furthestDistance = candidate; } } if (furthest < 0) return [points[0], points[points.length - 1]]; return [...simplify(points.slice(0, furthest + 1), tolerance).slice(0, -1), ...simplify(points.slice(furthest), tolerance)]; } function boundSegments(points, maximum = 7.5) { const result = [points[0]]; for (let index = 1; index < points.length; index += 1) { const start = result[result.length - 1]; const end = points[index]; const length = distance(start, end); const steps = Math.max(1, Math.ceil(length / maximum)); for (let step = 1; step <= steps; step += 1) { const t = step / steps; result.push([ start[0] + (end[0] - start[0]) * t, start[1] + (end[1] - start[1]) * t, start[2] + (end[2] - start[2]) * t, ]); } } return result; } function rounded(point) { return point.map((coordinate) => Number(coordinate.toFixed(4))); } function canonical(value) { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (value && typeof value === "object") { return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`; } return JSON.stringify(value); } function playerbotsTopologyEvidence(start, end, mapNodes, mapLinks) { const startNode = nearestNode(mapNodes, start); const endNode = nearestNode(mapNodes, end); if (!startNode || !endNode) return { landmarksAvailable: false, connected: false }; return { landmarksAvailable: true, connected: Boolean(shortestNodePath(mapNodes, mapLinks, startNode.id, endNode.id)), }; } function routeGeometry(graph, start, end) { const startSnap = graph.nearest(start); const endSnap = graph.nearest(end); const trianglePath = navigationTrianglePath(graph, startSnap.id, endSnap.id); const points = trianglePath ? trianglePath.map((id) => graph.triangles[id].centroid) : [startSnap.point, endSnap.point]; const checkpoints = boundSegments(simplify(points, 0.35)).map(rounded); return { checkpoints, connected: Boolean(trianglePath), startSnapDistance: startSnap.distance, endSnapDistance: endSnap.distance, }; } const campaign = JSON.parse(readFileSync(catalogPath, "utf8")); const stock = campaign.definitions.filter((dungeon) => String(dungeon.provenance?.databaseAdapter ?? "").startsWith("azerothcore-")); const routes = []; const routeAudits = []; for (const dungeon of stock) { const mapNodes = nodes.filter((node) => node.mapId === dungeon.mapId).map((node) => ({ ...node, three: transformPoint(dungeon, node.wow), })); const mapLinks = links.filter((link) => nodesById.get(link.from)?.mapId === dungeon.mapId && nodesById.get(link.to)?.mapId === dungeon.mapId); const navigationGraph = await loadNavigationGraph(dungeon); let start = dungeon.entrance.footPosition; const legAudits = []; const legs = dungeon.objectiveOrder.map((objective, index) => { const topology = playerbotsTopologyEvidence(start, objective.position, mapNodes, mapLinks); const geometry = routeGeometry(navigationGraph, start, objective.position); const { checkpoints } = geometry; if (!checkpoints.length) throw new Error(`${dungeon.id}: empty navigation leg to ${objective.id}.`); const expectedDistance = checkpoints.slice(1).reduce((total, point, pointIndex) => total + distance(checkpoints[pointIndex], point), 0); legAudits.push({ objectiveId: objective.id, playerbotsLandmarksAvailable: topology.landmarksAvailable, playerbotsTopologyConnected: topology.connected, navigationStartSnapDistance: Number(geometry.startSnapDistance.toFixed(3)), navigationEndSnapDistance: Number(geometry.endSnapDistance.toFixed(3)), checkpointCount: checkpoints.length, valid: geometry.connected && geometry.startSnapDistance <= 15 && geometry.endSnapDistance <= 15, issues: [ ...(!geometry.connected ? ["disconnected-navigation-leg"] : []), ...(geometry.startSnapDistance > 15 ? ["origin-snap-distance-exceeded"] : []), ...(geometry.endSnapDistance > 15 ? ["endpoint-snap-distance-exceeded"] : []), ], }); start = objective.position; return { id: `leg-${String(index + 1).padStart(2, "0")}-${objective.id}`, objectiveId: objective.id, objectiveName: objective.name, checkpoints, expectedDistance: Number(expectedDistance.toFixed(2)), corridorPlanarDistance: 10, corridorVerticalDistance: 4, actions: [], }; }); const unhashed = { schemaVersion: 1, id: "primary", dungeonId: dungeon.id, mapId: dungeon.mapId, legs }; const valid = legAudits.every((leg) => leg.valid); routes.push({ ...unhashed, hash: createHash("sha256").update(canonical(unhashed)).digest("hex"), generationStatus: valid ? "Candidate" : "Rejected", generationIssues: [...new Set(legAudits.flatMap((leg) => leg.issues))], }); routeAudits.push({ dungeonId: dungeon.id, mapId: dungeon.mapId, navigationTriangles: navigationGraph.triangles.length, valid, disposition: valid ? "candidate" : "rejected-needs-work", legs: legAudits, }); } if (routes.length !== 51) throw new Error(`Expected 51 stock dungeon routes, generated ${routes.length}.`); const safePlayerbotsRoot = playerbotsRoot.replaceAll("\\", "/"); const playerbotsGit = (...arguments_) => execFileSync( "git", ["-c", `safe.directory=${safePlayerbotsRoot}`, "-C", playerbotsRoot, ...arguments_], { encoding: "utf8" }, ).trim(); const playerbotsCommit = playerbotsGit("rev-parse", "HEAD"); const generatedAt = playerbotsGit("show", "-s", "--format=%cI", "HEAD"); const output = { schemaVersion: 1, generatedAt, source: { kind: "playerbots-landmark-reference", auditReport: "dungeon-pipeline/DUNGEON_ROUTE_GENERATION_AUDIT.json", note: "PlayerBots was used only as offline topology evidence. Shipped checkpoint geometry is computed from HealerMan navigation assets.", }, routes, }; writeFileSync(outputPath, `${JSON.stringify(output)}\n`); writeFileSync(auditPath, `${JSON.stringify({ schemaVersion: 1, generatedAt, playerbots: { repository: "azerothcore/mod-playerbots", commit: playerbotsCommit, attribution: "AzerothCore PlayerBots travel data was consulted offline as landmark and connectivity evidence; no source code, node IDs, raw node positions, or dense path rows are shipped.", }, policy: { anchors: "HealerMan entrance and required objective positions", geometry: "HealerMan Recast navigation GLBs", maximumCheckpointSegment: 7.5, }, routes: routeAudits, }, null, 2)}\n`); console.log(`Generated ${routes.length} candidate routes at ${path.relative(projectRoot, outputPath)}.`);