Files
healer-man/scripts/runewaker-pipeline/analyze-navigation-gaps.mjs
T
2026-08-14 15:56:39 -04:00

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));