forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
6114a2295f
commit
69809f7228
593 files changed
+903643
-40774
No files matched your search
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* 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,
|
||||
]);
|
||||
}
|
||||
Reference in new issue
Block a user