Files
2026-10-06 22:25:34 -04:00

88 lines
4.8 KiB
TypeScript

import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { GLTFLoader, MeshoptDecoder } from "three-stdlib";
import { DUNGEON_REGISTRY } from "../../src/game/dungeonRegistry";
import { findPartyNavigationPath, type PartyNavigationPosition } from "../../src/game/partyNavigation";
import { preparePartyNavigationGraph } from "../../src/game/partyNavigationAssetCache";
// Observational plan only. The companion playtest uses ordinary UI/WASD and
// reports physical blocking; this planner never relocates a runtime player.
const output = path.resolve(process.argv.find(value => value.startsWith("--output="))?.slice(9)
?? "artifacts/playtest/rom-deep-review/walking-plans.json");
const maximumDistance = 45;
const plans: Record<string, unknown> = {};
const reports: unknown[] = [];
const loader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder);
function boundedWaypoints(route: readonly PartyNavigationPosition[]) {
const points: number[][] = [];
let distance = 0;
let sinceCapture = 0;
for (let index = 1; index < route.length; index += 1) {
const a = route[index - 1], b = route[index];
const length = Math.hypot(b[0] - a[0], b[2] - a[2]);
const retainedLength = Math.min(length, maximumDistance - distance);
const segments = Math.max(1, Math.ceil(retainedLength / 4));
for (let step = 1; step <= segments; step++) {
const ratio = length ? retainedLength / length * step / segments : 1;
const point = a.map((value, axis) => value + (b[axis] - value) * ratio);
sinceCapture += retainedLength / segments;
// Keep portal crossings even on short legs so walking follows actual links.
if (sinceCapture > 0.5 || step === segments) points.push(point);
sinceCapture = 0;
}
distance += retainedLength;
if (distance >= maximumDistance) break;
}
return { points, plannedDistance: distance };
}
for (const definition of Object.values(DUNGEON_REGISTRY).filter(row => row.expansion === "runewaker" && !row.id.startsWith("bite-sized-"))) {
const asset = definition.assets.navigation;
if (!asset) continue;
const file = `src/assets/game/dungeons/${definition.id}/${asset.fileName}`;
try {
const bytes = await readFile(file);
const checksum = createHash("sha256").update(bytes).digest("hex");
const gltf = await loader.parseAsync(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), "");
const graph = preparePartyNavigationGraph(file, gltf.scene, definition.navigationLinks ?? [],
definition.traversalRamps ?? [], 250_000, false, gltf.userData.runewakerNavigationTopology,
asset.detourTopology, asset.navigationComponentPolicy, checksum);
const entry = definition.entrance.footPosition;
const targets = definition.bosses.map(boss => ({ name: boss.name, position: boss.position }));
const bossRoutes = targets.map(target => ({ target, route: findPartyNavigationPath(graph, entry, target.position, {
maxStartSnapDistance: 3, maxEndSnapDistance: 8,
}) }));
let selected = bossRoutes.find(row => row.route && row.route.length > 1);
if (!selected) {
const forward = definition.entrance.forward;
// Try a retained surface ahead of the entry when no boss is connected.
for (const distance of [45, 25, 12, 6]) {
const position = entry.map((value, axis) => value + forward[axis] * distance) as unknown as PartyNavigationPosition;
const route = findPartyNavigationPath(graph, entry, position, { maxStartSnapDistance: 3, maxEndSnapDistance: 8 });
if (route && route.length > 1) {
selected = { target: { name: `forward surface ${distance}m`, position }, route };
break;
}
}
}
const bounded = selected?.route ? boundedWaypoints(selected.route) : {
points: [3, 6].map(distance => entry.map((value, axis) => value + definition.entrance.forward[axis] * distance)),
plannedDistance: 6,
source: "ordinary short forward collision probe; navigation did not certify a connected target",
};
plans[definition.id] = bounded;
reports.push({ id: definition.id, checksum, target: selected?.target.name ?? null,
bossRoutes: bossRoutes.map(row => ({ name: row.target.name, connected: row.route !== null })), ...bounded });
console.log(`${definition.id}: ${bounded.points.length} waypoints, ${bounded.plannedDistance.toFixed(1)}m, ${selected?.target.name ?? "no connected target"}`);
} catch (error) {
reports.push({ id: definition.id, error: String(error) });
plans[definition.id] = { points: [] };
console.log(`${definition.id}: ${String(error)}`);
}
}
await mkdir(path.dirname(output), { recursive: true });
await writeFile(output, `${JSON.stringify(plans, null, 2)}\n`);
await writeFile(output.replace(/\.json$/, "-proof.json"), `${JSON.stringify({ maximumDistance, reports }, null, 2)}\n`);