forked from phenom/healer-man
Release Healer Man 0.1.46
This commit is contained in:
1 parent
f82daf1c13
commit
9ff574ac0b
73 files changed
+33486
-174
No files matched your search
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
BOSS_PRESSURE_PARTY_SIZE,
|
||||
BOSS_PRESSURE_WINDOW_MS,
|
||||
rankBossPartyPressure,
|
||||
} from "../../src/game/bossPressure";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const playerLevel = Math.max(1, Math.min(80, Number(process.env.BOSS_PRESSURE_LEVEL) || 80));
|
||||
const { DUNGEON_DEFINITIONS } = await import("../../src/game/dungeonRegistry");
|
||||
const wowDefinitions = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion !== "runewaker");
|
||||
const ranking = rankBossPartyPressure(wowDefinitions, playerLevel);
|
||||
const median = ranking.length ? ranking[Math.floor(ranking.length / 2)].partyDamagePerSecond : 0;
|
||||
const entries = ranking.map((entry, index) => ({
|
||||
rank: index + 1,
|
||||
pressureVsMedian: median > 0 ? entry.partyDamagePerSecond / median : 0,
|
||||
...entry,
|
||||
}));
|
||||
const eck = entries.find((entry) => entry.serverEntry === 29_932) ?? null;
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
assumptions: {
|
||||
playerLevel,
|
||||
enemyLevelOffset: 2,
|
||||
durationSeconds: BOSS_PRESSURE_WINDOW_MS / 1_000,
|
||||
partySize: BOSS_PRESSURE_PARTY_SIZE,
|
||||
partyLayout: "all five actors inside nearby-party effects",
|
||||
metric: "unmitigated direct damage from the live boss attack catalog",
|
||||
excludes: [
|
||||
"armor and resistance",
|
||||
"avoidable movement and interrupts",
|
||||
"summoned creature damage",
|
||||
"damage-amplifying debuffs",
|
||||
"healing and absorbs",
|
||||
"mutually exclusive phase uptime",
|
||||
],
|
||||
},
|
||||
bosses: entries.length,
|
||||
medianPartyDamagePerSecond: median,
|
||||
eck,
|
||||
entries,
|
||||
};
|
||||
|
||||
const number = (value: number): string => Math.round(value).toLocaleString("en-US");
|
||||
const decimal = (value: number): string => value.toFixed(1);
|
||||
const top = entries.slice(0, 30);
|
||||
const markdown = `# WoW boss party-pressure audit
|
||||
|
||||
Generated ${report.generatedAt}. This is a deterministic comparison at player level ${playerLevel}: ${BOSS_PRESSURE_PARTY_SIZE} grouped party members, ${BOSS_PRESSURE_WINDOW_MS / 1_000} seconds, and live boss attack definitions. It is a pressure ceiling, not a combat-log prediction: mitigation, movement, interrupts, summons, debuff amplification, and phase uptime are excluded.
|
||||
|
||||
| Rank | Boss | Dungeon | Party DPS | Tank DPS | Random DPS | Groupwide DPS | vs median |
|
||||
| ---: | --- | --- | ---: | ---: | ---: | ---: | ---: |
|
||||
${top.map((entry) => `| ${entry.rank} | ${entry.boss} | ${entry.dungeonTitle} | ${number(entry.partyDamagePerSecond)} | ${number(entry.tankDamage / entry.windowSeconds)} | ${number(entry.randomTargetDamage / entry.windowSeconds)} | ${number(entry.groupwideDamage / entry.windowSeconds)} | ${decimal(entry.pressureVsMedian)}x |`).join("\n")}
|
||||
|
||||
## Eck the Ferocious
|
||||
|
||||
${eck ? `Eck ranks **${eck.rank} of ${entries.length}** at **${number(eck.partyDamagePerSecond)} raw party DPS** (**${decimal(eck.pressureVsMedian)}x** the median). His corrected schedule contains one 2-second melee swing, Bite every 8–12 seconds, Spit every 8–12 seconds after its delayed opener, Spring every 10–24 seconds against a random party member, and a non-damaging Berserk at 90 seconds.` : "Eck was not found in the runtime catalog."}
|
||||
|
||||
The complete machine-readable ranking and per-attack breakdown are in \`dungeon-pipeline/WOW_BOSS_PRESSURE_REPORT.json\`.
|
||||
`;
|
||||
|
||||
const jsonPath = path.join(projectRoot, "dungeon-pipeline", "WOW_BOSS_PRESSURE_REPORT.json");
|
||||
const markdownPath = path.join(projectRoot, "dungeon-pipeline", "WOW_BOSS_PRESSURE_REPORT.md");
|
||||
await Promise.all([
|
||||
writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"),
|
||||
writeFile(markdownPath, markdown, "utf8"),
|
||||
]);
|
||||
console.log(JSON.stringify({
|
||||
status: "complete",
|
||||
bosses: entries.length,
|
||||
playerLevel,
|
||||
medianPartyDamagePerSecond: Number(median.toFixed(2)),
|
||||
eck: eck ? { rank: eck.rank, partyDamagePerSecond: Number(eck.partyDamagePerSecond.toFixed(2)), pressureVsMedian: Number(eck.pressureVsMedian.toFixed(2)) } : null,
|
||||
outputs: [path.relative(projectRoot, jsonPath), path.relative(projectRoot, markdownPath)],
|
||||
}, null, 2));
|
||||
@@ -0,0 +1,426 @@
|
||||
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)}.`);
|
||||
Reference in new issue
Block a user