554 lines
22 KiB
JavaScript
554 lines
22 KiB
JavaScript
#!/usr/bin/env node
|
|
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { NodeIO } from "@gltf-transform/core";
|
|
import { Matrix4, Triangle, Vector3 } from "three";
|
|
import {
|
|
buildPartyNavigationGraph,
|
|
findNearestPartyNavigationSurface,
|
|
findPartyNavigationPath,
|
|
} from "../../src/game/partyNavigation.ts";
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const recipeDirectory = path.join(root, "scripts/runewaker-pipeline/recipes");
|
|
const gameplayReportFile = path.join(
|
|
root,
|
|
"scripts/runewaker-pipeline/RUNEWAKER_GAMEPLAY_CONTENT_REPORT.json",
|
|
);
|
|
const outputJson = path.join(
|
|
root,
|
|
"scripts/runewaker-pipeline/RUNEWAKER_NAVIGATION_GAP_REPORT.json",
|
|
);
|
|
const outputMarkdown = path.join(
|
|
root,
|
|
"scripts/runewaker-pipeline/RUNEWAKER_NAVIGATION_GAP_REPORT.md",
|
|
);
|
|
const weldTolerance = 0.001;
|
|
const reviewedFindings = {
|
|
"cave-water-dragon": {
|
|
disposition: "gated-wave-and-door-review",
|
|
safeAutomaticLink: false,
|
|
finding: "Template 104161 runs the ten-add water-elemental controller, creates a blocking door, and removes it only after the counter reaches ten. Lytfir is on a lower isolated surface, so a permanent link would bypass the preserved gate/drop sequence.",
|
|
evidence: [
|
|
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:1",
|
|
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:21",
|
|
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:42",
|
|
],
|
|
},
|
|
hoto: {
|
|
disposition: "source-order-required-before-linking",
|
|
safeAutomaticLink: false,
|
|
finding: "The distance fallback crosses the same 6.65m vertical surface break three times. Preserved local configuration supplies boss anchors but no authoritative encounter order, so these heuristic legs cannot justify a link.",
|
|
evidence: [
|
|
"scripts/runewaker-pipeline/build-population.mjs:634",
|
|
"scripts/runewaker-pipeline/recipes/hoto-population.json",
|
|
],
|
|
},
|
|
"ice-dwarf-kingdom": {
|
|
disposition: "door-gated-room-review",
|
|
safeAutomaticLink: false,
|
|
finding: "Crasset's isolated surface is separated by a 1.64m/0.60m gap, but his preserved death hook creates an orb that finds and hides door 112321. A permanent bidirectional link would erase that gate state.",
|
|
evidence: [
|
|
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:16",
|
|
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:36",
|
|
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:46",
|
|
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:54",
|
|
],
|
|
},
|
|
"kalins-shrine": {
|
|
disposition: "teleport-room-mechanic",
|
|
safeAutomaticLink: false,
|
|
finding: "The preserved server scripts register six Kalin locations as separate keeper rooms and advance with a group teleport. The vertical component gaps are therefore encounter state, not wall seams.",
|
|
evidence: [
|
|
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:713",
|
|
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1298",
|
|
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1363",
|
|
],
|
|
},
|
|
"raksha-temple": {
|
|
disposition: "missing-modular-environment-assembly",
|
|
safeAutomaticLink: false,
|
|
finding: "The environment recipe explicitly adds isolated source-anchor support pads because the single primary ROS does not cover the modular WDB combat anchors. Secondary environment pieces must be assembled before route authoring.",
|
|
evidence: ["scripts/runewaker-pipeline/recipes/raksha-temple.json:16"],
|
|
},
|
|
"the-origin": {
|
|
disposition: "missing-modular-environment-assembly",
|
|
safeAutomaticLink: false,
|
|
finding: "Both objectives and the entrance sit on isolated source-anchor support pads. The WDB contains many separately placed cathedral, slope, bridge, and connector ROS pieces that the primary-only environment package does not yet assemble.",
|
|
evidence: ["scripts/runewaker-pipeline/recipes/the-origin.json:16"],
|
|
},
|
|
"treasure-trove": {
|
|
disposition: "missing-modular-environment-plus-bridge-door-mechanics",
|
|
safeAutomaticLink: false,
|
|
finding: "Boss anchors are isolated support pads, while preserved Lua requires a boss-enabled drawbridge switch and a key-gated hold door. The modular environment and stateful bridge/door must precede navigation links.",
|
|
evidence: [
|
|
"scripts/runewaker-pipeline/recipes/treasure-trove.json:16",
|
|
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01159.lua:1",
|
|
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01159.lua:38",
|
|
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01159.lua:50",
|
|
],
|
|
},
|
|
"ystra-labyrinth-boss1": {
|
|
disposition: "separate-authored-room-placements",
|
|
safeAutomaticLink: false,
|
|
finding: "The package intentionally clones three distinct WDB room placements hundreds of meters apart; the preserved travel catalog also treats the boss-room destinations separately. Connecting them through open space would invent geometry.",
|
|
evidence: [
|
|
"scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss1.json:16",
|
|
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1423",
|
|
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1425",
|
|
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1426",
|
|
],
|
|
},
|
|
};
|
|
|
|
const readJson = async (file) => JSON.parse((await readFile(file, "utf8")).replace(/^\uFEFF/, ""));
|
|
const constantCase = (value) => String(value).replaceAll("-", "_").toUpperCase();
|
|
|
|
function generatedJson(source, name, suffix) {
|
|
const marker = "export const " + name + " = ";
|
|
const start = source.indexOf(marker);
|
|
if (start < 0) throw new Error("Missing generated constant " + name + ".");
|
|
const valueStart = start + marker.length;
|
|
const valueEnd = source.indexOf(suffix, valueStart);
|
|
if (valueEnd < 0) throw new Error("Missing type suffix for " + name + ".");
|
|
return JSON.parse(source.slice(valueStart, valueEnd));
|
|
}
|
|
|
|
class DisjointSet {
|
|
constructor(size) {
|
|
this.parent = Int32Array.from({ length: size }, (_, index) => index);
|
|
this.rank = new Uint8Array(size);
|
|
}
|
|
|
|
find(value) {
|
|
let rootValue = value;
|
|
while (this.parent[rootValue] !== rootValue) rootValue = this.parent[rootValue];
|
|
while (this.parent[value] !== value) {
|
|
const next = this.parent[value];
|
|
this.parent[value] = rootValue;
|
|
value = next;
|
|
}
|
|
return rootValue;
|
|
}
|
|
|
|
union(left, right) {
|
|
let leftRoot = this.find(left);
|
|
let rightRoot = this.find(right);
|
|
if (leftRoot === rightRoot) return;
|
|
if (this.rank[leftRoot] < this.rank[rightRoot]) [leftRoot, rightRoot] = [rightRoot, leftRoot];
|
|
this.parent[rightRoot] = leftRoot;
|
|
if (this.rank[leftRoot] === this.rank[rightRoot]) this.rank[leftRoot] += 1;
|
|
}
|
|
}
|
|
|
|
function geometryFromPositions(positions) {
|
|
return {
|
|
getAttribute(name) {
|
|
if (name !== "position") return undefined;
|
|
return {
|
|
itemSize: 3,
|
|
count: positions.length / 3,
|
|
getX: (index) => positions[index * 3],
|
|
getY: (index) => positions[index * 3 + 1],
|
|
getZ: (index) => positions[index * 3 + 2],
|
|
};
|
|
},
|
|
getIndex() { return null; },
|
|
};
|
|
}
|
|
|
|
async function navigationPositions(file) {
|
|
const document = await new NodeIO().read(file);
|
|
const positions = [];
|
|
const vertex = new Vector3();
|
|
for (const node of document.getRoot().listNodes()) {
|
|
const mesh = node.getMesh();
|
|
if (!mesh) continue;
|
|
const world = new Matrix4().fromArray(node.getWorldMatrix());
|
|
for (const primitive of mesh.listPrimitives()) {
|
|
const source = primitive.getAttribute("POSITION")?.getArray();
|
|
if (!source) continue;
|
|
const sourceIndices = primitive.getIndices()?.getArray();
|
|
const indices = sourceIndices
|
|
? Array.from(sourceIndices)
|
|
: Array.from({ length: source.length / 3 }, (_, index) => index);
|
|
for (const index of indices) {
|
|
vertex.fromArray(source, index * 3).applyMatrix4(world);
|
|
positions.push(vertex.x, vertex.y, vertex.z);
|
|
}
|
|
}
|
|
}
|
|
if (!positions.length || positions.length % 9 !== 0) {
|
|
throw new Error("Navigation GLB contains no complete triangles.");
|
|
}
|
|
return positions;
|
|
}
|
|
|
|
function bucketCoordinate(value) {
|
|
return Math.floor(value / weldTolerance);
|
|
}
|
|
|
|
function bucketKey(x, y, z) {
|
|
return `${x},${y},${z}`;
|
|
}
|
|
|
|
function analyzeRawComponents(positions) {
|
|
const sourceVertexCount = positions.length / 3;
|
|
const sourceToWelded = new Int32Array(sourceVertexCount);
|
|
const welded = [];
|
|
const buckets = new Map();
|
|
const toleranceSquared = weldTolerance * weldTolerance;
|
|
|
|
for (let sourceId = 0; sourceId < sourceVertexCount; sourceId += 1) {
|
|
const point = positions.slice(sourceId * 3, sourceId * 3 + 3);
|
|
const bucket = point.map(bucketCoordinate);
|
|
let bestId = -1;
|
|
let bestDistanceSquared = Number.POSITIVE_INFINITY;
|
|
for (let dx = -1; dx <= 1; dx += 1) {
|
|
for (let dy = -1; dy <= 1; dy += 1) {
|
|
for (let dz = -1; dz <= 1; dz += 1) {
|
|
for (const candidateId of buckets.get(bucketKey(
|
|
bucket[0] + dx,
|
|
bucket[1] + dy,
|
|
bucket[2] + dz,
|
|
)) ?? []) {
|
|
const candidate = welded[candidateId];
|
|
const distanceSquared = (
|
|
(candidate[0] - point[0]) ** 2
|
|
+ (candidate[1] - point[1]) ** 2
|
|
+ (candidate[2] - point[2]) ** 2
|
|
);
|
|
if (distanceSquared <= toleranceSquared && distanceSquared < bestDistanceSquared) {
|
|
bestId = candidateId;
|
|
bestDistanceSquared = distanceSquared;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (bestId < 0) {
|
|
bestId = welded.length;
|
|
welded.push(point);
|
|
const key = bucketKey(...bucket);
|
|
const entries = buckets.get(key);
|
|
if (entries) entries.push(bestId);
|
|
else buckets.set(key, [bestId]);
|
|
}
|
|
sourceToWelded[sourceId] = bestId;
|
|
}
|
|
|
|
const sets = new DisjointSet(welded.length);
|
|
const triangles = [];
|
|
for (let sourceId = 0; sourceId < sourceVertexCount; sourceId += 3) {
|
|
const vertices = [
|
|
sourceToWelded[sourceId],
|
|
sourceToWelded[sourceId + 1],
|
|
sourceToWelded[sourceId + 2],
|
|
];
|
|
sets.union(vertices[0], vertices[1]);
|
|
sets.union(vertices[1], vertices[2]);
|
|
triangles.push({ vertices, sourceId: sourceId / 3 });
|
|
}
|
|
|
|
const componentByRoot = new Map();
|
|
for (let vertexId = 0; vertexId < welded.length; vertexId += 1) {
|
|
const rootId = sets.find(vertexId);
|
|
let component = componentByRoot.get(rootId);
|
|
if (!component) {
|
|
component = { sourceId: rootId, vertices: [], triangles: [], bounds: null };
|
|
componentByRoot.set(rootId, component);
|
|
}
|
|
component.vertices.push(vertexId);
|
|
}
|
|
for (const triangle of triangles) {
|
|
componentByRoot.get(sets.find(triangle.vertices[0])).triangles.push(triangle);
|
|
}
|
|
const components = [...componentByRoot.values()].sort((left, right) => (
|
|
right.vertices.length - left.vertices.length || left.sourceId - right.sourceId
|
|
));
|
|
components.forEach((component, componentId) => {
|
|
component.id = componentId;
|
|
const points = component.vertices.map((vertexId) => welded[vertexId]);
|
|
component.bounds = {
|
|
min: [0, 1, 2].map((axis) => Math.min(...points.map((point) => point[axis]))),
|
|
max: [0, 1, 2].map((axis) => Math.max(...points.map((point) => point[axis]))),
|
|
};
|
|
});
|
|
|
|
const triangle = new Triangle();
|
|
const target = new Vector3();
|
|
const closest = new Vector3();
|
|
const nearest = (point) => {
|
|
target.fromArray(point);
|
|
let result = null;
|
|
for (const component of components) {
|
|
for (const entry of component.triangles) {
|
|
triangle.a.fromArray(welded[entry.vertices[0]]);
|
|
triangle.b.fromArray(welded[entry.vertices[1]]);
|
|
triangle.c.fromArray(welded[entry.vertices[2]]);
|
|
triangle.closestPointToPoint(target, closest);
|
|
const distance = target.distanceTo(closest);
|
|
if (!result || distance < result.distance) {
|
|
result = {
|
|
componentId: component.id,
|
|
componentVertices: component.vertices.length,
|
|
componentTriangles: component.triangles.length,
|
|
distance,
|
|
position: closest.toArray(),
|
|
triangleId: entry.sourceId,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const componentGap = (leftId, rightId) => {
|
|
if (leftId === rightId) {
|
|
return { distance: 0, horizontalDistance: 0, verticalDistance: 0, left: null, right: null };
|
|
}
|
|
let left = components[leftId];
|
|
let right = components[rightId];
|
|
let swapped = false;
|
|
if (left.vertices.length > right.vertices.length) {
|
|
[left, right] = [right, left];
|
|
swapped = true;
|
|
}
|
|
let result = null;
|
|
for (const leftVertexId of left.vertices) {
|
|
const leftPoint = welded[leftVertexId];
|
|
for (const rightVertexId of right.vertices) {
|
|
const rightPoint = welded[rightVertexId];
|
|
const dx = rightPoint[0] - leftPoint[0];
|
|
const dy = rightPoint[1] - leftPoint[1];
|
|
const dz = rightPoint[2] - leftPoint[2];
|
|
const distance = Math.hypot(dx, dy, dz);
|
|
if (result && result.distance <= distance) continue;
|
|
result = {
|
|
distance,
|
|
horizontalDistance: Math.hypot(dx, dz),
|
|
verticalDistance: Math.abs(dy),
|
|
left: swapped ? rightPoint : leftPoint,
|
|
right: swapped ? leftPoint : rightPoint,
|
|
};
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
|
|
return { components, nearest, componentGap, welded };
|
|
}
|
|
|
|
function triangleComponents(graph) {
|
|
const nodes = graph.triangleNodes ?? [];
|
|
const componentIds = new Int32Array(nodes.length);
|
|
componentIds.fill(-1);
|
|
let nextComponent = 0;
|
|
for (const node of nodes) {
|
|
if (componentIds[node.id] >= 0) continue;
|
|
const pending = [node.id];
|
|
componentIds[node.id] = nextComponent;
|
|
while (pending.length) {
|
|
const triangleId = pending.pop();
|
|
for (const edge of nodes[triangleId].neighbors) {
|
|
if (componentIds[edge.to] >= 0) continue;
|
|
componentIds[edge.to] = nextComponent;
|
|
pending.push(edge.to);
|
|
}
|
|
}
|
|
nextComponent += 1;
|
|
}
|
|
return { componentIds, count: nextComponent };
|
|
}
|
|
|
|
function endpointEvidence(point, graph, retainedTriangleComponents, raw) {
|
|
const retained = findNearestPartyNavigationSurface(graph, point, Number.POSITIVE_INFINITY);
|
|
const rawNearest = raw.nearest(point);
|
|
return {
|
|
point,
|
|
retainedDistance: retained?.distance ?? null,
|
|
retainedPosition: retained?.position ?? null,
|
|
retainedTriangleId: retained?.triangleId ?? null,
|
|
retainedTriangleComponent: retained
|
|
? retainedTriangleComponents.componentIds[retained.triangleId]
|
|
: null,
|
|
raw: rawNearest,
|
|
};
|
|
}
|
|
|
|
function classifyLeg(from, to, reachable, environmentRecipe, componentGap) {
|
|
if (reachable) return "mesh-reachable";
|
|
const fromRaw = from.raw;
|
|
const toRaw = to.raw;
|
|
if (!fromRaw || !toRaw) return "missing-navigation-data";
|
|
if (fromRaw.distance > 6 || toRaw.distance > 6) {
|
|
return "objective-or-entrance-outside-all-navigation";
|
|
}
|
|
if (fromRaw.componentId !== toRaw.componentId) {
|
|
const usesSupportPad = Boolean(environmentRecipe.source.sourceAnchorSupport)
|
|
&& (fromRaw.componentTriangles <= 2 || toRaw.componentTriangles <= 2);
|
|
if (usesSupportPad) return "missing-primary-environment-geometry-source-support-pad";
|
|
if (environmentRecipe.source.assemblyPlacementTranslations) {
|
|
return "separate-authored-wdb-room-placements";
|
|
}
|
|
if (componentGap && componentGap.horizontalDistance <= 1 && componentGap.verticalDistance <= 0.5) {
|
|
return "small-recast-surface-discontinuity-candidate";
|
|
}
|
|
return "separate-recast-surface-components";
|
|
}
|
|
if (from.retainedTriangleComponent !== to.retainedTriangleComponent) {
|
|
return "retained-surface-edge-discontinuity";
|
|
}
|
|
if (from.retainedDistance > 3) return "entrance-or-prior-objective-outside-live-start-limit";
|
|
if (to.retainedDistance > 6) return "objective-outside-live-approach-limit";
|
|
return "unclassified-route-topology";
|
|
}
|
|
|
|
function rounded(value) {
|
|
return Number.isFinite(value) ? Math.round(value * 1000) / 1000 : value;
|
|
}
|
|
|
|
function renderMarkdown(report) {
|
|
const lines = [
|
|
"# RuneWaker navigation gap analysis",
|
|
"",
|
|
"This diagnostic is offline-only. It measures source-authored entrance/objective positions",
|
|
"against every raw Recast surface component and against the retained runtime surface.",
|
|
"The current boss sequence is HealerMan's distance-from-entrance fallback, not a claimed",
|
|
"source-authored order. It does not create links or infer doors/teleports.",
|
|
"",
|
|
`Unreachable heuristic legs analyzed: **${report.unreachableLegs}** across **${report.dungeons.length}** dungeons.`,
|
|
"",
|
|
"| Dungeon | Ordered leg | Geometry classification | From raw comp/dist | To raw comp/dist | Retained distances |",
|
|
"| --- | --- | --- | --- | --- | --- |",
|
|
];
|
|
for (const dungeon of report.dungeons) {
|
|
for (const leg of dungeon.legs.filter((entry) => !entry.reachable)) {
|
|
lines.push(`| ${dungeon.id} | ${leg.fromName} -> ${leg.toName} | ${leg.classification} | `
|
|
+ `${leg.from.raw.componentId}/${rounded(leg.from.raw.distance)}m | `
|
|
+ `${leg.to.raw.componentId}/${rounded(leg.to.raw.distance)}m | `
|
|
+ `${rounded(leg.from.retainedDistance)}m -> ${rounded(leg.to.retainedDistance)}m |`);
|
|
}
|
|
}
|
|
lines.push("", "## Per-dungeon surface inventory", "");
|
|
for (const dungeon of report.dungeons) {
|
|
lines.push(`- ${dungeon.id}: ${dungeon.rawComponentCount} raw vertex-connected surfaces; `
|
|
+ `${dungeon.retainedTriangleComponentCount} shared-edge corridors inside the retained surface; `
|
|
+ `${dungeon.discardedRuntimeNodes} welded nodes discarded by largest-surface retention.`);
|
|
}
|
|
lines.push("", "## Reviewed dispositions", "");
|
|
for (const dungeon of report.dungeons) {
|
|
const finding = dungeon.reviewedFinding;
|
|
lines.push(`### ${dungeon.id}`, "", `- Disposition: ${finding.disposition}.`,
|
|
`- Safe automatic link: ${finding.safeAutomaticLink ? "yes" : "no"}.`,
|
|
`- Finding: ${finding.finding}`,
|
|
...finding.evidence.map((entry) => `- Evidence: ${entry}`), "");
|
|
}
|
|
lines.push("");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
const gameplayReport = await readJson(gameplayReportFile);
|
|
const blockedDungeonIds = new Set(
|
|
gameplayReport.dungeons
|
|
.filter((dungeon) => dungeon.objectiveRouteEvidence?.some((route) => !route.reachable))
|
|
.map((dungeon) => dungeon.id),
|
|
);
|
|
const recipeNames = (await readdir(recipeDirectory))
|
|
.filter((name) => name.endsWith("-population.json"))
|
|
.sort();
|
|
const dungeons = [];
|
|
|
|
for (const recipeName of recipeNames) {
|
|
const recipe = await readJson(path.join(recipeDirectory, recipeName));
|
|
if (!blockedDungeonIds.has(recipe.dungeonId)) continue;
|
|
const [environmentRecipe, environment, source, positions] = await Promise.all([
|
|
readJson(path.resolve(root, recipe.files.environmentRecipe)),
|
|
readJson(path.resolve(root, recipe.files.environmentMetadata)),
|
|
readFile(path.resolve(root, recipe.files.generatedSource), "utf8"),
|
|
navigationPositions(path.resolve(root, recipe.files.navigation)),
|
|
]);
|
|
const bosses = generatedJson(
|
|
source,
|
|
constantCase(recipe.dungeonId) + "_BOSSES",
|
|
" as const satisfies readonly DungeonBossObjective[];",
|
|
);
|
|
const graph = buildPartyNavigationGraph(geometryFromPositions(positions));
|
|
const retainedTriangleComponents = triangleComponents(graph);
|
|
const raw = analyzeRawComponents(positions);
|
|
const legs = [];
|
|
let origin = endpointEvidence(environment.entrance.footPosition, graph, retainedTriangleComponents, raw);
|
|
let originName = "entrance";
|
|
for (const boss of bosses) {
|
|
const target = endpointEvidence(boss.position, graph, retainedTriangleComponents, raw);
|
|
const approach = findNearestPartyNavigationSurface(graph, boss.position, 6);
|
|
const pathResult = approach
|
|
? findPartyNavigationPath(graph, origin.point, approach.position, {
|
|
maxStartSnapDistance: 3,
|
|
maxEndSnapDistance: 6,
|
|
simplifyTolerance: 0.01,
|
|
})
|
|
: null;
|
|
const reachable = Boolean(pathResult?.length);
|
|
const componentGap = origin.raw && target.raw
|
|
? raw.componentGap(origin.raw.componentId, target.raw.componentId)
|
|
: null;
|
|
legs.push({
|
|
fromName: originName,
|
|
toName: boss.name,
|
|
objectiveId: boss.id,
|
|
reachable,
|
|
classification: classifyLeg(origin, target, reachable, environmentRecipe, componentGap),
|
|
componentGap,
|
|
from: origin,
|
|
to: target,
|
|
waypointCount: pathResult?.length ?? 0,
|
|
});
|
|
origin = approach
|
|
? endpointEvidence(approach.position, graph, retainedTriangleComponents, raw)
|
|
: target;
|
|
originName = boss.name;
|
|
}
|
|
dungeons.push({
|
|
id: recipe.dungeonId,
|
|
navigationFile: path.relative(root, path.resolve(root, recipe.files.navigation)).replaceAll("\\", "/"),
|
|
rawComponentCount: raw.components.length,
|
|
retainedTriangleComponentCount: retainedTriangleComponents.count,
|
|
discardedRuntimeNodes: graph.discardedNodeCount,
|
|
reviewedFinding: reviewedFindings[recipe.dungeonId] ?? {
|
|
disposition: "manual-review-required",
|
|
safeAutomaticLink: false,
|
|
finding: "No reviewed source-backed navigation disposition is recorded.",
|
|
evidence: [],
|
|
},
|
|
rawComponents: raw.components.map((component) => ({
|
|
id: component.id,
|
|
vertexCount: component.vertices.length,
|
|
triangleCount: component.triangles.length,
|
|
bounds: component.bounds,
|
|
})),
|
|
legs,
|
|
});
|
|
}
|
|
|
|
const report = {
|
|
schemaVersion: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
methodology: {
|
|
weldTolerance,
|
|
liveStartSnapDistance: 3,
|
|
liveObjectiveSnapDistance: 6,
|
|
mutatesAssets: false,
|
|
},
|
|
unreachableLegs: dungeons.flatMap((dungeon) => dungeon.legs).filter((leg) => !leg.reachable).length,
|
|
dungeons,
|
|
};
|
|
await mkdir(path.dirname(outputJson), { recursive: true });
|
|
await Promise.all([
|
|
writeFile(outputJson, JSON.stringify(report, null, 2) + "\n"),
|
|
writeFile(outputMarkdown, renderMarkdown(report)),
|
|
]);
|
|
console.log(`Analyzed ${report.unreachableLegs} unreachable RuneWaker objective route diagnostics.`);
|
|
console.log(path.relative(root, outputMarkdown));
|