Files
healer-man/scripts/dungeon-pipeline/audit-wow-stairs.ts
T
2026-09-13 11:07:56 -04:00

143 lines
7.8 KiB
TypeScript

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<string, unknown> = 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`);
}