#!/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));