Files
healer-man/scripts/audit-mob-routes.ts
2026-08-14 15:56:39 -04:00

118 lines
4.1 KiB
TypeScript

import { NodeIO } from "@gltf-transform/core";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { BufferAttribute, BufferGeometry, DoubleSide, Ray, Vector3 } from "three";
import { MeshBVH } from "three-mesh-bvh";
import {
createLoopedPath,
samplePackMember,
WAILING_CAVERNS_BOSS_SPAWNS,
WAILING_CAVERNS_ENTRANCE_SPAWNS,
WAILING_CAVERNS_ROAMING_PACKS,
} from "../src/game/mobPopulation";
const here = dirname(fileURLToPath(import.meta.url));
const collisionPath = resolve(
here,
"../src/assets/game/dungeons/wailing-caverns/wailing-caverns-collision.glb",
);
const document = await new NodeIO().read(collisionPath);
const primitive = document.getRoot().listMeshes()[0]?.listPrimitives()[0];
if (!primitive) throw new Error("Collision GLB has no mesh primitive.");
const positions = primitive.getAttribute("POSITION")?.getArray();
const indices = primitive.getIndices()?.getArray();
if (!positions || !indices) throw new Error("Collision GLB lacks indexed positions.");
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(positions, 3));
geometry.setIndex(new BufferAttribute(indices, 1));
const bvh = new MeshBVH(geometry);
const down = new Vector3(0, -1, 0);
const origin = new Vector3();
const sampledPosition: [number, number, number] = [0, 0, 0];
const sampledForward: [number, number, number] = [0, 0, 1];
const entranceCamera = new Vector3(168.44, -69.7, 135.31);
const sightTarget = new Vector3();
const sightDirection = new Vector3();
function floorDelta(point: readonly [number, number, number]): number | null {
origin.set(point[0], point[1] + 2.5, point[2]);
const hit = bvh.raycastFirst(new Ray(origin, down), DoubleSide, 0, 7);
return hit ? Math.abs(hit.point.y - point[1]) : null;
}
function hasEntranceLineOfSight(point: readonly [number, number, number]): boolean {
sightTarget.set(point[0], point[1] + 1.1, point[2]);
sightDirection.copy(sightTarget).sub(entranceCamera);
const distance = sightDirection.length();
sightDirection.multiplyScalar(1 / distance);
return bvh.raycastFirst(
new Ray(entranceCamera, sightDirection),
DoubleSide,
0,
Math.max(0, distance - 0.25),
) === null;
}
const routeReports = WAILING_CAVERNS_ROAMING_PACKS.map((pack) => {
const path = createLoopedPath(pack.waypoints);
let missingFloorSamples = 0;
let maxFloorDelta = 0;
const sampleCount = Math.max(24, Math.ceil(path.totalLength / 2));
for (let sampleIndex = 0; sampleIndex < sampleCount; sampleIndex += 1) {
const leaderDistance = (sampleIndex / sampleCount) * path.totalLength;
for (const member of pack.members) {
samplePackMember(
path,
leaderDistance,
member.formationOffset,
sampledPosition,
sampledForward,
);
const delta = floorDelta(sampledPosition);
if (delta === null) missingFloorSamples += 1;
else maxFloorDelta = Math.max(maxFloorDelta, delta);
}
}
return {
id: pack.id,
pathId: pack.pathId,
members: pack.members.length,
pathLength: Number(path.totalLength.toFixed(2)),
maxFloorDelta: Number(maxFloorDelta.toFixed(3)),
missingFloorSamples,
};
});
const bossReports = WAILING_CAVERNS_BOSS_SPAWNS.map((spawn) => {
const closest = bvh.closestPointToPoint(new Vector3(...spawn.position));
return {
id: spawn.id,
closestSurfaceDistance: closest ? Number(closest.distance.toFixed(3)) : null,
};
});
const entranceReports = WAILING_CAVERNS_ENTRANCE_SPAWNS.map((spawn) => ({
id: spawn.id,
floorDelta: floorDelta(spawn.position),
entranceLineOfSight: hasEntranceLineOfSight(spawn.position),
}));
const failedRoutes = routeReports.filter(
(report) => report.missingFloorSamples > 0 || report.maxFloorDelta > 4,
);
const failedEntranceSpawns = entranceReports.filter(
(report) => report.floorDelta === null || report.floorDelta > 0.2 || !report.entranceLineOfSight,
);
console.log(JSON.stringify({
routeReports,
bossReports,
entranceReports,
failedRoutes,
failedEntranceSpawns,
}, null, 2));
if (failedRoutes.length > 0 || failedEntranceSpawns.length > 0) process.exitCode = 1;