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