import { readFileSync, writeFileSync } from "node:fs"; import { createHash } from "node:crypto"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { DungeonDefinition } from "../../src/game/dungeonTypes"; import { detectDungeonStairRamps, dungeonStairNavigationBridges, dungeonStairRisers, readDungeonGeometry } from "./wow-stair-geometry.ts"; import RAPIER from "@dimforge/rapier3d-compat"; import { dungeonRampHeight, smoothDungeonStairGeometry } from "../../src/game/dungeonTraversalRamps.ts"; import type { DungeonTraversalRamp } from "../../src/game/dungeonTypes"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const catalog = JSON.parse(readFileSync(path.join(root, "src/game/generated/dungeonCampaignCatalog.json"), "utf8")); const outputFile = path.join(root, "src/game/generated/wowDungeonStairs.generated.json"); const output: Record = process.argv.includes("--update") ? JSON.parse(readFileSync(outputFile, "utf8")).dungeons : {}; const requested = process.argv.slice(2).find((arg) => !arg.startsWith("--")); if (requested && !catalog.definitions.some((dungeon: DungeonDefinition) => dungeon.id === requested)) { throw new Error(`Unknown WoW dungeon: ${requested}`); } await RAPIER.init(); function rampTraversable(world: RAPIER.World, ramp: DungeonTraversalRamp): boolean { const rejected = (reason: string, detail: unknown) => { if (process.argv.includes("--diagnostics")) console.log(ramp.id, reason, JSON.stringify(detail)); return false; }; const dx = ramp.end[0] - ramp.start[0]; const dz = ramp.end[2] - ramp.start[2]; const length = Math.hypot(dx, dz); const shape = new RAPIER.Capsule(0.32, 0.34); const rotation = { x: 0, y: 0, z: 0, w: 1 }; // Test both directions across the center and two capsule-safe lateral lanes. const lateral = Math.max(0, Math.min(ramp.width / 4, ramp.width / 2 - 0.45)); for (const lane of [0, -lateral, lateral]) { const count = Math.max(3, Math.ceil(length / 0.35) + 1); const points = Array.from({ length: count }, (_, index) => { const t = index / (count - 1); return { x: ramp.start[0] + dx * t - dz / length * lane, y: ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t, z: ramp.start[2] + dz * t + dx / length * lane, }; }); for (const point of points) { const floor = world.castRayAndGetNormal(new RAPIER.Ray({ ...point, y: point.y + 1.25 }, { x: 0, y: -1, z: 0 }), 2, true); if (!floor || floor.normal.y < 0.65 || Math.abs(1.25 - floor.timeOfImpact) > 0.55) return rejected("floor", { lane, point, normal: floor?.normal, heightDelta: floor ? 1.25 - floor.timeOfImpact : null }); } for (let index = 1; index < points.length; index += 1) { for (const [start, end] of [[points[index - 1], points[index]], [points[index], points[index - 1]]]) { const delta = { x: end.x - start.x, y: end.y - start.y, z: end.z - start.z }; const distance = Math.hypot(delta.x, delta.y, delta.z); const hit = world.castShape({ ...start, y: start.y + 1.02 }, rotation, delta, shape, 0.025, 1, false); if (hit && hit.time_of_impact < 1 - 0.04 / distance) return rejected("sweep", { lane, start, end, hit: hit.time_of_impact }); } } } return true; } for (const dungeon of catalog.definitions as DungeonDefinition[]) { if (requested && dungeon.id !== requested) continue; if (dungeon.id === "stormwind-stockade") { output[dungeon.id] = { collision: dungeon.assets.collision, ramps: [], reviewedOverride: true }; console.log(`${dungeon.id}: preserving three reviewed ramps`); continue; } const risers = []; const geometries = []; // Wailing Caverns retains its authored environment rather than the campaign's // canonical Manastorm package. Audit the assets actually used by that entry. const collisionAssets = dungeon.id === "wailing-caverns" ? [{ fileName: "wailing-caverns-collision.glb", url: undefined, checksum: createHash("sha256").update(readFileSync(path.join(root, "src/assets/game/dungeons/wailing-caverns/wailing-caverns-collision.glb"))).digest("hex"), }] : dungeon.assets.collision; const assetFile = (asset: { url?: string; fileName: string }) => asset.url ? path.join(root, "public", asset.url) : path.join(root, "src/assets/game/dungeons", dungeon.id, asset.fileName); const navigationAsset = dungeon.id === "wailing-caverns" ? { fileName: "wailing-caverns-navigation.glb", url: undefined, checksum: createHash("sha256").update(readFileSync(path.join(root, "src/assets/game/dungeons/wailing-caverns/wailing-caverns-navigation.glb"))).digest("hex"), } : dungeon.assets.navigation; for (const asset of collisionAssets) { for (const geometry of await readDungeonGeometry(assetFile(asset))) { risers.push(...dungeonStairRisers(geometry)); geometries.push(geometry); } } const candidates = detectDungeonStairRamps(risers); const verify = (input: DungeonTraversalRamp[]): DungeonTraversalRamp[] => { let ramps = input; // Rebuild after rejection: accepted ramps must not depend on rejected geometry changes. while (ramps.length) { const world = new RAPIER.World({ x: 0, y: -18, z: 0 }); try { for (const source of geometries) { const geometry = source.clone(); smoothDungeonStairGeometry(geometry, ramps); world.createCollider(RAPIER.ColliderDesc.trimesh( Float32Array.from(geometry.getAttribute("position").array), Uint32Array.from(geometry.index?.array ?? Array.from({ length: geometry.getAttribute("position").count }, (_, index) => index)), )); geometry.dispose(); } world.step(); const accepted = ramps.filter((ramp) => rampTraversable(world, ramp)); if (accepted.length === ramps.length) break; ramps = accepted; } finally { world.free(); } } return ramps; }; let ramps = verify(candidates); // If a long merged flight was unsafe, retain independently verified shorter // flights instead. Do not let fallback fragments reshape an accepted plane. const fallback = detectDungeonStairRamps(risers, false).filter((ramp) => !ramps.some((accepted) => { const within = (a: DungeonTraversalRamp, b: DungeonTraversalRamp) => [b.start, b.end, b.start.map((v, i) => (v + b.end[i]) / 2)].some((point) => { const height = dungeonRampHeight(a, point[0], point[2]); return height !== null && Math.abs(point[1] - height) <= 0.85; }); return within(accepted, ramp) || within(ramp, accepted); })).map((ramp, index) => ({ ...ramp, id: `stair-fallback-${index + 1}` })); ramps = verify([...ramps, ...fallback]); output[dungeon.id] = { collision: collisionAssets.map(({ fileName, url, checksum }) => ({ fileName, url, checksum })), navigation: navigationAsset, ramps, candidateCount: candidates.length + fallback.length, rejectedCount: candidates.length + fallback.length - ramps.length, }; if (ramps.length && navigationAsset) { const navigation = await readDungeonGeometry(assetFile(navigationAsset)); const largest = navigation.sort((a, b) => b.getAttribute("position").count - a.getAttribute("position").count)[0]; (output[dungeon.id] as { ramps: DungeonTraversalRamp[] }).ramps = dungeonStairNavigationBridges(largest, ramps); navigation.forEach((geometry) => geometry.dispose()); } console.log(`${dungeon.id}: ${ramps.length}/${candidates.length + fallback.length} collision-verified stair proxies`); geometries.forEach((geometry) => geometry.dispose()); } if (process.argv.includes("--write") || process.argv.includes("--update")) { if (requested && !process.argv.includes("--update")) throw new Error("Refusing to replace the fleet catalog with a single-dungeon scan."); writeFileSync(outputFile, `${JSON.stringify({ schemaVersion: 1, dungeons: output }, null, 2)}\n`); }