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 = {}; 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`);