import type { ActiveAura } from "./combatAuras"; import { combatHasLineOfSight, combatNavigationGraph, combatSpatialActor, projectCombatGroundPoint } from "./combatSpatial"; import { createEnemyFearMovementRuntime, resolveEnemyFearActorAuras } from "./enemyFearMovementRuntime"; import { stepEnemyFearTowardDestination, type EnemyFearPosition } from "./enemyFearMovement"; import { enemyTriggeredShoutMovementSpeedMultiplier } from "./enemyTriggeredShouts"; import { findNearestPartyNavigationSurface, findPartyNavigationPath } from "./partyNavigation"; const runtime = createEnemyFearMovementRuntime(); const distance = (a: EnemyFearPosition, b: EnemyFearPosition) => Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]); /** A fear-only position step; callers always suppress summon attacks and casts while it applies. */ export function sourceFearSummonPosition(input: { readonly id: string; readonly position: EnemyFearPosition; readonly auras: readonly ActiveAura[]; readonly now: number; readonly deltaSeconds: number; readonly movementMultiplier: number; }): EnemyFearPosition | null { const control = resolveEnemyFearActorAuras(input.auras, input.id, input.now); if (!control.fearAura) { runtime.clearActor(input.id); return null; } // Existing generic control remains authoritative, even if its timer is shorter than source fear. const otherMovementControl = input.auras.some((aura) => aura.targetId === input.id && (aura.expiresAt === null || aura.expiresAt > input.now) && aura.definition.id.startsWith("enemy-control:") && !aura.definition.id.startsWith("enemy-control:source-spell-") && !/:slow$|:interrupt$|:silence$/.test(aura.definition.id)); if (otherMovementControl) { runtime.clearActor(input.id); return input.position; } const graph = combatNavigationGraph(); if (!graph) { runtime.clearActor(input.id); return input.position; } const path = (current: EnemyFearPosition, goal: EnemyFearPosition) => findPartyNavigationPath(graph, current, goal, { maxSnapDistance: 0.75, routeSegmentAllowed: combatHasLineOfSight, }); const plan = runtime.plan({ actorId: input.id, fearAura: control.fearAura, stunEndsAtMs: control.stunEndsAtMs, nowMs: input.now, currentPosition: input.position, baseMovementSpeed: 4 * input.movementMultiplier, sourceSpeedMultiplier: enemyTriggeredShoutMovementSpeedMultiplier(input.auras, input.id, input.now), worldUnitsPerYard: 1, getSourcePosition: (id) => combatSpatialActor(id)?.position ?? null, }, { projectDestination: (requested, current) => { const projected = projectCombatGroundPoint(requested, distance(current, requested), current); if (!projected || distance(requested, projected) > 1.25) return null; return findNearestPartyNavigationSurface(graph, projected, 0.75)?.position ?? null; }, hasLineOfSight: combatHasLineOfSight, pathLength: (current, goal) => { const route = path(current, goal); return route ? route.reduce((length, point, index) => length + (index ? distance(route[index - 1], point) : 0), 0) : null; }, }); if (plan.kind !== "move") return input.position; const route = path(input.position, plan.destination); if (!route) { runtime.clearActor(input.id); return input.position; } const waypoint = route.find((point) => distance(input.position, point) > 0.05); if (!waypoint) return input.position; const maximumStep = plan.movementSpeed * Math.max(0, Math.min(1, input.deltaSeconds)); const requested = stepEnemyFearTowardDestination(input.position, waypoint, maximumStep); const projected = projectCombatGroundPoint(requested, maximumStep, input.position); if (!projected || distance(input.position, projected) > maximumStep + 0.05 || !combatHasLineOfSight(input.position, projected) || !findNearestPartyNavigationSurface(graph, projected, 0.15)) return input.position; return projected; }