import { NodeIO } from "@gltf-transform/core"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { createRequire } from "node:module"; import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { MeshoptDecoder } from "meshoptimizer"; import { BufferAttribute, BufferGeometry, Group, Mesh } from "three"; import { findNearestPartyNavigationSurface, findPartyNavigationPath, } from "../src/game/partyNavigation"; import { preparePartyNavigationGraph } from "../src/game/partyNavigationAssetCache"; import type { DungeonDefinition } from "../src/game/dungeonTypes"; import { withWowDungeonEnvironment } from "../src/game/wowDungeonEnvironment"; const require = createRequire(import.meta.url); const { DEFAULT_TEST_DUNGEON_ID, FULL_STOCKADE_DUNGEON_ID } = require("./playtest-defaults.cjs") as { DEFAULT_TEST_DUNGEON_ID: string; FULL_STOCKADE_DUNGEON_ID: string; }; type Position = readonly [number, number, number]; type ProbeDungeon = { id: string; title: string; entrance: { footPosition: Position }; assets: { navigation?: { fileName: string; url: string } | null }; navigationLinks?: readonly { start: Position; end: Position; bidirectional: boolean }[]; traversalRamps?: DungeonDefinition["traversalRamps"]; bosses: readonly { id: string; name: string; position: Position; eventOnly?: boolean }[]; }; const campaignCatalogPath = resolve(process.cwd(), "src", "game", "generated", "dungeonCampaignCatalog.json"); const campaignCatalog = JSON.parse(readFileSync(campaignCatalogPath, "utf8")) as { definitions: ProbeDungeon[] }; function requireGeneratedDungeonDefinition(dungeonId: string): ProbeDungeon { const dungeon = campaignCatalog.definitions.find((candidate) => candidate.id === dungeonId); if (!dungeon) throw new Error(`Generated campaign catalog has no dungeon ${dungeonId}.`); return dungeon; } const navigationIo = new NodeIO() .registerExtensions(ALL_EXTENSIONS) .registerDependencies({ "meshopt.decoder": MeshoptDecoder }); const requestedDungeonIds = process.argv.slice(2).filter((argument) => !argument.startsWith("--")); const dungeonIds = requestedDungeonIds.length ? requestedDungeonIds : [DEFAULT_TEST_DUNGEON_ID]; async function navigationGraph(dungeonId: string) { const dungeon = requireGeneratedDungeonDefinition(dungeonId); const navigation = dungeon.assets.navigation; if (!navigation) throw new Error(`${dungeonId} has no navigation asset.`); const publicAssetPath = navigation.url.startsWith("/") ? resolve(process.cwd(), "public", navigation.url.slice(1)) : resolve(process.cwd(), "public", "assets", "game", "dungeons", dungeonId, navigation.fileName); const sourceAssetPath = resolve(process.cwd(), "src", "assets", "game", "dungeons", dungeonId, navigation.fileName); const file = [publicAssetPath, sourceAssetPath].find(existsSync); if (!file) throw new Error(`${dungeonId} navigation asset is missing at ${publicAssetPath} and ${sourceAssetPath}.`); await MeshoptDecoder.ready; const document = await navigationIo.read(file); const scene = new Group(); for (const node of document.getRoot().listNodes()) { const mesh = node.getMesh(); if (!mesh) continue; for (const primitive of mesh.listPrimitives()) { const accessor = primitive.getAttribute("POSITION"); const source = accessor?.getArray(); if (!source || !accessor) continue; const geometry = new BufferGeometry(); geometry.setAttribute("position", new BufferAttribute(source, 3, accessor.getNormalized())); const sourceIndices = primitive.getIndices()?.getArray(); if (sourceIndices) geometry.setIndex(Array.from(sourceIndices)); const navigationMesh = new Mesh(geometry); navigationMesh.matrixAutoUpdate = false; navigationMesh.matrix.fromArray(node.getWorldMatrix()); scene.add(navigationMesh); } } const definition = withWowDungeonEnvironment(dungeon as unknown as DungeonDefinition); try { return preparePartyNavigationGraph(navigation.url, scene, definition.navigationLinks ?? [], definition.traversalRamps ?? []); } finally { scene.traverse((node) => { if ((node as Mesh).isMesh) (node as Mesh).geometry.dispose(); }); } } for (const dungeonId of dungeonIds) { const dungeon = requireGeneratedDungeonDefinition(dungeonId); const eligibleBosses = dungeon.bosses.filter((candidate) => !candidate.eventOnly); const bosses = dungeonId === DEFAULT_TEST_DUNGEON_ID || dungeonId === FULL_STOCKADE_DUNGEON_ID ? eligibleBosses : eligibleBosses.slice(0, 1); if (!bosses.length) throw new Error(`${dungeonId} has no normal campaign bosses.`); const graph = await navigationGraph(dungeonId); const start = findNearestPartyNavigationSurface(graph, dungeon.entrance.footPosition, 3); if (!start) throw new Error(`${dungeonId} entrance does not snap to navigation.`); const waypoints: number[][] = [[...start.position]]; const objectives: Array<{ id: string; name: string; position: readonly number[]; navigationPosition: readonly number[]; waypointIndex: number; expectedNavigationDistance: number; snapDistance: number }> = []; let segmentStart = start.position; let expectedDistance = 0; for (const boss of bosses) { const end = findNearestPartyNavigationSurface(graph, boss.position, 6); if (!end) throw new Error(`${dungeonId} ${boss.name} does not snap to navigation.`); const path = findPartyNavigationPath(graph, segmentStart, end.position, { maxStartSnapDistance: 3, maxEndSnapDistance: 6, simplifyTolerance: 0.01, }); if (!path?.length) throw new Error(`${dungeonId} has no navigation path to ${boss.name}.`); const startIndex = waypoints.length - 1; let segmentDistance = 0; for (let index = 1; index < path.length; index += 1) { const segmentLength = Math.hypot( path[index][0] - path[index - 1][0], path[index][1] - path[index - 1][1], path[index][2] - path[index - 1][2], ); segmentDistance += segmentLength; expectedDistance += segmentLength; waypoints.push([...path[index]]); } objectives.push({ id: boss.id, name: boss.name, position: boss.position, navigationPosition: end.position, waypointIndex: waypoints.length - 1, expectedNavigationDistance: Number(segmentDistance.toFixed(2)), snapDistance: end.distance, }); if (waypoints.length - 1 <= startIndex) { throw new Error(`${dungeonId} navigation path to ${boss.name} has no traversable segment.`); } segmentStart = end.position; } process.stdout.write(`${JSON.stringify({ dungeonId, status: "navigation-guidance-only; live UI traversal required", objective: bosses.at(-1)!.name, objectiveId: bosses.at(-1)!.id, objectives, entrance: start.position, objectivePosition: bosses.at(-1)!.position, startSnapDistance: start.distance, objectiveSnapDistance: objectives.at(-1)!.snapDistance, waypointCount: waypoints.length, expectedNavigationDistance: Number(expectedDistance.toFixed(2)), waypoints, })}\n`); }