import { NodeIO } from "@gltf-transform/core"; import RAPIER from "@dimforge/rapier3d-compat"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { MeshoptDecoder } from "meshoptimizer"; import { BufferAttribute, BufferGeometry, Group, Mesh, Ray, Vector3 } from "three"; import { beforeAll, describe, expect, it } from "vitest"; import { requireDungeonDefinition } from "./dungeonRegistry"; import { findNearestPartyNavigationNode, findNearestPartyNavigationSurface, findPartyNavigationPath, type PartyNavigationGraph, } from "./partyNavigation"; import { preparePartyNavigationGraph } from "./partyNavigationAssetCache"; import { shouldLoadPartyNavigation } from "./partyNavigationPolicy"; import { emptyPartyRouteState, planPartyRoute } from "./partyPathing"; import { partyRejoinMovement } from "./partyRejoin"; import { partyCombatMovementGoal, stepPartyPosition } from "./partyMovement"; import { derivePartySpawnSlots } from "./partySpawnSlots"; import { collisionTrimeshes, collisionLineOfSightTrimeshes, prepareCollision } from "../scene/productionDungeonCollision"; import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS, hasDungeonLineOfSight } from "../scene/dungeonLineOfSight"; import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator, createPartyRecoveryPointValidator } from "../scene/partyTraversal"; import { configurePlayerCharacterController, PLAYER_CHARACTER_CONTROLLER_OFFSET } from "./playerMovement"; import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "../scene/actorCollision"; import { choosePartyStuckRecoveryPosition } from "./partyStuckRecovery"; import { createMobPursuit } from "./mobPursuit"; const STOCKADES_NAVIGATION = "public/assets/game/manastorm/34-stormwindjail/navigation.glb"; const TANK_RANGE = Object.freeze({ minimum: 0, preferred: 2.9, maximum: 3.15 }); const stockades = requireDungeonDefinition("stormwind-stockade"); const biteSizedStockades = requireDungeonDefinition("bite-sized-stormwind-stockade"); let graph: PartyNavigationGraph; let biteSizedGraph: PartyNavigationGraph; let sourceCollision: Group; beforeAll(async () => { await RAPIER.init(); await MeshoptDecoder.ready; const document = await new NodeIO() .registerExtensions(ALL_EXTENSIONS) .registerDependencies({ "meshopt.decoder": MeshoptDecoder }) .read(STOCKADES_NAVIGATION); const meshNode = document.getRoot().listNodes().find((node) => node.getMesh()); const primitive = meshNode?.getMesh()?.listPrimitives()[0]; const accessor = primitive?.getAttribute("POSITION"); const sourcePositions = accessor?.getArray(); if (!meshNode || !accessor || !sourcePositions) { throw new Error("Stormwind Stockade navigation GLB is missing its triangle surface."); } if (!(sourcePositions instanceof Int16Array) || !accessor.getNormalized()) { throw new Error("Stormwind Stockade navigation regression expects normalized Int16 positions."); } const geometry = new BufferGeometry(); geometry.setAttribute("position", new BufferAttribute(sourcePositions, 3, accessor.getNormalized())); const sourceIndices = primitive?.getIndices()?.getArray(); if (sourceIndices) geometry.setIndex(Array.from(sourceIndices)); const mesh = new Mesh(geometry); mesh.matrixAutoUpdate = false; mesh.matrix.fromArray(meshNode.getWorldMatrix()); const scene = new Group(); scene.add(mesh); // Exercise the same quantized-buffer and node-transform path as GLTFLoader // in the shipping runtime, not a separately pre-expanded test geometry. graph = preparePartyNavigationGraph( "/test/stockades-navigation.glb", scene, stockades.navigationLinks, stockades.traversalRamps, ); biteSizedGraph = preparePartyNavigationGraph( "/test/bite-sized-stockades-navigation.glb", scene, biteSizedStockades.navigationLinks, biteSizedStockades.traversalRamps, ); geometry.dispose(); const collisionDocument = await new NodeIO() .registerExtensions(ALL_EXTENSIONS) .registerDependencies({ "meshopt.decoder": MeshoptDecoder }) .read("public/assets/game/manastorm/34-stormwindjail/collision.glb"); sourceCollision = new Group(); for (const node of collisionDocument.getRoot().listNodes()) { for (const collisionPrimitive of node.getMesh()?.listPrimitives() ?? []) { const positions = collisionPrimitive.getAttribute("POSITION")!; const values = positions.getArray(); if (!(values instanceof Int16Array)) throw new Error("Expected quantized Stockades collision positions."); const collisionGeometry = new BufferGeometry(); collisionGeometry.setAttribute("position", new BufferAttribute(values, 3, positions.getNormalized())); collisionGeometry.setIndex(Array.from(collisionPrimitive.getIndices()!.getArray()!)); const collisionMesh = new Mesh(collisionGeometry); collisionMesh.matrixAutoUpdate = false; collisionMesh.matrix.fromArray(node.getWorldMatrix()); sourceCollision.add(collisionMesh); } } }); function collisionWorld(useRamps = true, includeSight = false): RAPIER.World { const world = new RAPIER.World({ x: 0, y: -18, z: 0 }); const prepared = prepareCollision(sourceCollision, useRamps ? stockades.traversalRamps : []); for (const trimesh of collisionTrimeshes(prepared)) { world.createCollider(RAPIER.ColliderDesc.trimesh( Float32Array.from(trimesh.vertices), Uint32Array.from(trimesh.indices), ).setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS)); } if (includeSight) { for (const trimesh of collisionLineOfSightTrimeshes(prepared)) { world.createCollider(RAPIER.ColliderDesc.trimesh( Float32Array.from(trimesh.vertices), Uint32Array.from(trimesh.indices), ).setCollisionGroups(DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS).setSensor(true)); } } for (const child of prepared.children) (child as Mesh).geometry.dispose(); world.step(); return world; } function distance(left: readonly number[], right: readonly number[]): number { return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]); } describe("Stormwind Stockade party navigation", () => { it("returns resurrected teammates from the entrance and every boss checkpoint through approved shipping corridors", () => { const world = collisionWorld(true, true); try { const directSegmentAllowed = createPartyDirectSegmentValidator(RAPIER, world); const routeSegmentAllowed = createPartyRouteSegmentValidator(RAPIER, world); const checkpoints = [stockades.entrance, ...stockades.bosses.map((boss) => ({ footPosition: boss.position, yaw: stockades.entrance.yaw }))]; for (const checkpoint of checkpoints) { const player = findNearestPartyNavigationSurface(graph, stockades.entrance.footPosition, 6)!.position; for (const slot of derivePartySpawnSlots(checkpoint, 5).slice(1)) { const origin = findNearestPartyNavigationSurface(graph, slot.footPosition, 6)?.position; expect(origin, JSON.stringify(slot)).toBeDefined(); let result = partyRejoinMovement("returning-member", origin!, player, 0, graph, emptyPartyRouteState(), { directSegmentAllowed, routeSegmentAllowed, }); expect(result.blocked, JSON.stringify({ checkpoint, origin, waypoints: result.route.waypoints })).toBe(false); expect(result.rejoined || result.waypoint !== null).toBe(true); for (let index = 1; index < result.route.waypoints.length; index += 1) { expect(routeSegmentAllowed(result.route.waypoints[index - 1], result.route.waypoints[index])).toBe(true); } const current: [number, number, number] = [...origin!]; const next: [number, number, number] = [0, 0, 0]; for (let frame = 1; frame <= 4_000 && !result.rejoined; frame += 1) { if (result.waypoint) { stepPartyPosition(current, result.waypoint, .175, next); current[0] = next[0]; current[1] = next[1]; current[2] = next[2]; } result = partyRejoinMovement("returning-member", current, player, frame * 50, graph, result.route, { directSegmentAllowed, routeSegmentAllowed, }); } expect(result.rejoined, JSON.stringify({ checkpoint, origin, current, result })).toBe(true); } } } finally { world.free(); } }, 60_000); it("blocks combat sight across shipping room walls in both directions while preserving clear stair sight", () => { const world = collisionWorld(true, true); try { const prepared = prepareCollision(sourceCollision, stockades.traversalRamps); let checkedWalls = 0; for (const mesh of collisionLineOfSightTrimeshes(prepared)) { for (let offset = 0; offset < mesh.indices.length && checkedWalls < 12; offset += 3) { const a = Number(mesh.indices[offset]) * 3; const b = Number(mesh.indices[offset + 1]) * 3; const c = Number(mesh.indices[offset + 2]) * 3; const v = mesh.vertices; const ab = [Number(v[b]) - Number(v[a]), Number(v[b + 1]) - Number(v[a + 1]), Number(v[b + 2]) - Number(v[a + 2])]; const ac = [Number(v[c]) - Number(v[a]), Number(v[c + 1]) - Number(v[a + 1]), Number(v[c + 2]) - Number(v[a + 2])]; const normalX = ab[1] * ac[2] - ab[2] * ac[1]; const normalZ = ab[0] * ac[1] - ab[1] * ac[0]; const length = Math.hypot(normalX, normalZ); if (length < 0.1) continue; const center = [0, 1, 2].map((axis) => (Number(v[a + axis]) + Number(v[b + axis]) + Number(v[c + axis])) / 3); const first = findNearestPartyNavigationSurface(graph, [center[0] + normalX / length, center[1] - 1.05, center[2] + normalZ / length], 2.5)?.position; const second = findNearestPartyNavigationSurface(graph, [center[0] - normalX / length, center[1] - 1.05, center[2] - normalZ / length], 2.5)?.position; if (!first || !second || Math.abs(first[1] - second[1]) > 0.4 || distance(first, second) < 1.5 || Math.abs((first[1] + second[1]) / 2 + 1.05 - center[1]) > 0.15 || Math.hypot(first[0] - center[0], first[2] - center[2]) > 1.3 || Math.hypot(second[0] - center[0], second[2] - center[2]) > 1.3) continue; const sideA = (first[0] - center[0]) * normalX + (first[2] - center[2]) * normalZ; const sideB = (second[0] - center[0]) * normalX + (second[2] - center[2]) * normalZ; if (sideA * sideB >= 0) continue; // Nearby decorative faces/door-frame triangles are not room walls // unless the actors' chest ray actually crosses this source face. const origin = new Vector3(first[0], first[1] + 1.05, first[2]); const destination = new Vector3(second[0], second[1] + 1.05, second[2]); const ray = new Ray(origin, destination.clone().sub(origin).normalize()); const intersection = ray.intersectTriangle( new Vector3(Number(v[a]), Number(v[a + 1]), Number(v[a + 2])), new Vector3(Number(v[b]), Number(v[b + 1]), Number(v[b + 2])), new Vector3(Number(v[c]), Number(v[c + 1]), Number(v[c + 2])), false, new Vector3(), ); if (!intersection || intersection.distanceTo(origin) >= destination.distanceTo(origin)) continue; expect(hasDungeonLineOfSight(RAPIER, world, first, second), JSON.stringify({ center, first, second })).toBe(false); expect(hasDungeonLineOfSight(RAPIER, world, second, first), JSON.stringify({ center, first, second })).toBe(false); checkedWalls += 1; } } for (const child of prepared.children) (child as Mesh).geometry.dispose(); expect(checkedWalls).toBeGreaterThanOrEqual(8); for (const ramp of stockades.traversalRamps ?? []) { expect(hasDungeonLineOfSight(RAPIER, world, ramp.start, ramp.end)).toBe(true); expect(hasDungeonLineOfSight(RAPIER, world, ramp.end, ramp.start)).toBe(true); } } finally { world.free(); } }, 20_000); it("moves pursuing mobs up and down all shipping ramps without a false teleport or reset", () => { const world = collisionWorld(true, true); try { const directAllowed = createPartyDirectSegmentValidator(RAPIER, world); const routeAllowed = createPartyRouteSegmentValidator(RAPIER, world); const navigator = createMobPursuit({ graph: () => graph, directAllowed, routeAllowed, hasLineOfSight: (a, b) => hasDungeonLineOfSight(RAPIER, world, a, b), validateLanding: createPartyRecoveryPointValidator(RAPIER, world, graph) }); for (const ramp of stockades.traversalRamps ?? []) { for (const [start, target] of [[ramp.start, ramp.end], [ramp.end, ramp.start]]) { const initialRoute = planPartyRoute(graph, "probe", start, target, 0, { maxOriginSnapDistance: 3, maxDestinationSnapDistance: 3, directSegmentAllowed: directAllowed, routeSegmentAllowed: routeAllowed, destinationReached: (surface, destination) => distance(surface, destination) <= 1.5 && hasDungeonLineOfSight(RAPIER, world, surface, destination), }); expect(initialRoute.blocked, JSON.stringify({ ramp: ramp.id, waypoints: initialRoute.waypoints })).toBe(false); const position: [number, number, number] = [...start]; for (let frame = 0; frame < 500 && distance(position, target) > 1.5; frame += 1) { const before = [...position]; expect(navigator.step({ id: `${ramp.id}:${start[1]}`, targetId: "target", position, target, home: start, phase: "chasing", health: 100, engaged: true, active: true, casting: false, controlled: false, attackRange: 1.5, leashRange: 40, maximumStep: 0.175, nowMs: frame * 50 }), JSON.stringify({ ramp: ramp.id, start, target, before, frame })).toBe("none"); } expect(distance(position, target)).toBeLessThanOrEqual(1.5); } } } finally { world.free(); } }); it("uses the reviewed warm indoor lighting profile", () => { expect(stockades.presentation).toMatchObject({ background: "#171815", fog: { color: "#1b1c18", near: 44 }, ambientLight: { color: "#d8c9aa", intensity: 0.58 }, hemisphereLight: { skyColor: "#cfc3a5", groundColor: "#292820", intensity: 0.86 }, directionalLight: { color: "#ffe0a8", intensity: 1.1, offset: [10, 18, -5] }, }); }); it("loads its independently reviewed, budgeted navigation graph", () => { expect(stockades.validation).toMatchObject({ status: "unvalidated", navigation: "green" }); expect(stockades.navigationLinks).toHaveLength(3); expect(shouldLoadPartyNavigation("dungeon", stockades, null)).toBe(true); expect(graph).toMatchObject({ sourceTriangleCount: 3_544, componentCount: 31, }); expect(graph.discardedNodeCount).toBeGreaterThan(0); expect(graph.triangles?.length).toBeGreaterThan(0); expect(graph.triangleNodes?.length).toBe(graph.triangles?.length); }); it("keeps the bite-sized entrance and boss room on the retained navigation surface", () => { const world = collisionWorld(); try { for (const wall of biteSizedStockades.serverGameObjects.filter((object) => object.id.startsWith("bite-sized-"))) { world.createCollider(RAPIER.ColliderDesc.cuboid(...wall.halfExtents) .setTranslation(...wall.position) .setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS)); } world.step(); const entrance = findNearestPartyNavigationSurface( biteSizedGraph, biteSizedStockades.entrance.footPosition, 6, ); const boss = findNearestPartyNavigationSurface( biteSizedGraph, biteSizedStockades.bosses[0].position, 15, ); expect(entrance).not.toBeNull(); expect(boss).not.toBeNull(); expect(findPartyNavigationPath( biteSizedGraph, entrance!.position, boss!.position, { maxStartSnapDistance: 0.1, maxEndSnapDistance: 0.1, routeSegmentAllowed: createPartyRouteSegmentValidator(RAPIER, world), }, )).not.toBeNull(); } finally { world.free(); } }); it("projects every entrance formation slot onto the retained walkable surface", () => { const slots = derivePartySpawnSlots({ footPosition: stockades.entrance.footPosition, yaw: stockades.entrance.yaw, }); expect(slots).toHaveLength(5); expect(findNearestPartyNavigationNode(graph, stockades.entrance.footPosition)?.distance) .toBeLessThanOrEqual(6); for (const slot of slots) { expect(findNearestPartyNavigationSurface(graph, slot.footPosition, 6)).not.toBeNull(); } }); it("routes from the shipping entrance to every authored boss objective", () => { const world = collisionWorld(); const routeSegmentAllowed = createPartyRouteSegmentValidator(RAPIER, world); const entrance = findNearestPartyNavigationSurface( graph, stockades.entrance.footPosition, 6, ); expect(entrance).not.toBeNull(); for (const objective of stockades.objectiveOrder) { const approach = findNearestPartyNavigationSurface(graph, objective.position, 15); expect(approach, objective.name).not.toBeNull(); expect(findPartyNavigationPath( graph, entrance!.position, approach!.position, { maxStartSnapDistance: 0.1, maxEndSnapDistance: 0.1, routeSegmentAllowed }, ), objective.name).not.toBeNull(); } world.free(); }); it("gives the tank a usable route into melee range of the first enemy", () => { const world = collisionWorld(); const segmentAllowed = createPartyDirectSegmentValidator(RAPIER, world); const tankSlot = derivePartySpawnSlots({ footPosition: stockades.entrance.footPosition, yaw: stockades.entrance.yaw, }, 2)[1]; const tankStart = findNearestPartyNavigationSurface(graph, tankSlot.footPosition, 6); expect(tankStart).not.toBeNull(); const firstEnemy = [...stockades.staticSpawns].sort((left, right) => ( distance(left.position, stockades.entrance.footPosition) - distance(right.position, stockades.entrance.footPosition) ))[0]; const desired: [number, number, number] = [0, 0, 0]; expect(partyCombatMovementGoal( tankStart!.position, firstEnemy.position, true, TANK_RANGE.minimum, TANK_RANGE.preferred, TANK_RANGE.maximum, desired, )).toBe("approach"); const route = planPartyRoute( graph, `tank:${firstEnemy.id}`, tankStart!.position, desired, 0, { maxOriginSnapDistance: 3, maxDestinationSnapDistance: 5, localFinishDistance: TANK_RANGE.maximum, directSegmentAllowed: segmentAllowed, routeSegmentAllowed: createPartyRouteSegmentValidator(RAPIER, world), }, ); expect(route.blocked).toBe(false); expect(route.waypoints.length).toBeGreaterThan(1); let current: [number, number, number] = [...tankStart!.position]; const next: [number, number, number] = [0, 0, 0]; for (const waypoint of route.waypoints) { while (distance(current, waypoint) > 0.01) { stepPartyPosition(current, waypoint, 0.5, next); current = [...next]; } } expect(distance(current, firstEnemy.position)).toBeLessThanOrEqual(TANK_RANGE.maximum); expect(partyCombatMovementGoal( current, firstEnemy.position, true, TANK_RANGE.minimum, TANK_RANGE.preferred, TANK_RANGE.maximum, desired, )).toBe("hold"); world.free(); }); it("traverses all three shipping stair flights in both directions with the live capsule check", () => { const world = collisionWorld(); const allowed = createPartyDirectSegmentValidator(RAPIER, world); expect(stockades.traversalRamps).toHaveLength(3); for (const ramp of stockades.traversalRamps!) { const point = (t: number, lateral: number): [number, number, number] => { const dx = ramp.end[0] - ramp.start[0]; const dz = ramp.end[2] - ramp.start[2]; const length = Math.hypot(dx, dz); return [ ramp.start[0] + dx * t + dz / length * lateral, ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t + 0.05, ramp.start[2] + dz * t - dx / length * lateral, ]; }; for (const lateral of [-2, 0, 2]) { const start = point(0.05, lateral); const end = point(0.95, lateral); expect(allowed(start, end), `${ramp.id}: downhill, offset ${lateral}`).toBe(true); expect(allowed(end, start), `${ramp.id}: uphill, offset ${lateral}`).toBe(true); } const start = findNearestPartyNavigationSurface(graph, point(0.15, 0), 2)!.position; const end = findNearestPartyNavigationSurface(graph, point(0.85, 0), 2)!.position; expect(findPartyNavigationPath(graph, start, end, { maxSnapDistance: 0.1 }), ramp.id).not.toBeNull(); // Replanning midway through a stair must not require a blocked snap to // a distant boundary vertex, as the previous off-mesh graph did. for (const t of [0.2, 0.4, 0.6, 0.8]) { const current = findNearestPartyNavigationSurface(graph, point(t, 0), 2)!.position; const route = planPartyRoute(graph, ramp.id, current, end, t * 1_000, { maxSnapDistance: 0.1, directSegmentAllowed: allowed, }); expect(route.blocked, `${ramp.id}: replan at ${t}`).toBe(false); } } world.free(); }); it("reproduces the old step-face rejection on the unmodified source collision", () => { const world = collisionWorld(false); const allowed = createPartyDirectSegmentValidator(RAPIER, world); for (const ramp of stockades.traversalRamps!) { const point = (t: number): [number, number, number] => [ ramp.start[0] + (ramp.end[0] - ramp.start[0]) * t, ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t + 0.05, ramp.start[2] + (ramp.end[2] - ramp.start[2]) * t, ]; expect(allowed(point(0.95), point(0.05)), ramp.id).toBe(false); } world.free(); }); it("still blocks movement through the stairwell walls", () => { const world = collisionWorld(); const directAllowed = createPartyDirectSegmentValidator(RAPIER, world); const routeAllowed = createPartyRouteSegmentValidator(RAPIER, world); const ramp = stockades.traversalRamps![0]; const center: [number, number, number] = [ (ramp.start[0] + ramp.end[0]) / 2, (ramp.start[1] + ramp.end[1]) / 2 + 0.05, ramp.start[2], ]; const acrossWall: [number, number, number] = [center[0], center[1], center[2] + 9]; expect(directAllowed(center, acrossWall)).toBe(false); expect(routeAllowed(center, acrossWall)).toBe(false); world.free(); }); it("does not mistake the nearby player's kinematic capsule for stair terrain", () => { const world = collisionWorld(); const ramp = stockades.traversalRamps![0]; const point = (t: number): [number, number, number] => [ ramp.start[0] + (ramp.end[0] - ramp.start[0]) * t, ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t + 0.05, ramp.start[2], ]; const player = point(0.5); const body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation( player[0], player[1] + 0.84, player[2], )); world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36).setCollisionGroups(ACTOR_COLLISION_GROUPS), body); world.step(); expect(createPartyDirectSegmentValidator(RAPIER, world)(point(0.4), point(0.6))).toBe(true); expect(createPartyRouteSegmentValidator(RAPIER, world)(point(0.4), point(0.6))).toBe(true); world.free(); }); it("finds grounded recovery landings on all three stair ramps", () => { const world = collisionWorld(); const allowed = createPartyRecoveryPointValidator(RAPIER, world, graph); for (const ramp of stockades.traversalRamps!) { const point = (t: number): [number, number, number] => [ ramp.start[0] + (ramp.end[0] - ramp.start[0]) * t, ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t + 0.05, ramp.start[2] + (ramp.end[2] - ramp.start[2]) * t, ]; expect(allowed(point(0.5), point(0.52)), ramp.id).not.toBeNull(); } world.free(); }); it("recovers from boss-room approaches onto a validated visited trail near the group", () => { const world = collisionWorld(); const allowed = createPartyRecoveryPointValidator(RAPIER, world, graph); const routeAllowed = createPartyRouteSegmentValidator(RAPIER, world); const entrance = findNearestPartyNavigationSurface(graph, stockades.entrance.footPosition, 6)!.position; for (const objective of stockades.objectiveOrder) { const current = findNearestPartyNavigationSurface(graph, objective.position, 15)!.position; const path = findPartyNavigationPath(graph, entrance, current, { routeSegmentAllowed: routeAllowed })!; const trail = path.slice(1).map((point, index): [number, number, number] => [ (path[index][0] + point[0]) / 2, (path[index][1] + point[1]) / 2, (path[index][2] + point[2]) / 2, ]).filter((point) => distance(point, current) >= 2 && distance(point, current) <= 12 && Math.abs(point[1] - current[1]) <= 1.25 && allowed(point, point)); expect(trail.length, objective.name).toBeGreaterThan(0); const anchor = trail.at(-1)!; const landing = choosePartyStuckRecoveryPosition({ current, player: entrance, groupAnchor: anchor, trailPoints: trail, validate: allowed }); expect(landing?.source, objective.name).toBe("trail"); expect(allowed(landing!.position, anchor), objective.name).not.toBeNull(); } world.free(); }); it("walks the player capsule down and back up every ramp without jumping", () => { for (const ramp of stockades.traversalRamps!) { const world = collisionWorld(); const dx = ramp.end[0] - ramp.start[0]; const dz = ramp.end[2] - ramp.start[2]; const length = Math.hypot(dx, dz); const centerHeight = 0.36 + 0.48; const startT = 0.03; const startY = ramp.start[1] + (ramp.end[1] - ramp.start[1]) * startT; const body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation( ramp.start[0] + dx * startT, startY + centerHeight + 0.02, ramp.start[2] + dz * startT, )); const collider = world.createCollider(RAPIER.ColliderDesc.capsule(0.48, 0.36).setCollisionGroups(ACTOR_COLLISION_GROUPS), body); world.step(); const controller = configurePlayerCharacterController(world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET)); const progress = () => { const position = body.translation(); return ((position.x - ramp.start[0]) * dx + (position.z - ramp.start[2]) * dz) / (length * length); }; for (const direction of [1, -1]) { // Rapier projects uphill movement onto the slope, reducing planar // speed. Bound the walk by twice the flat-ground travel time. const frames = Math.ceil(length * 2 / (4.8 / 60)); for (let frame = 0; frame < frames; frame += 1) { if (direction === 1 ? progress() >= 0.95 : progress() <= 0.05) break; controller.computeColliderMovement(collider, { x: dx / length * direction * 4.8 / 60, y: -0.5 / 60, z: dz / length * direction * 4.8 / 60, }, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS); const position = body.translation(); const move = controller.computedMovement(); body.setNextKinematicTranslation({ x: position.x + move.x, y: position.y + move.y, z: position.z + move.z }); world.step(); } if (direction === 1) expect(progress(), `${ramp.id}: player downhill`).toBeGreaterThan(0.9); else expect(progress(), `${ramp.id}: player uphill`).toBeLessThan(0.12); } world.removeCharacterController(controller); world.free(); } }); });