Files
healer-man/scripts/runewaker-pipeline/analyze-navigation-gaps.mjs
T
2026-09-08 10:40:26 -04:00

608 lines
25 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,
objectiveOrderStatus: "partial-gate-evidence-insufficient-for-complete-order",
requirements: ["encounter-gate-state", "source-objective-review", "dynamic-navigation-transition"],
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,
objectiveOrderStatus: "source-objective-review-required",
requirements: ["source-objective-review", "navigation-geometry-review"],
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,
objectiveOrderStatus: "partial-gate-evidence-insufficient-for-complete-order",
requirements: ["encounter-gate-state", "source-objective-review", "dynamic-navigation-transition"],
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,
objectiveOrderStatus: "source-randomized-not-linear",
requirements: ["nonlinear-objectives", "encounter-teleport-state", "dynamic-navigation-transition"],
finding: "The preserved server scripts register six Kalin locations as separate keeper rooms, select the next uncleared room randomly, and advance with a group teleport. The vertical component gaps are encounter state, not wall seams, and a fixed objective order would contradict the source.",
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:1354",
],
},
"raksha-temple": {
disposition: "missing-modular-environment-assembly",
safeAutomaticLink: false,
objectiveOrderStatus: "not-applicable-single-objective",
requirements: ["modular-environment-assembly"],
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,
objectiveOrderStatus: "source-objective-review-required",
requirements: ["modular-environment-assembly", "source-objective-review"],
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,
objectiveOrderStatus: "partial-gate-evidence-insufficient-for-complete-order",
requirements: [
"modular-environment-assembly",
"encounter-gate-state",
"source-objective-review",
"dynamic-navigation-transition",
],
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,
objectiveOrderStatus: "independent-room-order-unresolved",
requirements: ["encounter-teleport-state", "source-objective-review", "dynamic-navigation-transition"],
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 currently ordered legs analyzed: **${report.unreachableLegs}** across **${report.dungeons.length}** dungeons.`,
`Source-authored fixed orders implemented by the audited packages: **${report.resolutionSummary.sourceAuthoredFixedOrders}**.`,
`Authored off-mesh links added by this review: **${report.resolutionSummary.authoredOffMeshLinksAdded}**.`,
"",
"| 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"}.`,
`- Objective order: ${finding.objectiveOrderStatus}.`,
`- Current package order: ${dungeon.objectiveOrdering.kind} (${dungeon.objectiveOrdering.authoritative ? "authoritative" : "non-authoritative"}).`,
`- Finding: ${finding.finding}`,
...finding.requirements.map((entry) => `- Remaining requirement: ${entry}.`),
...finding.evidence.map((entry) => `- Evidence: ${entry}`), "");
}
lines.push("## Resolution boundary", "",
"No off-mesh link was emitted. Every failed leg is either controlled by encounter state,",
"depends on environment geometry that is not yet in the runtime package, represents",
"separate authored rooms, or still lacks a complete source-authored boss sequence.",
"A static link would let companions bypass a door, bridge, wave, or teleport mechanic.",
"The structured requirement counts below are deterministic and identify the remaining work.", "");
for (const [requirement, count] of Object.entries(report.resolutionSummary.requirementDungeonCounts)) {
lines.push(`- ${requirement}: ${count} dungeon(s).`);
}
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 populationProvenance = generatedJson(
source,
constantCase(recipe.dungeonId) + "_POPULATION_PROVENANCE",
" as const;",
);
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,
objectiveOrderStatus: "source-objective-review-required",
requirements: ["source-objective-review"],
finding: "No reviewed source-backed navigation disposition is recorded.",
evidence: [],
},
objectiveOrdering: populationProvenance.objectiveOrder,
rawComponents: raw.components.map((component) => ({
id: component.id,
vertexCount: component.vertices.length,
triangleCount: component.triangles.length,
bounds: component.bounds,
})),
legs,
});
}
const requirementDungeonCounts = {};
for (const dungeon of dungeons) {
for (const requirement of new Set(dungeon.reviewedFinding.requirements)) {
requirementDungeonCounts[requirement] = (requirementDungeonCounts[requirement] ?? 0) + 1;
}
}
const report = {
schemaVersion: 2,
generator: "scripts/runewaker-pipeline/analyze-navigation-gaps.mjs",
methodology: {
weldTolerance,
liveStartSnapDistance: 3,
liveObjectiveSnapDistance: 6,
mutatesAssets: false,
},
resolutionSummary: {
sourceAuthoredFixedOrders: dungeons.filter((dungeon) => dungeon.objectiveOrdering.authoritative).length,
authoredOffMeshLinksAdded: 0,
requirementDungeonCounts: Object.fromEntries(Object.entries(requirementDungeonCounts).sort()),
},
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));