Files
healer-man/src/game/enemyFearMovement.ts
T
2026-10-06 22:25:34 -04:00

294 lines
12 KiB
TypeScript

/**
* 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 ?? "<lost-source>"}`;
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<EnemyFearMovementPlan, { kind: "move" }>["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,
]);
}