/** * Deterministic, source-bounded fear destination planning for simulator actors. * * Movement adapters own the actor's real map projection, line of sight, and * approved path query. A returned destination is a route goal, never a * position to assign directly to an actor. */ export type EnemyFearPosition = readonly [x: number, y: number, z: number]; /** Constants read from e822d718's FleeingMovementGenerator.cpp. */ export const ENEMY_FEAR_SOURCE_RULES = Object.freeze({ minQuietDistanceYards: 28, maxQuietDistanceYards: 43, minPathLengthYards: 2, maxPathLengthYards: 30, nearDistanceScale: Object.freeze([0.4, 1.3] as const), farDistanceScale: Object.freeze([0.4, 1.0] as const), quietDistanceScale: Object.freeze([0.6, 1.2] as const), nearAngleJitterRadians: Math.PI / 8, farAngleJitterRadians: Math.PI / 4, lineOfSightRetryMs: 200, pathRetryMs: 100, repathWaitMs: Object.freeze([800, 1_500] as const), }); export interface EnemyFearMovementInput { /** Stable identity of this independently controlled actor. */ readonly actorId: string; /** Source aura caster identity; may be null if the aura has no source. */ readonly sourceActorId: string | null; /** Aura application timestamp, used to seed this actor's deterministic choices. */ readonly fearAppliedAtMs: number; /** Increment after each accepted destination or failed path attempt. */ readonly decisionIndex: number; readonly nowMs: number; readonly fearEndsAtMs: number; readonly stunEndsAtMs: number | null; readonly currentPosition: EnemyFearPosition; /** Base actor speed in world units per second, before this source aura's bonus. */ readonly baseMovementSpeed: number; /** Actor-local multiplier from the active source speed aura; use 1 when absent. */ readonly sourceSpeedMultiplier: number; /** Conversion from source yards to the simulator's map units. */ readonly worldUnitsPerYard: number; /** Read the live caster position at each repath; null selects the source fallback. */ readonly getSourcePosition: (sourceActorId: string) => EnemyFearPosition | null; } export interface EnemyFearMovementSpatialAdapter { /** * Return an actor-safe, nearby map destination, or null when this candidate * cannot be projected without an excessive snap or floor change. */ readonly projectDestination: ( requested: EnemyFearPosition, current: EnemyFearPosition, ) => EnemyFearPosition | null; /** LOS from current actor position to the projected destination. */ readonly hasLineOfSight: (current: EnemyFearPosition, destination: EnemyFearPosition) => boolean; /** * Return the length of a complete actor-safe route, or null if no approved * path exists. The adapter must reject shortcuts and blocked segments. */ readonly pathLength: (current: EnemyFearPosition, destination: EnemyFearPosition) => number | null; } export type EnemyFearMovementHoldReason = | "fear-inactive" | "stun" | "invalid-input" | "zero-speed" | "map-rejected" | "line-of-sight" | "path-rejected" | "path-too-short" | "path-too-long"; export type EnemyFearMovementPlan = | { readonly kind: "hold"; readonly reason: EnemyFearMovementHoldReason; /** Core retry delay for rejected goals; null when status state owns retry timing. */ readonly retryAfterMs: number | null; } | { readonly kind: "move"; /** Safe route goal. Integrations must move along the approved actor path. */ readonly destination: EnemyFearPosition; readonly requestedDestination: EnemyFearPosition; readonly pathLength: number; readonly movementSpeed: number; readonly band: "near-source" | "quiet-range" | "far-source" | "source-lost"; /** Simulator repath time: estimated travel time plus a seeded source-range wait. */ readonly nextDecisionAtMs: number; readonly seededWaitMs: number; }; const TWO_PI = Math.PI * 2; const COINCIDENT_SOURCE_DISTANCE_YARDS = 0.2; function isFinitePosition(position: EnemyFearPosition): boolean { return Number.isFinite(position[0]) && Number.isFinite(position[1]) && Number.isFinite(position[2]); } function clampUnit(value: number): number { return Math.max(0, Math.min(1, value)); } function mix32(value: number): number { let mixed = value | 0; mixed ^= mixed >>> 16; mixed = Math.imul(mixed, 0x7feb352d); mixed ^= mixed >>> 15; mixed = Math.imul(mixed, 0x846ca68b); mixed ^= mixed >>> 16; return mixed >>> 0; } function movementSeed(input: EnemyFearMovementInput): number { let seed = 0x811c9dc5; const identity = `${input.actorId}\u0000${input.sourceActorId ?? ""}`; for (let index = 0; index < identity.length; index += 1) { seed = Math.imul(seed ^ identity.charCodeAt(index), 0x01000193); } seed = Math.imul(seed ^ (Math.trunc(input.fearAppliedAtMs) | 0), 0x01000193); seed = Math.imul(seed ^ Math.trunc(input.fearAppliedAtMs / 0x1_0000_0000), 0x01000193); seed = Math.imul(seed ^ (input.decisionIndex | 0), 0x01000193); return mix32(seed); } function seededUnit(seed: number, channel: number): number { return mix32(seed ^ Math.imul(channel + 1, 0x9e3779b9)) / 0x1_0000_0000; } function lerp(range: readonly [number, number], amount: number): number { return range[0] + (range[1] - range[0]) * amount; } function planarAngle(from: EnemyFearPosition, to: EnemyFearPosition): number { return Math.atan2(to[0] - from[0], to[2] - from[2]); } function distance3d(left: EnemyFearPosition, right: EnemyFearPosition): number { return Math.hypot(right[0] - left[0], right[1] - left[1], right[2] - left[2]); } function hold(reason: EnemyFearMovementHoldReason, retryAfterMs: number | null = null): EnemyFearMovementPlan { return Object.freeze({ kind: "hold", reason, retryAfterMs }); } /** * Pick one deterministic flee goal using the historical server's distance * bands and path limits. Random values are stable per actor, source aura, and * decision index; they are simulator choices, not the core's global RNG. */ export function planEnemyFearMovement( input: EnemyFearMovementInput, spatial: EnemyFearMovementSpatialAdapter, ): EnemyFearMovementPlan { if (input.stunEndsAtMs !== null && input.stunEndsAtMs > input.nowMs) return hold("stun"); if (input.fearEndsAtMs <= input.nowMs) return hold("fear-inactive"); if (!input.actorId || !Number.isFinite(input.nowMs) || !Number.isFinite(input.fearAppliedAtMs) || !Number.isInteger(input.decisionIndex) || input.decisionIndex < 0 || !Number.isFinite(input.fearEndsAtMs) || (input.stunEndsAtMs !== null && !Number.isFinite(input.stunEndsAtMs)) || !isFinitePosition(input.currentPosition) || !Number.isFinite(input.baseMovementSpeed) || !Number.isFinite(input.sourceSpeedMultiplier) || !Number.isFinite(input.worldUnitsPerYard) || input.baseMovementSpeed < 0 || input.sourceSpeedMultiplier < 0 || input.worldUnitsPerYard <= 0) return hold("invalid-input"); const movementSpeed = input.baseMovementSpeed * input.sourceSpeedMultiplier; if (!Number.isFinite(movementSpeed) || movementSpeed <= 0) return hold("zero-speed"); const seed = movementSeed(input); const sourcePosition = input.sourceActorId ? input.getSourcePosition(input.sourceActorId) : null; const sourceIsAvailable = Boolean(sourcePosition && isFinitePosition(sourcePosition)); const source = sourceIsAvailable ? sourcePosition! : null; const sourceDistanceYards = source ? distance3d(input.currentPosition, source) / input.worldUnitsPerYard : 0; let band: Extract["band"]; let requestedDistanceYards: number; let angle: number; if (!source) { // Core fallback after its source GUID stops resolving: random angle, then // the same near-source branch with casterDistance = 0. band = "source-lost"; angle = seededUnit(seed, 0) * TWO_PI; requestedDistanceYards = lerp( ENEMY_FEAR_SOURCE_RULES.nearDistanceScale, seededUnit(seed, 1), ) * ENEMY_FEAR_SOURCE_RULES.minQuietDistanceYards; angle += (seededUnit(seed, 2) * 2 - 1) * ENEMY_FEAR_SOURCE_RULES.nearAngleJitterRadians; } else if (sourceDistanceYards < ENEMY_FEAR_SOURCE_RULES.minQuietDistanceYards) { band = "near-source"; const awayAngle = sourceDistanceYards > COINCIDENT_SOURCE_DISTANCE_YARDS ? planarAngle(source, input.currentPosition) : seededUnit(seed, 0) * TWO_PI; requestedDistanceYards = lerp( ENEMY_FEAR_SOURCE_RULES.nearDistanceScale, seededUnit(seed, 1), ) * (ENEMY_FEAR_SOURCE_RULES.minQuietDistanceYards - sourceDistanceYards); angle = awayAngle + (seededUnit(seed, 2) * 2 - 1) * ENEMY_FEAR_SOURCE_RULES.nearAngleJitterRadians; } else if (sourceDistanceYards > ENEMY_FEAR_SOURCE_RULES.maxQuietDistanceYards) { band = "far-source"; const towardSource = planarAngle(input.currentPosition, source); requestedDistanceYards = lerp( ENEMY_FEAR_SOURCE_RULES.farDistanceScale, seededUnit(seed, 1), ) * (ENEMY_FEAR_SOURCE_RULES.maxQuietDistanceYards - ENEMY_FEAR_SOURCE_RULES.minQuietDistanceYards); angle = towardSource + (seededUnit(seed, 2) * 2 - 1) * ENEMY_FEAR_SOURCE_RULES.farAngleJitterRadians; } else { band = "quiet-range"; requestedDistanceYards = lerp( ENEMY_FEAR_SOURCE_RULES.quietDistanceScale, seededUnit(seed, 1), ) * (ENEMY_FEAR_SOURCE_RULES.maxQuietDistanceYards - ENEMY_FEAR_SOURCE_RULES.minQuietDistanceYards); angle = seededUnit(seed, 2) * TWO_PI; } const distance = requestedDistanceYards * input.worldUnitsPerYard; const requestedDestination: EnemyFearPosition = Object.freeze([ input.currentPosition[0] + Math.sin(angle) * distance, input.currentPosition[1], input.currentPosition[2] + Math.cos(angle) * distance, ]); const destination = spatial.projectDestination(requestedDestination, input.currentPosition); if (!destination || !isFinitePosition(destination)) return hold("map-rejected", ENEMY_FEAR_SOURCE_RULES.pathRetryMs); if (!spatial.hasLineOfSight(input.currentPosition, destination)) { return hold("line-of-sight", ENEMY_FEAR_SOURCE_RULES.lineOfSightRetryMs); } const pathLength = spatial.pathLength(input.currentPosition, destination); if (pathLength === null || !Number.isFinite(pathLength) || pathLength < 0) { return hold("path-rejected", ENEMY_FEAR_SOURCE_RULES.pathRetryMs); } const pathLengthYards = pathLength / input.worldUnitsPerYard; if (pathLengthYards < ENEMY_FEAR_SOURCE_RULES.minPathLengthYards) { return hold("path-too-short", ENEMY_FEAR_SOURCE_RULES.pathRetryMs); } if (pathLengthYards > ENEMY_FEAR_SOURCE_RULES.maxPathLengthYards) { return hold("path-too-long", ENEMY_FEAR_SOURCE_RULES.pathRetryMs); } const seededWaitMs = Math.round(lerp( ENEMY_FEAR_SOURCE_RULES.repathWaitMs, seededUnit(seed, 3), )); const estimatedTravelMs = pathLength / movementSpeed * 1_000; return Object.freeze({ kind: "move", destination: Object.freeze([...destination]) as EnemyFearPosition, requestedDestination, pathLength, movementSpeed, band, nextDecisionAtMs: input.nowMs + estimatedTravelMs + seededWaitMs, seededWaitMs, }); } /** Move a waypoint target toward a fear destination by a bounded planar step. */ export function stepEnemyFearTowardDestination( current: EnemyFearPosition, destination: EnemyFearPosition, maximumStep: number, ): EnemyFearPosition { if (!isFinitePosition(current) || !isFinitePosition(destination)) return Object.freeze([...current]); const step = Number.isFinite(maximumStep) ? Math.max(0, maximumStep) : 0; const dx = destination[0] - current[0]; const dz = destination[2] - current[2]; const distance = Math.hypot(dx, dz); if (step <= 0 || distance <= 1e-9) return Object.freeze([...current]); const scale = Math.min(1, step / distance); return Object.freeze([ current[0] + dx * scale, current[1] + (destination[1] - current[1]) * scale, current[2] + dz * scale, ]); }