Files
healer-man/src/game/wowDungeonStairAssets.test.ts
T
2026-09-13 11:07:56 -04:00

82 lines
4.2 KiB
TypeScript

import path from "node:path";
import RAPIER from "@dimforge/rapier3d-compat";
import { BufferGeometry, Group, Mesh } from "three";
import { beforeAll, describe, expect, it } from "vitest";
import type { DungeonTraversalRamp } from "./dungeonTypes";
import stairs from "./generated/wowDungeonStairs.generated.json";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { bridgeDungeonStairNavigation, smoothDungeonStairGeometry } from "./dungeonTraversalRamps";
import { collisionTrimeshes, prepareCollision } from "../scene/productionDungeonCollision";
import { DUNGEON_PHYSICAL_COLLISION_GROUPS } from "../scene/dungeonLineOfSight";
import { createPartyDirectSegmentValidator } from "../scene/partyTraversal";
const pipelineModule = "../../scripts/dungeon-pipeline/wow-stair-geometry.ts";
const { readDungeonGeometry } = await import(pipelineModule) as {
readDungeonGeometry(file: string): Promise<BufferGeometry[]>;
};
const packages = Object.entries(stairs.dungeons).filter(([, entry]) => entry.ramps.length);
beforeAll(async () => { await RAPIER.init(); });
describe("shipping WoW dungeon stair proxies", () => {
it.each(packages)("%s supports live NPC capsule sweeps uphill/downhill in three lanes", async (id) => {
const definition = requireDungeonDefinition(id);
const ramps = definition.traversalRamps!;
const source = new Group();
for (const asset of definition.assets.collision) {
for (const geometry of await readDungeonGeometry(path.join(process.cwd(), "public", asset.url!))) {
source.add(new Mesh(geometry));
}
}
const prepared = prepareCollision(source, ramps);
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
try {
for (const mesh of collisionTrimeshes(prepared)) {
world.createCollider(RAPIER.ColliderDesc.trimesh(Float32Array.from(mesh.vertices), Uint32Array.from(mesh.indices))
.setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
}
world.step();
const allowed = createPartyDirectSegmentValidator(RAPIER, world);
for (const ramp of ramps) {
const dx = ramp.end[0] - ramp.start[0]; const dz = ramp.end[2] - ramp.start[2];
const length = Math.hypot(dx, dz);
const count = Math.max(3, Math.ceil(length / 0.35) + 1);
const lateral = Math.max(0, Math.min(ramp.width / 4, ramp.width / 2 - 0.45));
for (const lane of [0, -lateral, lateral]) {
const points = Array.from({ length: count }, (_, index): [number, number, number] => {
const t = index / (count - 1);
return [ramp.start[0] + dx * t - dz / length * lane,
ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t,
ramp.start[2] + dz * t + dx / length * lane];
});
for (let index = 1; index < points.length; index += 1) {
const detail = `${id}/${ramp.id}, lane ${lane}, sample ${index}`;
expect(allowed(points[index - 1], points[index]), detail).toBe(true);
expect(allowed(points[index], points[index - 1]), detail).toBe(true);
}
}
}
} finally {
world.free();
for (const group of [source, prepared]) group.traverse((node) => { if ((node as Mesh).isMesh) (node as Mesh).geometry.dispose(); });
}
}, 120000);
it("every generated seam matches actual shipping navigation vertices", async () => {
for (const [id] of packages) {
const definition = requireDungeonDefinition(id);
const ramps = definition.traversalRamps as readonly DungeonTraversalRamp[];
const bridges = ramps.reduce((count, ramp) => count + (ramp.navigationBridges?.length ?? 0), 0);
if (!bridges) continue;
const geometries = await readDungeonGeometry(path.join(process.cwd(), "public", definition.assets.navigation!.url!));
const geometry = geometries.sort((a, b) => b.getAttribute("position").count - a.getAttribute("position").count)[0];
try {
const originalCount = geometry.index!.count;
smoothDungeonStairGeometry(geometry, ramps, 0.05);
expect(() => bridgeDungeonStairNavigation(geometry, ramps, 0.05)).not.toThrow();
expect(geometry.index!.count).toBe(originalCount + bridges * 6);
} finally { geometries.forEach((entry) => entry.dispose()); }
}
}, 120000);
});