import { findNearestPartyNavigationSurface, findPartyNavigationPath, type PartyNavigationGraph } from "./partyNavigation"; import { derivePartySpawnSlots } from "./partySpawnSlots"; import type { BiteSizedLayoutBlocker, BiteSizedSpot, LayoutVector } from "../../shared/bite-sized-layouts.mjs"; export function biteSizedRouteSegmentClear(blockers: readonly BiteSizedLayoutBlocker[], from: LayoutVector, to: LayoutVector): boolean { return blockers.every((blocker) => { const sin = Math.sin(blocker.yaw), cos = Math.cos(blocker.yaw); const local = (point: LayoutVector) => { const x = point[0] - blocker.position[0], z = point[2] - blocker.position[2]; return [x * cos - z * sin, point[1] - blocker.position[1], x * sin + z * cos]; }; const start = local(from), end = local(to); let enter = 0, leave = 1; for (let axis = 0; axis < 3; axis++) { const extent = blocker.halfExtents[axis] + (axis === 1 ? 1 : 0.34); const delta = end[axis] - start[axis]; if (Math.abs(delta) < 1e-9) { if (Math.abs(start[axis]) > extent) return true; continue; } const first = (-extent - start[axis]) / delta, second = (extent - start[axis]) / delta; enter = Math.max(enter, Math.min(first, second)); leave = Math.min(leave, Math.max(first, second)); if (enter > leave) return true; } return false; }); } export function validateBiteSizedSpotNavigation(spot: BiteSizedSpot, graph: PartyNavigationGraph, regions?: readonly number[], blockers: readonly BiteSizedLayoutBlocker[] = []): void { const start = findNearestPartyNavigationSurface(graph, spot.partySpawn.footPosition, 0.75); if (!start) throw new Error(`${spot.name}: Party anchor is off the walkable floor. Move its spawn point.`); const points: { label: string; position: readonly [number, number, number]; snapDistance?: number }[] = [ ...derivePartySpawnSlots(spot.partySpawn).map((s, i) => ({ label: `Party member ${i + 1}`, position: s.footPosition, snapDistance: i === 0 ? 0.75 : 6 })), ...spot.mobs.map((m, i) => ({ label: `Mob ${i + 1}`, position: m.position })), { label: "Boss", position: spot.bossSpawn.footPosition }, ]; for (const point of points) { // Companions project their formation seats onto the floor within six metres, // exactly as PartyPopulation does. Authored mob/boss points never auto-snap. const match = findNearestPartyNavigationSurface(graph, point.position, point.snapDistance ?? 0.75); if (!match) throw new Error(`${spot.name}: ${point.label} is off the walkable floor. Move its spawn point.`); if (!biteSizedRouteSegmentClear(blockers, match.position, match.position)) throw new Error(`${spot.name}: ${point.label} is inside a route blocker.`); const reachable = regions ? regions[start.triangleId] === regions[match.triangleId] : findPartyNavigationPath(graph, start.position, match.position, { maxSnapDistance: 0.75, routeSegmentAllowed: (from, to) => biteSizedRouteSegmentClear(blockers, from, to) }); if (!reachable) throw new Error(`${spot.name}: ${point.label} cannot be reached from the party start.`); } }