import { Html, useGLTF } from "@react-three/drei"; import { resolveContentUrl } from "../content/contentManager"; import { useFrame } from "@react-three/fiber"; import { useRapier } from "@react-three/rapier"; import { Component, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ErrorInfo, type ReactNode, } from "react"; import { type Group } from "three"; import { classById, isRomClassId, raceById, type GenderId, type RaceId } from "../app/characterCatalog"; import { CharacterModel } from "../avatar/CharacterModel"; import { ProceduralAvatar } from "../avatar/ProceduralAvatar"; import { useCombatStore } from "../game/combatStore"; import { resolvedEquipmentItems } from "../game/heirloomEquipment"; import { characterCombatAnimationForPresentation, type CharacterCombatAnimationEvent, } from "../game/combatAnimation"; import { subscribeCombatPresentation } from "../game/combatPresentation"; import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dungeonAssets"; import { activeManastormStageAssetPackage } from "../game/manastormStageLoader"; import { derivePartySpawnSlots } from "../game/partySpawnSlots"; import { requireDungeonDefinition } from "../game/dungeonRegistry"; import type { DungeonBossObjective, DungeonTraversalRamp } from "../game/dungeonTypes"; import { getMobPosition } from "../game/mobRuntimeRegistry"; import { enemyControlMovementMultiplier, enemyControlPreventsMovement, } from "../game/enemyAttacks"; import { appendPartyBreadcrumb, createPartyBreadcrumbCursor, createPartyBreadcrumbTrail, nextPartyBreadcrumbFollowWaypoint, partyFormationPosition, partyMovementProfile, partyNaturalFollowPosition, partyPersonalizedCombatMovementGoal, partyPresentationY, partyShouldMoveToComfortTarget, personalizedPartyCombatRangeBand, resetPartyBreadcrumbTrail, stepPartyPosition, synchronizePartyBreadcrumbCursor, type PartyBreadcrumbCursor, type PartyMovementProfile, } from "../game/partyMovement"; import { applyPartyStuckRecoveryLanding, choosePartyStuckRecoveryPosition, createPartyStuckRecoveryState, markPartyStuckRecovered, resetPartyStuckRecoveryProgress, updatePartyStuckRecovery, type PartyStuckRecoveryState, } from "../game/partyStuckRecovery"; import { findNearestPartyNavigationSurface, type PartyNavigationGraph, } from "../game/partyNavigation"; import { preparePartyNavigationGraph } from "../game/partyNavigationAssetCache"; import { partyObjectivesForNavigation, shouldLoadPartyNavigation, } from "../game/partyNavigationPolicy"; import { emptyPartyRouteState, nextPartyRouteRecoveryWaypoint, nextPartyRouteWaypoint, partyRouteNeedsPlanning, planPartyRoute, type PartyRouteState, } from "../game/partyPathing"; import { partyCombatRangeBand } from "../game/partyRuntime"; import { partyRejoinMovement } from "../game/partyRejoin"; import { useWailingEncounterStore } from "../game/wailingCavernsEncounter"; import { clearPartyRuntimeAttackCorridor, consumePartyRuntimeRejoin, finishPartyRuntimeRejoin, getPartyRuntimePosition, partyRuntimeIsRejoining, registerPartyRuntimePosition, updatePartyRuntimeAttackCorridor, updatePartyRuntimePosition, updatePartyRuntimeTargetVisibility, type MutablePartyWorldPosition, type PartyWorldPosition, } from "../game/partyRuntimeRegistry"; import { usePartyStore } from "../game/partyStore"; import { useGameStore } from "../game/store"; import { useManastormStore } from "../game/manastormStore"; import { registerCombatSpatialProvider } from "../game/combatSpatial"; import { PLAYER_AGGRO_ID } from "../game/aggro"; import { hasDungeonLineOfSight } from "./dungeonLineOfSight"; import { registerCombatEffectAnchor } from "./combatEffectAnchorRegistry"; import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision"; import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator, createPartyRecoveryPointValidator, PARTY_FLOOR_RAY_DEPTH, PARTY_FLOOR_RAY_HEIGHT, } from "./partyTraversal"; import { activeDungeonRouteTarget, routeForActiveDungeon, type ActiveDungeonRouteTarget, } from "../game/dungeonRouteRuntime"; import { useDungeonSimulationStore } from "../game/dungeonSimulationStore"; import { useDungeonRouteStore } from "../game/dungeonRouteStore"; const PARTY_MOVE_SPEED = 5.25; const PARTY_CATCH_UP_SPEED = 7.5; const BREADCRUMB_REACHED_DISTANCE = 0.04; const PARTY_OBJECTIVE_APPROACH_SNAP_DISTANCE = 15; const PARTY_NAVIGATION_SNAP_DISTANCE = 6; const PARTY_MAX_GROUND_CORRECTION = 0.85; const EMPTY_NAVIGATION_LINKS = [] as const; const PARTY_PLAYER_TRAILING_DISTANCE: readonly number[] = [1.45, 2.6, 3.75, 4.9]; const PARTY_ENTRANCE_FORWARD_DISTANCE: readonly number[] = [2.4, 1.8, 1.2, 0.6]; const PARTY_TRAIL_DISTANCE: readonly number[] = [0, 1.15, 2.3, 3.45]; interface PartyAvatarMetrics { readonly labelY: number; readonly ringRadius: number; readonly hitRadius: number; readonly hitLength: number; readonly hitCenterY: number; } interface PartyCombatAnchor { readonly targetId: string; readonly bearing: number; } type PartyAvatarMetricKey = `${RaceId}:${GenderId}`; /** Bind-pose bounds measured from the shipped wow335a rigs, in local meters. */ const PARTY_AVATAR_METRICS: Readonly>> = { "human:male": { labelY: 2.38, ringRadius: 0.72, hitRadius: 0.5, hitLength: 1.13, hitCenterY: 1.06 }, "human:female": { labelY: 2.19, ringRadius: 0.44, hitRadius: 0.42, hitLength: 1.1, hitCenterY: 0.97 }, "orc:male": { labelY: 2.84, ringRadius: 0.94, hitRadius: 0.67, hitLength: 1.25, hitCenterY: 1.29 }, "orc:female": { labelY: 2.46, ringRadius: 0.63, hitRadius: 0.43, hitLength: 1.36, hitCenterY: 1.1 }, "dwarf:male": { labelY: 1.92, ringRadius: 0.69, hitRadius: 0.47, hitLength: 0.73, hitCenterY: 0.83 }, "dwarf:female": { labelY: 1.79, ringRadius: 0.55, hitRadius: 0.42, hitLength: 0.7, hitCenterY: 0.77 }, "night-elf:male": { labelY: 2.81, ringRadius: 0.71, hitRadius: 0.49, hitLength: 1.6, hitCenterY: 1.28 }, "night-elf:female": { labelY: 2.54, ringRadius: 0.51, hitRadius: 0.42, hitLength: 1.45, hitCenterY: 1.14 }, "undead:male": { labelY: 2.61, ringRadius: 0.59, hitRadius: 0.42, hitLength: 1.52, hitCenterY: 1.18 }, "undead:female": { labelY: 2.41, ringRadius: 0.5, hitRadius: 0.42, hitLength: 1.32, hitCenterY: 1.08 }, "tauren:male": { labelY: 2.58, ringRadius: 0.94, hitRadius: 0.67, hitLength: 1.04, hitCenterY: 1.14 }, "tauren:female": { labelY: 2.48, ringRadius: 0.8, hitRadius: 0.56, hitLength: 1.1, hitCenterY: 1.11 }, "gnome:male": { labelY: 1.57, ringRadius: 0.5, hitRadius: 0.42, hitLength: 0.49, hitCenterY: 0.66 }, "gnome:female": { labelY: 1.42, ringRadius: 0.41, hitRadius: 0.42, hitLength: 0.34, hitCenterY: 0.59 }, "troll:male": { labelY: 3.44, ringRadius: 0.82, hitRadius: 0.58, hitLength: 2.04, hitCenterY: 1.6 }, "troll:female": { labelY: 2.93, ringRadius: 0.6, hitRadius: 0.42, hitLength: 1.84, hitCenterY: 1.34 }, "blood-elf:male": { labelY: 2.49, ringRadius: 0.6, hitRadius: 0.42, hitLength: 1.4, hitCenterY: 1.12 }, "blood-elf:female": { labelY: 2.28, ringRadius: 0.47, hitRadius: 0.42, hitLength: 1.19, hitCenterY: 1.02 }, "draenei:male": { labelY: 2.96, ringRadius: 0.85, hitRadius: 0.6, hitLength: 1.5, hitCenterY: 1.35 }, "draenei:female": { labelY: 2.72, ringRadius: 0.54, hitRadius: 0.42, hitLength: 1.63, hitCenterY: 1.23 }, }; const DEFAULT_PARTY_AVATAR_METRICS: PartyAvatarMetrics = { labelY: 2.55, ringRadius: 0.68, hitRadius: 0.5, hitLength: 1.35, hitCenterY: 1.15, }; const partyLabelStyle: CSSProperties = { color: "#eef6e6", fontFamily: "system-ui, sans-serif", fontSize: "10px", fontWeight: 700, lineHeight: 1.1, pointerEvents: "none", textAlign: "center", textShadow: "0 1px 2px #000, 0 0 5px #000", userSelect: "none", whiteSpace: "nowrap", }; interface NavigationAssetBoundaryProps { readonly children: ReactNode; readonly onFailure: () => void; } interface NavigationAssetBoundaryState { readonly failed: boolean; } /** A navigation failure removes only the optional route helper, never the Canvas. */ class NavigationAssetBoundary extends Component { state: NavigationAssetBoundaryState = { failed: false }; static getDerivedStateFromError(): NavigationAssetBoundaryState { return { failed: true }; } componentDidCatch(error: unknown, info: ErrorInfo): void { this.props.onFailure(); console.warn("Party navigation asset could not be prepared; companions will follow breadcrumbs.", error, info); } render(): ReactNode { return this.state.failed ? null : this.props.children; } } function PartyNavigationLoader({ url, offMeshLinks, ramps, onReady, }: { readonly url: string; readonly offMeshLinks: ReturnType["navigationLinks"]; readonly ramps: readonly DungeonTraversalRamp[]; readonly onReady: (graph: PartyNavigationGraph) => void; }) { const gltf = useGLTF(resolveContentUrl(url)); const graph = useMemo( () => preparePartyNavigationGraph(url, gltf.scene, offMeshLinks, ramps), [gltf.scene, offMeshLinks, ramps, url], ); useEffect(() => onReady(graph), [graph, onReady]); return null; } function planarDistance(left: PartyWorldPosition, right: PartyWorldPosition): number { return Math.hypot(right[0] - left[0], right[2] - left[2]); } function fullDistance(left: PartyWorldPosition, right: PartyWorldPosition): number { return Math.hypot(right[0] - left[0], right[1] - left[1], right[2] - left[2]); } function setEntrancePosition( index: number, anchor: PartyWorldPosition, forwardX: number, forwardZ: number, out: MutablePartyWorldPosition, ): void { const forwardDistance = PARTY_ENTRANCE_FORWARD_DISTANCE[index] ?? 0.6; // The dungeon entrance ends immediately behind the player. Spawn the squad // in a compact line on the walkable side, with the tank furthest forward. partyFormationPosition(anchor, forwardX, forwardZ, 0, -forwardDistance, out); } function initialMemberPosition(index: number): MutablePartyWorldPosition { const game = useGameStore.getState(); if (game.gameMode === "manastorm") return [...game.playerPosition]; const out: MutablePartyWorldPosition = [0, 0, 0]; setEntrancePosition( index, game.playerPosition, Math.sin(game.cameraYaw), Math.cos(game.cameraYaw), out, ); return out; } function PartyActors({ active, simulate, navigationGraph, navigationFailed, objectives, stackAtEntrance, }: { readonly active: boolean; readonly simulate: boolean; readonly navigationGraph: PartyNavigationGraph | null; readonly navigationFailed: boolean; readonly objectives: readonly DungeonBossObjective[]; readonly stackAtEntrance: boolean; }) { const members = usePartyStore((state) => state.members); const selectedMemberId = usePartyStore((state) => state.selectedMemberId); const inventory = useCombatStore((state) => state.inventory); const equipment = useCombatStore((state) => state.equipment); const equipmentItemsByOwner = useMemo( () => new Map( members.map((member) => [ member.id, resolvedEquipmentItems(equipment, inventory, { ownerId: member.id, ownerKind: "party", classId: member.classId, specializationId: member.specializationId, role: member.role, level: member.level, }), ]), ), [equipment, inventory, members], ); const resetRevision = useGameStore((state) => state.resetRevision); const { rapier, world } = useRapier(); const presentationY = useCallback((position: PartyWorldPosition): number => { const ray = new rapier.Ray({ x: position[0], y: position[1] + PARTY_FLOOR_RAY_HEIGHT, z: position[2], }, { x: 0, y: -1, z: 0 }); const floor = world.castRay( ray, PARTY_FLOOR_RAY_DEPTH, true, rapier.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS, ); const floorY = floor ? ray.origin.y - floor.timeOfImpact : null; return partyPresentationY(position[1], floorY, PARTY_MAX_GROUND_CORRECTION); }, [rapier, world]); const directSegmentAllowed = useMemo( () => createPartyDirectSegmentValidator(rapier, world), [rapier, world], ); const hasStaticLineOfSight = useCallback(( start: PartyWorldPosition, end: PartyWorldPosition, ): boolean => hasDungeonLineOfSight(rapier, world, start, end), [rapier, world]); const routeSurfaceSegmentAllowed = useMemo( () => createPartyRouteSegmentValidator(rapier, world), [rapier, world], ); const recoveryPointAllowed = useMemo( () => createPartyRecoveryPointValidator(rapier, world, navigationGraph), [rapier, world, navigationGraph], ); // Authored-link graphs use their eroded Recast node edges directly. Their // boundary vertices are already agent-radius safe, while a center ray can // falsely read the stair floor as a wall across a short vertical seam. const navigationRouteSegmentAllowed = navigationGraph?.triangles?.length ? routeSurfaceSegmentAllowed : undefined; const actorRefs = useRef>([]); const bodyRefs = useRef>([]); const positionsRef = useRef(new Map()); const movingSignalsRef = useRef(new Map()); const animationEventSignalsRef = useRef(new Map()); const animationRevisionRef = useRef(0); const lastHealthRef = useRef(new Map()); const routeRef = useRef(emptyPartyRouteState()); const combatRoutesRef = useRef(new Map()); const navigationGraphRef = useRef(navigationGraph); navigationGraphRef.current = navigationGraph; const playerTrailRef = useRef(createPartyBreadcrumbTrail()); const leaderPlayerCursorRef = useRef(createPartyBreadcrumbCursor()); const leaderTrailRef = useRef(createPartyBreadcrumbTrail()); const trailLeaderIdRef = useRef(null); const lastActiveTargetIdRef = useRef(null); const followerCursorsRef = useRef(new Map()); const lastLeaderWaypointRef = useRef(null); const lastCommandRef = useRef(usePartyStore.getState().command); const lastRecallRevisionRef = useRef(usePartyStore.getState().recallRevision); const stuckProgressRef = useRef(new Map()); const movementProfilesRef = useRef(new Map()); const directFollowMovingRef = useRef(new Map()); const combatAnchorsRef = useRef(new Map()); const lastSimulationElapsedRef = useRef(0); const memberIdKey = members.map((member) => member.id).join("|"); useEffect(() => registerCombatSpatialProvider({ actor: (actorId) => { const gameState = useGameStore.getState(); if (actorId === PLAYER_AGGRO_ID) { return { id: actorId, position: gameState.playerPosition, yaw: gameState.cameraYaw }; } const memberIndex = usePartyStore.getState().members.findIndex((member) => member.id === actorId); const memberPosition = positionsRef.current.get(actorId); if (memberPosition) { return { id: actorId, position: memberPosition, yaw: actorRefs.current[memberIndex]?.rotation.y ?? gameState.cameraYaw, }; } const summon = useCombatStore.getState().summons.find((candidate) => candidate.id === actorId); if (summon) return { id: actorId, position: summon.position, yaw: gameState.cameraYaw }; const mobPosition = getMobPosition(actorId); if (!mobPosition) return null; const mob = useCombatStore.getState().mobs[actorId]; const targetPosition = mob?.targetActorId === PLAYER_AGGRO_ID ? gameState.playerPosition : mob?.targetActorId ? positionsRef.current.get(mob.targetActorId) ?? useCombatStore.getState().summons.find((candidate) => candidate.id === mob.targetActorId)?.position : null; const yaw = targetPosition ? Math.atan2(targetPosition[0] - mobPosition[0], targetPosition[2] - mobPosition[2]) : 0; return { id: actorId, position: mobPosition, yaw }; }, hasLineOfSight: hasStaticLineOfSight, navigationGraph: () => navigationGraphRef.current, projectGroundPoint: (requested, maximumDistance, requestedOrigin) => { const origin = requestedOrigin ?? useGameStore.getState().playerPosition; const dx = requested[0] - origin[0]; const dz = requested[2] - origin[2]; const planar = Math.hypot(dx, dz); const scale = planar > maximumDistance && planar > 0 ? maximumDistance / planar : 1; const x = origin[0] + dx * scale; const z = origin[2] + dz * scale; const ray = new rapier.Ray({ x, y: requested[1] + 8, z }, { x: 0, y: -1, z: 0 }); const hit = world.castRay( ray, 20, true, rapier.QueryFilterFlags.EXCLUDE_SENSORS, ACTOR_TERRAIN_QUERY_GROUPS, ); return hit ? [x, ray.origin.y - hit.timeOfImpact, z] : null; }, }), [hasStaticLineOfSight, memberIdKey, rapier, world]); const teleportPartyToPlayer = useCallback((player: PartyWorldPosition, now: number): void => { const party = usePartyStore.getState(); const navigationRecall = navigationGraphRef.current ? findNearestPartyNavigationSurface( navigationGraphRef.current, player, PARTY_NAVIGATION_SNAP_DISTANCE, ) : null; const recallPosition = navigationRecall?.position ?? player; party.members.forEach((member, index) => { finishPartyRuntimeRejoin(member.id); const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index); // Attach a recalled squad to the reviewed surface just as initial // spawns do. An exact visual-floor point can sit between Recast stair // vertices and leave the first route connector falsely blocked. position[0] = recallPosition[0]; position[1] = recallPosition[1]; position[2] = recallPosition[2]; positionsRef.current.set(member.id, position); updatePartyRuntimePosition(member.id, position[0], position[1], position[2]); actorRefs.current[index]?.position.set(position[0], presentationY(position), position[2]); const movingSignal = movingSignalsRef.current.get(member.id); if (movingSignal) movingSignal.current = false; const body = bodyRefs.current[index]; if (body) body.position.y = 0; const progress = stuckProgressRef.current.get(member.id) ?? createPartyStuckRecoveryState(position, now); markPartyStuckRecovered(progress, position, now); stuckProgressRef.current.set(member.id, progress); }); const firstLiving = party.members.find((member) => member.health > 0); resetPartyBreadcrumbTrail(playerTrailRef.current, player); leaderPlayerCursorRef.current.breadcrumbId = null; resetPartyBreadcrumbTrail(leaderTrailRef.current, recallPosition); trailLeaderIdRef.current = firstLiving?.id ?? null; routeRef.current = emptyPartyRouteState(); combatRoutesRef.current.clear(); directFollowMovingRef.current.clear(); combatAnchorsRef.current.clear(); followerCursorsRef.current.clear(); lastActiveTargetIdRef.current = party.activeMobId; lastLeaderWaypointRef.current = null; clearPartyRuntimeAttackCorridor(); party.setRouteBlocked(false); }, [presentationY]); useEffect(() => { const game = useGameStore.getState(); const activeIds = new Set(members.map((member) => member.id)); for (const id of animationEventSignalsRef.current.keys()) { if (!activeIds.has(id)) animationEventSignalsRef.current.delete(id); } for (const id of lastHealthRef.current.keys()) { if (!activeIds.has(id)) lastHealthRef.current.delete(id); } for (const id of combatRoutesRef.current.keys()) { if (!activeIds.has(id)) combatRoutesRef.current.delete(id); } for (const id of stuckProgressRef.current.keys()) { if (!activeIds.has(id)) stuckProgressRef.current.delete(id); } for (const id of directFollowMovingRef.current.keys()) { if (!activeIds.has(id)) directFollowMovingRef.current.delete(id); } for (const id of combatAnchorsRef.current.keys()) { if (!activeIds.has(id)) combatAnchorsRef.current.delete(id); } for (const key of movementProfilesRef.current.keys()) { if (![...activeIds].some((id) => key.startsWith(`${id}|`))) { movementProfilesRef.current.delete(key); } } const cleanups = members.map((member, index) => { const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index); positionsRef.current.set(member.id, position); if (!lastHealthRef.current.has(member.id)) lastHealthRef.current.set(member.id, member.health); actorRefs.current[index]?.position.set(position[0], position[1], position[2]); const runtimeCleanup = registerPartyRuntimePosition(member.id, position); const actor = actorRefs.current[index]; const anchorCleanup = actor ? registerCombatEffectAnchor(member.id, actor) : undefined; return () => { runtimeCleanup(); anchorCleanup?.(); }; }); // Make the initial facing agree with the player instead of flashing at yaw zero. for (const actor of actorRefs.current) { if (actor) actor.rotation.y = game.cameraYaw; } return () => { cleanups.forEach((cleanup) => cleanup()); for (const id of followerCursorsRef.current.keys()) { if (!activeIds.has(id)) followerCursorsRef.current.delete(id); } }; // IDs, not low-frequency health/status commits, own registry lifetime. // eslint-disable-next-line react-hooks/exhaustive-deps }, [memberIdKey]); useEffect(() => subscribeCombatPresentation((message) => { if (message.kind === "reset") { for (const signal of animationEventSignalsRef.current.values()) { animationRevisionRef.current += 1; signal.current = { revision: animationRevisionRef.current, kind: "ability-cancel" }; } return; } const signal = animationEventSignalsRef.current.get(message.event.sourceActorId); if (!signal) return; const revision = animationRevisionRef.current + 1; const animationEvent = characterCombatAnimationForPresentation(message.event, revision); if (!animationEvent) return; animationRevisionRef.current = revision; signal.current = animationEvent; }), []); useEffect(() => { const game = useGameStore.getState(); const spawnSlots = derivePartySpawnSlots(game.activeSpawn, members.length + 1); members.forEach((member, index) => { const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index); const slot = spawnSlots[index + 1] ?? spawnSlots[0]; finishPartyRuntimeRejoin(member.id); position[0] = slot.footPosition[0]; position[1] = slot.footPosition[1]; position[2] = slot.footPosition[2]; const navigationSpawn = navigationGraphRef.current ? findNearestPartyNavigationSurface(navigationGraphRef.current, position, 6) : null; if (navigationSpawn) { position[0] = navigationSpawn.position[0]; position[1] = navigationSpawn.position[1]; position[2] = navigationSpawn.position[2]; } positionsRef.current.set(member.id, position); updatePartyRuntimePosition(member.id, position[0], position[1], position[2]); actorRefs.current[index]?.position.set(position[0], position[1], position[2]); }); const firstLiving = members.find((member) => member.health > 0); const leaderPosition = firstLiving ? positionsRef.current.get(firstLiving.id) : null; resetPartyBreadcrumbTrail(playerTrailRef.current, game.playerPosition); leaderPlayerCursorRef.current.breadcrumbId = null; resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition ?? game.playerPosition); trailLeaderIdRef.current = firstLiving?.id ?? null; followerCursorsRef.current.clear(); combatRoutesRef.current.clear(); directFollowMovingRef.current.clear(); combatAnchorsRef.current.clear(); lastActiveTargetIdRef.current = null; lastLeaderWaypointRef.current = null; routeRef.current = emptyPartyRouteState(); stuckProgressRef.current.clear(); }, [memberIdKey, resetRevision, stackAtEntrance]); useEffect(() => { routeRef.current = emptyPartyRouteState(); combatRoutesRef.current.clear(); if (!navigationGraph) return; const partyMembers = usePartyStore.getState().members; partyMembers.forEach((member, index) => { const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index); const navigationSpawn = findNearestPartyNavigationSurface(navigationGraph, position, 6); if (!navigationSpawn) return; position[0] = navigationSpawn.position[0]; position[1] = navigationSpawn.position[1]; position[2] = navigationSpawn.position[2]; positionsRef.current.set(member.id, position); updatePartyRuntimePosition(member.id, position[0], position[1], position[2]); actorRefs.current[index]?.position.set(position[0], position[1], position[2]); }); const firstLiving = partyMembers.find((member) => member.health > 0); const leaderPosition = firstLiving ? positionsRef.current.get(firstLiving.id) : null; if (leaderPosition) resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition); followerCursorsRef.current.clear(); stuckProgressRef.current.clear(); directFollowMovingRef.current.clear(); combatAnchorsRef.current.clear(); }, [navigationGraph]); // Time spent in a pause/menu must not count toward the stuck watchdog. useEffect(() => { for (const state of stuckProgressRef.current.values()) resetPartyStuckRecoveryProgress(state); }, [active]); useEffect(() => clearPartyRuntimeAttackCorridor, []); useFrame(({ clock }, delta) => { if (!active) return; const party = usePartyStore.getState(); if (!party.members.length) return; if (!simulate) { party.members.forEach((member, index) => { const synchronized = getPartyRuntimePosition(member.id); if (!synchronized) return; const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index); position[0] = synchronized[0]; position[1] = synchronized[1]; position[2] = synchronized[2]; positionsRef.current.set(member.id, position); actorRefs.current[index]?.position.set(position[0], presentationY(position), position[2]); const movingSignal = movingSignalsRef.current.get(member.id); if (movingSignal) movingSignal.current = false; }); return; } const game = useGameStore.getState(); const simulation = useDungeonSimulationStore.getState(); const playerNavigation = game.playerNavigationPosition; appendPartyBreadcrumb(playerTrailRef.current, playerNavigation); const routeNow = simulation.sessionMode === "spectate-live" ? simulation.elapsedMs : clock.elapsedTime * 1_000; const combatNow = simulation.sessionMode === "spectate-live" ? simulation.startedAt + simulation.elapsedMs : Date.now(); for (const member of party.members) { if (member.health <= 0) { finishPartyRuntimeRejoin(member.id); continue; } const checkpoint = consumePartyRuntimeRejoin(member.id); if (!checkpoint) continue; const landing = navigationGraph ? findNearestPartyNavigationSurface(navigationGraph, checkpoint, 6)?.position ?? checkpoint : checkpoint; const current = positionsRef.current.get(member.id) ?? [...landing]; current[0] = landing[0]; current[1] = landing[1]; current[2] = landing[2]; positionsRef.current.set(member.id, current); updatePartyRuntimePosition(member.id, current[0], current[1], current[2]); combatRoutesRef.current.delete(member.id); combatAnchorsRef.current.delete(member.id); followerCursorsRef.current.delete(member.id); directFollowMovingRef.current.delete(member.id); stuckProgressRef.current.delete(member.id); updatePartyRuntimeTargetVisibility(member.id, null, false); const animationSignal = animationEventSignalsRef.current.get(member.id); animationRevisionRef.current += 1; if (animationSignal) animationSignal.current = { revision: animationRevisionRef.current, kind: "ability-cancel" }; } const playerTrail = playerTrailRef.current.points; const latestPlayerTrailPoint = playerTrail[playerTrail.length - 1]; const previousPlayerTrailPoint = playerTrail[playerTrail.length - 2]; let playerForwardX = Math.sin(game.cameraYaw); let playerForwardZ = Math.cos(game.cameraYaw); if (latestPlayerTrailPoint && previousPlayerTrailPoint) { const trailX = latestPlayerTrailPoint.position[0] - previousPlayerTrailPoint.position[0]; const trailZ = latestPlayerTrailPoint.position[2] - previousPlayerTrailPoint.position[2]; if (Math.hypot(trailX, trailZ) > 0.05) { playerForwardX = trailX; playerForwardZ = trailZ; } } if (lastRecallRevisionRef.current !== party.recallRevision) { lastRecallRevisionRef.current = party.recallRevision; teleportPartyToPlayer(playerNavigation, routeNow); return; } if (lastCommandRef.current !== party.command) { lastCommandRef.current = party.command; routeRef.current = emptyPartyRouteState(); combatRoutesRef.current.clear(); directFollowMovingRef.current.clear(); combatAnchorsRef.current.clear(); followerCursorsRef.current.clear(); const livingLeader = party.members.find((member) => member.health > 0); const livingLeaderPosition = livingLeader ? positionsRef.current.get(livingLeader.id) : null; resetPartyBreadcrumbTrail( leaderTrailRef.current, livingLeaderPosition ?? playerNavigation, ); lastLeaderWaypointRef.current = null; } const objective = objectives.find((candidate) => candidate.id === party.objectiveBossId) ?? null; const leaderIndex = party.members.findIndex((member) => member.health > 0 && !partyRuntimeIsRejoining(member.id)); const simulationDelta = simulation.sessionMode === "spectate-live" ? Math.max(0, (simulation.elapsedMs - lastSimulationElapsedRef.current) / 1_000) : delta; lastSimulationElapsedRef.current = simulation.elapsedMs; const safeDelta = simulation.sessionMode === "spectate-live" ? Math.min(simulationDelta, 0.5) : Math.min(delta, 0.05); const desired: MutablePartyWorldPosition = [0, 0, 0]; const nextPosition: MutablePartyWorldPosition = [0, 0, 0]; const leaderDestination: MutablePartyWorldPosition = [0, 0, 0]; const naturalFollowTarget: MutablePartyWorldPosition = [0, 0, 0]; const livingIndices = party.members .map((member, index) => member.health > 0 ? index : -1) .filter((index) => index >= 0); const movementProfileFor = (memberId: string, memberIndex: number): PartyMovementProfile => { const livingRank = Math.max(0, livingIndices.indexOf(memberIndex)); const key = `${memberId}|${livingRank}`; let profile = movementProfilesRef.current.get(key); if (!profile) { profile = partyMovementProfile(memberId, livingRank); movementProfilesRef.current.set(key, profile); } return profile; }; let leaderHasWaypoint = false; let leaderMovementExpected = false; let leaderFollowingPlayer = false; let leaderPlayerTrailBlocked = false; let forceLeaderBreadcrumb = false; let authoredRouteTarget: ActiveDungeonRouteTarget | null = null; const leaderMember = leaderIndex >= 0 ? party.members[leaderIndex] : null; const leaderPosition = leaderMember ? positionsRef.current.get(leaderMember.id) : null; const leaderTargetId = leaderMember ? party.memberTargetIds[leaderMember.id] ?? party.activeMobId : party.activeMobId; const targetPosition = leaderTargetId ? getMobPosition(leaderTargetId) : null; const activeTargetId = targetPosition ? leaderTargetId : null; if (lastActiveTargetIdRef.current !== activeTargetId) { lastActiveTargetIdRef.current = activeTargetId; routeRef.current = emptyPartyRouteState(); combatRoutesRef.current.clear(); followerCursorsRef.current.clear(); resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition ?? playerNavigation); lastLeaderWaypointRef.current = null; } if (leaderMember && leaderPosition && trailLeaderIdRef.current !== leaderMember.id) { resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition); trailLeaderIdRef.current = leaderMember.id; followerCursorsRef.current.clear(); combatRoutesRef.current.clear(); combatAnchorsRef.current.clear(); lastLeaderWaypointRef.current = null; routeRef.current = emptyPartyRouteState(); } if (leaderMember && leaderPosition && party.command !== "stop") { const leader = leaderMember; let goalKey: string; let hasDestination = true; let leaderLocalFinishDistance = 3; if (targetPosition && leaderTargetId) { const profile = movementProfileFor(leader.id, leaderIndex); const rangeBand = personalizedPartyCombatRangeBand( partyCombatRangeBand(leader), profile, ); leaderLocalFinishDistance = Math.max(leaderLocalFinishDistance, rangeBand.maximum); let combatAnchor = combatAnchorsRef.current.get(leader.id); if (!combatAnchor || combatAnchor.targetId !== leaderTargetId) { combatAnchor = { targetId: leaderTargetId, bearing: Math.atan2( leaderPosition[0] - targetPosition[0], leaderPosition[2] - targetPosition[2], ) + profile.combatAngleOffset, }; combatAnchorsRef.current.set(leader.id, combatAnchor); } const movementIntent = partyPersonalizedCombatMovementGoal( leaderPosition, targetPosition, hasStaticLineOfSight(leaderPosition, targetPosition), rangeBand, combatAnchor.bearing, leaderDestination, ); if (movementIntent === "hold") { hasDestination = false; goalKey = `leader:${leader.id}|mob:${leaderTargetId}|in-range`; } else { goalKey = `leader:${leader.id}|mob:${leaderTargetId}|${movementIntent}`; } } else if (party.command === "attack" && objective) { // Imported source spawns can sit on decorative or discarded Recast // islands. Route to the nearest retained walkable surface and let the // normal aggro/combat path take over when the encounter engages. authoredRouteTarget = activeDungeonRouteTarget( game.activeDungeonId, objective.id, leaderPosition, leader.id, combatNow, ); const objectiveApproach = authoredRouteTarget?.target ?? (navigationGraph ? findNearestPartyNavigationSurface( navigationGraph, objective.position, PARTY_OBJECTIVE_APPROACH_SNAP_DISTANCE, )?.position : null); const objectiveDestination = objectiveApproach ?? objective.position; leaderDestination[0] = objectiveDestination[0]; leaderDestination[1] = objectiveDestination[1]; leaderDestination[2] = objectiveDestination[2]; goalKey = authoredRouteTarget ? `leader:${leader.id}|route:${authoredRouteTarget.route.hash}|${authoredRouteTarget.leg.id}|${authoredRouteTarget.checkpointIndex}` : `leader:${leader.id}|objective:${objective.id}`; if (authoredRouteTarget) leaderLocalFinishDistance = 0.3; } else { leaderFollowingPlayer = true; leaderDestination[0] = playerNavigation[0]; leaderDestination[1] = playerNavigation[1]; leaderDestination[2] = playerNavigation[2]; goalKey = `leader:${leader.id}|player`; } leaderMovementExpected = hasDestination && fullDistance(leaderPosition, leaderDestination) > 0.18; if (leaderFollowingPlayer) { const cursor = leaderPlayerCursorRef.current; const profile = movementProfileFor(leader.id, leaderIndex); const baseTrailingDistance = PARTY_PLAYER_TRAILING_DISTANCE[0]; const personalizedTrailingDistance = Math.max( 0.9, baseTrailingDistance + profile.followDistanceJitter, ); partyNaturalFollowPosition( playerNavigation, playerForwardX, playerForwardZ, baseTrailingDistance, profile, routeNow, leaderDestination, ); if (directSegmentAllowed(leaderPosition, leaderDestination)) { routeRef.current = { ...emptyPartyRouteState(), goalKey }; const wasMoving = directFollowMovingRef.current.get(leader.id) ?? false; leaderHasWaypoint = partyShouldMoveToComfortTarget( leaderPosition, leaderDestination, wasMoving, profile.comfortSlack, ); directFollowMovingRef.current.set(leader.id, leaderHasWaypoint); synchronizePartyBreadcrumbCursor( playerTrailRef.current, cursor, personalizedTrailingDistance, ); if (leaderHasWaypoint) { desired[0] = leaderDestination[0]; desired[1] = leaderDestination[1]; desired[2] = leaderDestination[2]; } leaderMovementExpected = leaderHasWaypoint; } else { directFollowMovingRef.current.set(leader.id, false); const previousBreadcrumbId = cursor.breadcrumbId; const requiresValidatedRejoin = cursor.breadcrumbId === null || !playerTrailRef.current.points.some((point) => point.id === cursor.breadcrumbId); leaderHasWaypoint = nextPartyBreadcrumbFollowWaypoint( playerTrailRef.current, cursor, leaderPosition, personalizedTrailingDistance, BREADCRUMB_REACHED_DISTANCE, desired, profile.comfortSlack, ); if ( leaderHasWaypoint && requiresValidatedRejoin && !directSegmentAllowed(leaderPosition, desired) ) { cursor.breadcrumbId = previousBreadcrumbId; leaderHasWaypoint = false; leaderPlayerTrailBlocked = true; } if (!leaderHasWaypoint && navigationGraph) { const navigationFollowDestination = findNearestPartyNavigationSurface( navigationGraph, leaderDestination, PARTY_NAVIGATION_SNAP_DISTANCE, )?.position ?? leaderDestination; if (partyRouteNeedsPlanning( routeRef.current, goalKey, leaderPosition, navigationFollowDestination, routeNow, { replanDistance: 1.25, replanIntervalMs: 350, blockedRetryMs: 800, }, )) { routeRef.current = planPartyRoute( navigationGraph, goalKey, leaderPosition, navigationFollowDestination, routeNow, { maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE, maxDestinationSnapDistance: 5, localFinishDistance: 3, replanDistance: 1.25, replanIntervalMs: 350, blockedRetryMs: 800, directSegmentAllowed, routeSegmentAllowed: navigationRouteSegmentAllowed, }, ); } leaderHasWaypoint = nextPartyRouteWaypoint( routeRef.current, leaderPosition, desired, ); if (!leaderHasWaypoint && routeRef.current.blocked) { leaderHasWaypoint = nextPartyRouteRecoveryWaypoint( routeRef.current, leaderPosition, desired, directSegmentAllowed, ); } leaderPlayerTrailBlocked = !leaderHasWaypoint && routeRef.current.blocked; } leaderMovementExpected = leaderHasWaypoint || ( (!navigationGraph || leaderPlayerTrailBlocked) && planarDistance(leaderPosition, playerNavigation) > personalizedTrailingDistance + profile.comfortSlack ); } } else if (!hasDestination || fullDistance(leaderPosition, leaderDestination) <= 0.18) { if ( routeRef.current.goalKey !== goalKey || routeRef.current.blocked || routeRef.current.waypoints.length > 0 ) routeRef.current = { ...emptyPartyRouteState(), goalKey }; } else { if (partyRouteNeedsPlanning( routeRef.current, goalKey, leaderPosition, leaderDestination, routeNow, )) { routeRef.current = authoredRouteTarget && !navigationGraph && directSegmentAllowed(leaderPosition, leaderDestination) ? { goalKey, destination: [leaderDestination[0], leaderDestination[1], leaderDestination[2]], waypoints: [[leaderDestination[0], leaderDestination[1], leaderDestination[2]]], cursor: 0, plannedAt: routeNow, blocked: false, } : planPartyRoute( navigationGraph, goalKey, leaderPosition, leaderDestination, routeNow, { maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE, maxDestinationSnapDistance: 5, localFinishDistance: leaderLocalFinishDistance, directSegmentAllowed, routeSegmentAllowed: navigationRouteSegmentAllowed, destinationReached: targetPosition ? (surface) => { const band = personalizedPartyCombatRangeBand(partyCombatRangeBand(leaderMember), movementProfileFor(leaderMember.id, leaderIndex)); const distance = fullDistance(surface, targetPosition); return distance >= band.minimum && distance <= band.maximum && hasStaticLineOfSight(surface, targetPosition); } : undefined, }, ); } leaderHasWaypoint = nextPartyRouteWaypoint(routeRef.current, leaderPosition, desired); if (!leaderHasWaypoint && routeRef.current.blocked) { leaderHasWaypoint = nextPartyRouteRecoveryWaypoint( routeRef.current, leaderPosition, desired, directSegmentAllowed, ); } if ( !leaderHasWaypoint && targetPosition && routeRef.current.blocked && directSegmentAllowed(leaderPosition, leaderDestination) ) { desired[0] = leaderDestination[0]; desired[1] = leaderDestination[1]; desired[2] = leaderDestination[2]; leaderHasWaypoint = true; } if ( !leaderHasWaypoint && authoredRouteTarget && routeRef.current.blocked && simulation.sessionMode === "play" && directSegmentAllowed(leaderPosition, playerNavigation) ) { desired[0] = playerNavigation[0]; desired[1] = playerNavigation[1]; desired[2] = playerNavigation[2]; leaderHasWaypoint = true; leaderFollowingPlayer = true; } } } if (leaderHasWaypoint) { const previousWaypoint = lastLeaderWaypointRef.current; if (!previousWaypoint || fullDistance(previousWaypoint, desired) > 0.05) { forceLeaderBreadcrumb = true; lastLeaderWaypointRef.current = [desired[0], desired[1], desired[2]]; } } else { lastLeaderWaypointRef.current = null; } if (forceLeaderBreadcrumb && leaderPosition) { appendPartyBreadcrumb(leaderTrailRef.current, leaderPosition, 0.01); } if ( party.command === "attack" && objective && !targetPosition && leaderPosition && !routeRef.current.blocked && (Boolean(authoredRouteTarget) || routeRef.current.waypoints.length > routeRef.current.cursor) ) { updatePartyRuntimeAttackCorridor( leaderPosition, authoredRouteTarget?.remaining ?? routeRef.current.waypoints, authoredRouteTarget ? 0 : routeRef.current.cursor, ); } else { clearPartyRuntimeAttackCorridor(); } let frameRouteBlocked = Boolean( leaderIndex >= 0 && party.command !== "stop" && !targetPosition && leaderMovementExpected && ( leaderFollowingPlayer ? leaderPlayerTrailBlocked : navigationFailed || Boolean((navigationGraph || authoredRouteTarget) && routeRef.current.blocked) ), ); for (let index = 0; index < party.members.length; index += 1) { const member = party.members[index]; const actor = actorRefs.current[index]; const body = bodyRefs.current[index]; const current = positionsRef.current.get(member.id); const previousHealth = lastHealthRef.current.get(member.id) ?? member.health; if (previousHealth > 0 && member.health <= 0) { const signal = animationEventSignalsRef.current.get(member.id); animationRevisionRef.current += 1; if (signal) signal.current = { revision: animationRevisionRef.current, kind: "death" }; } else if (previousHealth <= 0 && member.health > 0) { const signal = animationEventSignalsRef.current.get(member.id); animationRevisionRef.current += 1; if (signal) signal.current = { revision: animationRevisionRef.current, kind: "ability-cancel" }; } lastHealthRef.current.set(member.id, member.health); if (!actor || !current) continue; const rejoining = partyRuntimeIsRejoining(member.id); const memberTargetId = rejoining ? null : party.memberTargetIds[member.id] ?? party.activeMobId; const memberTargetPosition = memberTargetId ? getMobPosition(memberTargetId) : null; const canSeeActiveTarget = Boolean( memberTargetId && memberTargetPosition && hasStaticLineOfSight(current, memberTargetPosition), ); updatePartyRuntimeTargetVisibility(member.id, memberTargetId, canSeeActiveTarget); let shouldMove = false; let hasDesired = false; let movementExpected = false; let faceX = Math.sin(actor.rotation.y); let faceZ = Math.cos(actor.rotation.y); const activeControlMechanic = member.controlledUntil > combatNow ? member.controlMechanic : null; if (member.health <= 0) { const signal = movingSignalsRef.current.get(member.id); if (signal) signal.current = false; } else if (member.activeCast || enemyControlPreventsMovement(activeControlMechanic)) { // Casting and crowd control are intentional immobility, not stuck navigation. } else if (rejoining) { const intent = partyRejoinMovement(member.id, current, playerNavigation, routeNow, navigationGraph, combatRoutesRef.current.get(member.id) ?? emptyPartyRouteState(), { directSegmentAllowed, routeSegmentAllowed: navigationRouteSegmentAllowed }); combatRoutesRef.current.set(member.id, intent.route); if (intent.rejoined) { finishPartyRuntimeRejoin(member.id); combatRoutesRef.current.delete(member.id); followerCursorsRef.current.delete(member.id); } else { movementExpected = true; frameRouteBlocked ||= intent.blocked; if (intent.waypoint) { desired[0] = intent.waypoint[0]; desired[1] = intent.waypoint[1]; desired[2] = intent.waypoint[2]; hasDesired = true; faceX = desired[0] - current[0]; faceZ = desired[2] - current[2]; } } } else if (party.command === "stop") { // Holding is represented by retaining the current world-space tuple. } else if (index === leaderIndex) { hasDesired = leaderHasWaypoint; movementExpected = leaderMovementExpected; if (memberTargetPosition) { faceX = memberTargetPosition[0] - current[0]; faceZ = memberTargetPosition[2] - current[2]; } else if (hasDesired) { faceX = desired[0] - current[0]; faceZ = desired[2] - current[2]; } } else { if (memberTargetPosition && memberTargetId) { const profile = movementProfileFor(member.id, index); const rangeBand = personalizedPartyCombatRangeBand( partyCombatRangeBand(member), profile, ); let combatAnchor = combatAnchorsRef.current.get(member.id); if (!combatAnchor || combatAnchor.targetId !== memberTargetId) { combatAnchor = { targetId: memberTargetId, bearing: Math.atan2( current[0] - memberTargetPosition[0], current[2] - memberTargetPosition[2], ) + profile.combatAngleOffset, }; combatAnchorsRef.current.set(member.id, combatAnchor); } const movementIntent = partyPersonalizedCombatMovementGoal( current, memberTargetPosition, canSeeActiveTarget, rangeBand, combatAnchor.bearing, desired, ); movementExpected = movementIntent !== "hold"; if (movementIntent === "hold") { combatRoutesRef.current.delete(member.id); } else { const goalKey = `member:${member.id}|mob:${memberTargetId}|${movementIntent}`; let route = combatRoutesRef.current.get(member.id) ?? emptyPartyRouteState(); if (partyRouteNeedsPlanning( route, goalKey, current, desired, routeNow, { replanDistance: 1.25, replanIntervalMs: 350, blockedRetryMs: 800, }, )) { route = planPartyRoute( navigationGraph, goalKey, current, desired, routeNow, { maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE, maxDestinationSnapDistance: 5, localFinishDistance: Math.max(3, rangeBand.maximum), replanDistance: 1.25, replanIntervalMs: 350, blockedRetryMs: 800, directSegmentAllowed, routeSegmentAllowed: navigationRouteSegmentAllowed, destinationReached: (surface) => { const distance = fullDistance(surface, memberTargetPosition); return distance >= rangeBand.minimum && distance <= rangeBand.maximum && hasStaticLineOfSight(surface, memberTargetPosition); }, }, ); combatRoutesRef.current.set(member.id, route); } hasDesired = nextPartyRouteWaypoint(route, current, desired); const routePrefixRecovery = !hasDesired && route.blocked ? nextPartyRouteRecoveryWaypoint( route, current, desired, directSegmentAllowed, ) : false; if (routePrefixRecovery) hasDesired = true; const directCombatFallback = !hasDesired && route.blocked && directSegmentAllowed(current, desired); if (directCombatFallback) { hasDesired = true; } } } else { let cursor = followerCursorsRef.current.get(member.id); if (!cursor) { cursor = createPartyBreadcrumbCursor(); followerCursorsRef.current.set(member.id, cursor); } const livingRank = Math.max(1, livingIndices.indexOf(index)); const profile = movementProfileFor(member.id, index); const playerTrailingDistance = PARTY_PLAYER_TRAILING_DISTANCE[livingRank] ?? 1.45 + livingRank * 1.15; const leaderTrailingDistance = Math.max( 0.9, (PARTY_TRAIL_DISTANCE[livingRank] ?? livingRank * 1.15) + profile.followDistanceJitter, ); partyNaturalFollowPosition( playerNavigation, playerForwardX, playerForwardZ, playerTrailingDistance, profile, routeNow, naturalFollowTarget, ); if (directSegmentAllowed(current, naturalFollowTarget)) { combatRoutesRef.current.delete(member.id); const wasMoving = directFollowMovingRef.current.get(member.id) ?? false; hasDesired = partyShouldMoveToComfortTarget( current, naturalFollowTarget, wasMoving, profile.comfortSlack, ); directFollowMovingRef.current.set(member.id, hasDesired); synchronizePartyBreadcrumbCursor( leaderTrailRef.current, cursor, leaderTrailingDistance, ); if (hasDesired) { desired[0] = naturalFollowTarget[0]; desired[1] = naturalFollowTarget[1]; desired[2] = naturalFollowTarget[2]; } movementExpected = hasDesired; } else { directFollowMovingRef.current.set(member.id, false); let followerTrailBlocked = false; const previousBreadcrumbId = cursor.breadcrumbId; const requiresValidatedRejoin = cursor.breadcrumbId === null || !leaderTrailRef.current.points.some((point) => point.id === cursor!.breadcrumbId); hasDesired = nextPartyBreadcrumbFollowWaypoint( leaderTrailRef.current, cursor, current, leaderTrailingDistance, BREADCRUMB_REACHED_DISTANCE, desired, profile.comfortSlack, ); if ( hasDesired && requiresValidatedRejoin && !directSegmentAllowed(current, desired) ) { cursor.breadcrumbId = previousBreadcrumbId; hasDesired = false; followerTrailBlocked = true; } if (!hasDesired && navigationGraph) { const goalKey = `member:${member.id}|leader-follow`; const navigationFollowDestination = findNearestPartyNavigationSurface( navigationGraph, naturalFollowTarget, PARTY_NAVIGATION_SNAP_DISTANCE, )?.position ?? naturalFollowTarget; let route = combatRoutesRef.current.get(member.id) ?? emptyPartyRouteState(); if (partyRouteNeedsPlanning( route, goalKey, current, navigationFollowDestination, routeNow, { replanDistance: 1.25, replanIntervalMs: 350, blockedRetryMs: 800, }, )) { route = planPartyRoute( navigationGraph, goalKey, current, navigationFollowDestination, routeNow, { maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE, maxDestinationSnapDistance: 5, localFinishDistance: 3, replanDistance: 1.25, replanIntervalMs: 350, blockedRetryMs: 800, directSegmentAllowed, routeSegmentAllowed: navigationRouteSegmentAllowed, }, ); combatRoutesRef.current.set(member.id, route); } hasDesired = nextPartyRouteWaypoint(route, current, desired); const routePrefixRecovery = !hasDesired && route.blocked ? nextPartyRouteRecoveryWaypoint( route, current, desired, directSegmentAllowed, ) : false; if (routePrefixRecovery) hasDesired = true; followerTrailBlocked = !hasDesired && route.blocked; } else if (hasDesired) { combatRoutesRef.current.delete(member.id); } if (followerTrailBlocked) frameRouteBlocked = true; movementExpected = hasDesired || ( (!navigationGraph || followerTrailBlocked) && planarDistance(current, leaderPosition ?? playerNavigation) > leaderTrailingDistance + profile.comfortSlack ); } } if (memberTargetPosition) { faceX = memberTargetPosition[0] - current[0]; faceZ = memberTargetPosition[2] - current[2]; } else if (hasDesired) { faceX = desired[0] - current[0]; faceZ = desired[2] - current[2]; } } const movementProfile = movementProfileFor(member.id, index); const currentPlanarDistance = planarDistance(current, playerNavigation); if (member.health > 0 && (party.command !== "stop" || rejoining) && hasDesired) { const speed = (currentPlanarDistance > 8 ? PARTY_CATCH_UP_SPEED : PARTY_MOVE_SPEED) * movementProfile.speedMultiplier * enemyControlMovementMultiplier(activeControlMechanic); const moved = stepPartyPosition(current, desired, speed * safeDelta, nextPosition); // Route progress, not radial player distance, is authoritative. A // valid path may need to move away briefly around a U-bend. current[0] = nextPosition[0]; current[1] = nextPosition[1]; current[2] = nextPosition[2]; shouldMove = moved > 0.002; } if (index === leaderIndex && member.health > 0) { appendPartyBreadcrumb(leaderTrailRef.current, current); } const signal = movingSignalsRef.current.get(member.id); if (signal) signal.current = shouldMove; updatePartyRuntimePosition(member.id, current[0], current[1], current[2]); // Navigation height remains authoritative for route progress. Collision // height is presentation-only; mutating the route position here can pin // an actor forever on a vertically offset first waypoint. actor.position.set( current[0], presentationY(current), current[2], ); let stuckProgress = stuckProgressRef.current.get(member.id); if (!stuckProgress) { stuckProgress = createPartyStuckRecoveryState(current, routeNow); stuckProgressRef.current.set(member.id, stuckProgress); } const recoveryGoal = rejoining ? "resurrection-rejoin:player" : memberTargetPosition ? `mob:${memberTargetId}` : index === leaderIndex ? `objective:${party.objectiveBossId}` : `follow:${leaderMember?.id}`; const recovery = updatePartyStuckRecovery(stuckProgress, { position: current, waypoint: hasDesired ? desired : null, goalKey: `${party.command}|${recoveryGoal}`, movementExpected, health: member.health, paused: !active || game.paused || game.mapOpen || game.companionOpen, stopped: party.command === "stop" && !rejoining, casting: member.activeCast !== null, controlled: enemyControlPreventsMovement(activeControlMechanic), nowMs: routeNow, }); if (recovery !== "none") { // Invalidate only this actor's route. Keep its watchdog running across // replans so an endlessly blocked connector cannot restart the timer. combatRoutesRef.current.delete(member.id); followerCursorsRef.current.delete(member.id); directFollowMovingRef.current.delete(member.id); if (index === leaderIndex) { routeRef.current = emptyPartyRouteState(); leaderPlayerCursorRef.current.breadcrumbId = null; lastLeaderWaypointRef.current = null; } if (recovery === "recover") { const landing = choosePartyStuckRecoveryPosition({ current, player: playerNavigation, groupAnchor: rejoining || index === leaderIndex ? playerNavigation : leaderPosition ?? playerNavigation, trailPoints: [...leaderTrailRef.current.points, ...playerTrailRef.current.points].map((point) => point.position), validate: recoveryPointAllowed, }); if (landing && simulation.sessionMode === "spectate-live") { simulation.addFinding({ id: `recovery:${member.id}:${simulation.elapsedMs}`, atMs: simulation.elapsedMs, severity: "critical", code: "position-recovery", message: `${member.name} required a position-only recovery teleport.`, position: [current[0], current[1], current[2]], actorId: member.id, objectiveId: simulation.objectiveId ?? undefined, checkpointIndex: simulation.checkpointIndex, }); simulation.addRecovery(); } if (landing && applyPartyStuckRecoveryLanding(positionsRef.current, member.id, landing.position, stuckProgress, routeNow)) { updatePartyRuntimePosition(member.id, current[0], current[1], current[2]); actor.position.set(current[0], presentationY(current), current[2]); const movingSignal = movingSignalsRef.current.get(member.id); if (movingSignal) movingSignal.current = false; shouldMove = false; combatAnchorsRef.current.delete(member.id); updatePartyRuntimeTargetVisibility(member.id, memberTargetId, Boolean(memberTargetPosition && hasStaticLineOfSight(current, memberTargetPosition))); if (index === leaderIndex) { // Do not record a teleport as a straight, walkable breadcrumb segment. resetPartyBreadcrumbTrail(leaderTrailRef.current, current); followerCursorsRef.current.clear(); clearPartyRuntimeAttackCorridor(); } } } } if (Math.hypot(faceX, faceZ) > 0.001) { const targetYaw = Math.atan2(faceX, faceZ); const yawDelta = Math.atan2( Math.sin(targetYaw - actor.rotation.y), Math.cos(targetYaw - actor.rotation.y), ); actor.rotation.y += yawDelta * (1 - Math.exp(-12 * safeDelta)); } if (body) { const locomotionPhase = clock.elapsedTime * 9 + index * 0.85; body.position.y = shouldMove ? Math.abs(Math.sin(locomotionPhase)) * 0.055 : 0; body.rotation.z += (0 - body.rotation.z) * (1 - Math.exp(-10 * safeDelta)); body.rotation.x = 0; } } party.setRouteBlocked(frameRouteBlocked); }); if (!members.length) return null; return ( {members.map((member, index) => { const characterClass = classById(member.classId); const race = raceById(member.raceId); const subtitle = isRomClassId(member.classId) ? `${characterClass.name}${member.secondaryClassId ? `/${classById(member.secondaryClassId).name}` : ""}` : `${race.name} ยท ${member.specialization}`; const avatarMetrics = PARTY_AVATAR_METRICS[`${member.raceId}:${member.gender}`] ?? DEFAULT_PARTY_AVATAR_METRICS; const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index); let movingSignal = movingSignalsRef.current.get(member.id); if (!movingSignal) { movingSignal = { current: false }; movingSignalsRef.current.set(member.id, movingSignal); } let animationEventSignal = animationEventSignalsRef.current.get(member.id); if (!animationEventSignal) { animationEventSignal = { current: null }; animationEventSignalsRef.current.set(member.id, animationEventSignal); } positionsRef.current.set(member.id, position); return ( { actorRefs.current[index] = object; }} name={`party-member-${member.id}`} position={position} scale={member.role === "tank" ? 1.04 : 0.96} onClick={(event) => { event.stopPropagation(); event.nativeEvent.stopPropagation(); if (event.button !== 0 || !active || member.health <= 0) return; useCombatStore.getState().clearTarget(); usePartyStore.getState().selectMember(member.id); }} > { bodyRefs.current[index] = object; }}> } /> {selectedMemberId === member.id && member.health > 0 ? ( ) : null} {active ? ( 0 ? 1 : 0.55, }}>
{member.name}
{subtitle}
) : null}
); })}
); } export function PartyPopulation({ active = true, simulate = true, }: { readonly active?: boolean; readonly simulate?: boolean; }) { const activeDungeonId = useGameStore((state) => state.activeDungeonId); const gameMode = useGameStore((state) => state.gameMode); const sessionRevision = useGameStore((state) => state.sessionRevision); const wailingPhase = useWailingEncounterStore((state) => state.phase); const manastormEncounter = useManastormStore((state) => state.currentEncounter); const simulationSessionMode = useDungeonSimulationStore((state) => state.sessionMode); useDungeonRouteStore((state) => state.approvals); const dungeon = requireDungeonDefinition(activeDungeonId); const navigationReviewed = dungeon.validation.status === "green" || dungeon.validation.navigation === "green"; const manastormPackage = gameMode === "manastorm" ? activeManastormStageAssetPackage() : null; const objectives: readonly DungeonBossObjective[] = gameMode === "manastorm" && manastormEncounter ? [{ id: manastormEncounter.bossRuntimeId, name: manastormEncounter.bossName, position: manastormEncounter.bossPosition, }] // Generated packages preserve complete objective metadata, but their // fragmented Recast islands do not claim autonomous route fidelity. Until // reviewed links make navigation green, the player leads and the party // follows/engages locally instead of displaying a false blocked route. : simulationSessionMode === "spectate-live" || Boolean(routeForActiveDungeon(activeDungeonId)) ? dungeon.objectiveOrder.filter((objective) => !objective.optional) : navigationReviewed ? activeDungeonId === "wailing-caverns" && wailingPhase !== "mutanus" && wailingPhase !== "complete" ? dungeon.objectiveOrder.filter((objective) => !objective.eventOnly) : dungeon.objectiveOrder.filter((objective) => !objective.optional) : []; const assets = useMemo( () => manastormPackage ? resolveManastormStageAssets(manastormPackage) : resolveDungeonAssets(dungeon), [dungeon, manastormPackage, sessionRevision], ); const navigationEnabled = Boolean(assets.navigation) && shouldLoadPartyNavigation(gameMode, dungeon, manastormPackage); const navigationLinks = manastormPackage ? EMPTY_NAVIGATION_LINKS : dungeon.navigationLinks; const traversalRamps = manastormPackage ? EMPTY_NAVIGATION_LINKS : dungeon.traversalRamps ?? EMPTY_NAVIGATION_LINKS; const [navigationState, setNavigationState] = useState<{ readonly url: string; readonly graph: PartyNavigationGraph; } | null>(null); const [failedNavigationUrl, setFailedNavigationUrl] = useState(null); const navigationGraph = navigationEnabled && navigationState?.url === assets.navigation ? navigationState.graph : null; const navigationFailed = Boolean( assets.navigation && failedNavigationUrl === assets.navigation, ); const acceptNavigationGraph = useCallback((graph: PartyNavigationGraph) => { if (!assets.navigation) return; setFailedNavigationUrl((current) => current === assets.navigation ? null : current); setNavigationState((current) => ( current?.url === assets.navigation && current.graph === graph ? current : { url: assets.navigation!, graph } )); }, [assets.navigation]); const rejectNavigationGraph = useCallback(() => { if (assets.navigation) setFailedNavigationUrl(assets.navigation); }, [assets.navigation]); useEffect(() => { setNavigationState((current) => ( navigationEnabled && current?.url === assets.navigation ? current : null )); }, [assets.navigation, navigationEnabled]); return ( <> {navigationEnabled && assets.navigation ? ( ) : null} ); }