forked from phenom/healer-man
176 lines
6.9 KiB
TypeScript
176 lines
6.9 KiB
TypeScript
import type { MutablePartyWorldPosition, PartyWorldPosition } from "./partyRuntimeRegistry";
|
|
|
|
export const PARTY_STUCK_REPLAN_MS = 2_000;
|
|
export const PARTY_STUCK_RECOVER_MS = 5_000;
|
|
export const PARTY_STUCK_RECOVERY_COOLDOWN_MS = 10_000;
|
|
const PROGRESS_DISTANCE = 0.45;
|
|
const RECOVERY_RETRY_MS = 1_000;
|
|
const MAX_TRAIL_GROUP_DISTANCE = 12;
|
|
const MAX_TRAIL_HEIGHT_DIFFERENCE = 1.25;
|
|
const MIN_RECOVERY_DISTANCE = 1;
|
|
|
|
export interface PartyStuckRecoveryState {
|
|
goalKey: string | null;
|
|
readonly previousPosition: MutablePartyWorldPosition;
|
|
waypoint: MutablePartyWorldPosition | null;
|
|
netProgress: number;
|
|
lastProgressAtMs: number;
|
|
replanned: boolean;
|
|
cooldownUntilMs: number;
|
|
retryAtMs: number;
|
|
}
|
|
|
|
export interface PartyStuckRecoverySample {
|
|
readonly position: PartyWorldPosition;
|
|
readonly waypoint: PartyWorldPosition | null;
|
|
readonly goalKey: string;
|
|
readonly movementExpected: boolean;
|
|
readonly health: number;
|
|
readonly paused: boolean;
|
|
readonly stopped: boolean;
|
|
readonly casting: boolean;
|
|
readonly controlled: boolean;
|
|
readonly nowMs: number;
|
|
}
|
|
|
|
function distance(a: PartyWorldPosition, b: PartyWorldPosition): number {
|
|
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
|
}
|
|
|
|
function finitePosition(position: PartyWorldPosition): boolean {
|
|
return position.every(Number.isFinite);
|
|
}
|
|
|
|
export function createPartyStuckRecoveryState(position: PartyWorldPosition, nowMs: number): PartyStuckRecoveryState {
|
|
return {
|
|
goalKey: null,
|
|
previousPosition: [...position],
|
|
waypoint: null,
|
|
netProgress: 0,
|
|
lastProgressAtMs: nowMs,
|
|
replanned: false,
|
|
cooldownUntilMs: 0,
|
|
retryAtMs: 0,
|
|
};
|
|
}
|
|
|
|
/** Clear navigation timing without letting pauses/holds bypass a teleport cooldown. */
|
|
export function resetPartyStuckRecoveryProgress(state: PartyStuckRecoveryState): void {
|
|
state.goalKey = null;
|
|
state.waypoint = null;
|
|
state.netProgress = 0;
|
|
state.replanned = false;
|
|
}
|
|
|
|
/** Signed progress toward the previous waypoint cancels doorway back-and-forth. */
|
|
export function updatePartyStuckRecovery(
|
|
state: PartyStuckRecoveryState,
|
|
sample: PartyStuckRecoverySample,
|
|
): "none" | "replan" | "recover" {
|
|
const { position, waypoint, nowMs } = sample;
|
|
if (!Number.isFinite(nowMs) || !finitePosition(position) || (waypoint && !finitePosition(waypoint))) {
|
|
resetPartyStuckRecoveryProgress(state);
|
|
return "none";
|
|
}
|
|
const excluded = !sample.movementExpected || sample.health <= 0 || sample.paused
|
|
|| sample.stopped || sample.casting || sample.controlled;
|
|
if (excluded || state.goalKey !== sample.goalKey || nowMs < state.lastProgressAtMs) {
|
|
resetPartyStuckRecoveryProgress(state);
|
|
state.lastProgressAtMs = nowMs;
|
|
if (!excluded) state.goalKey = sample.goalKey;
|
|
} else if (state.waypoint) {
|
|
// Target/waypoint movement itself is not actor progress. Count only the
|
|
// actor's displacement relative to the last sampled route waypoint.
|
|
// Once the old waypoint was reached, movement belongs to the next leg.
|
|
// Otherwise dense waypoint chains would count every forward step as
|
|
// moving away from the just-completed point.
|
|
const progressWaypoint = waypoint && distance(state.previousPosition, state.waypoint) <= 0.06
|
|
&& distance(waypoint, state.waypoint) > 0.04 ? waypoint : state.waypoint;
|
|
state.netProgress += distance(state.previousPosition, progressWaypoint) - distance(position, progressWaypoint);
|
|
if (state.netProgress >= PROGRESS_DISTANCE) {
|
|
state.netProgress = 0;
|
|
state.lastProgressAtMs = nowMs;
|
|
state.replanned = false;
|
|
}
|
|
}
|
|
state.previousPosition[0] = position[0];
|
|
state.previousPosition[1] = position[1];
|
|
state.previousPosition[2] = position[2];
|
|
if (waypoint && !excluded) {
|
|
state.waypoint ??= [0, 0, 0];
|
|
state.waypoint[0] = waypoint[0];
|
|
state.waypoint[1] = waypoint[1];
|
|
state.waypoint[2] = waypoint[2];
|
|
} else state.waypoint = null;
|
|
if (excluded) return "none";
|
|
|
|
const elapsed = nowMs - state.lastProgressAtMs;
|
|
if (!state.replanned && elapsed >= PARTY_STUCK_REPLAN_MS) {
|
|
state.replanned = true;
|
|
return "replan";
|
|
}
|
|
if (elapsed >= PARTY_STUCK_RECOVER_MS && nowMs >= state.cooldownUntilMs && nowMs >= state.retryAtMs) {
|
|
state.retryAtMs = nowMs + RECOVERY_RETRY_MS;
|
|
return "recover";
|
|
}
|
|
return "none";
|
|
}
|
|
|
|
export function markPartyStuckRecovered(state: PartyStuckRecoveryState, position: PartyWorldPosition, nowMs: number): void {
|
|
resetPartyStuckRecoveryProgress(state);
|
|
state.previousPosition[0] = position[0];
|
|
state.previousPosition[1] = position[1];
|
|
state.previousPosition[2] = position[2];
|
|
state.lastProgressAtMs = nowMs;
|
|
state.cooldownUntilMs = nowMs + PARTY_STUCK_RECOVERY_COOLDOWN_MS;
|
|
state.retryAtMs = state.cooldownUntilMs;
|
|
}
|
|
|
|
/** Position-only recovery: no squad recall, healing, threat reset, or resurrection. */
|
|
export function applyPartyStuckRecoveryLanding(
|
|
positions: ReadonlyMap<string, MutablePartyWorldPosition>,
|
|
memberId: string,
|
|
landing: PartyWorldPosition,
|
|
state: PartyStuckRecoveryState,
|
|
nowMs: number,
|
|
): boolean {
|
|
const current = positions.get(memberId);
|
|
if (!current || !finitePosition(landing) || !Number.isFinite(nowMs)) return false;
|
|
current[0] = landing[0];
|
|
current[1] = landing[1];
|
|
current[2] = landing[2];
|
|
markPartyStuckRecovered(state, current, nowMs);
|
|
return true;
|
|
}
|
|
|
|
/** Only visited trail positions are local candidates; never jump to an arbitrary nearest room. */
|
|
export function choosePartyStuckRecoveryPosition({
|
|
current, player, groupAnchor, trailPoints, validate,
|
|
}: {
|
|
readonly current: PartyWorldPosition;
|
|
readonly player: PartyWorldPosition;
|
|
readonly groupAnchor: PartyWorldPosition;
|
|
readonly trailPoints: readonly PartyWorldPosition[];
|
|
readonly validate: (position: PartyWorldPosition, anchor: PartyWorldPosition) => PartyWorldPosition | null;
|
|
}): { readonly position: PartyWorldPosition; readonly source: "trail" | "player" } | null {
|
|
if (!finitePosition(current) || !finitePosition(player) || !finitePosition(groupAnchor)) return null;
|
|
const candidates = trailPoints.filter((point) => finitePosition(point)
|
|
&& distance(point, current) >= MIN_RECOVERY_DISTANCE
|
|
&& distance(point, groupAnchor) <= MAX_TRAIL_GROUP_DISTANCE
|
|
&& Math.abs(point[1] - groupAnchor[1]) <= MAX_TRAIL_HEIGHT_DIFFERENCE)
|
|
.sort((a, b) => distance(a, groupAnchor) - distance(b, groupAnchor));
|
|
// Bound expensive path/physics checks even when a looping trail has many samples.
|
|
for (const candidate of candidates.slice(0, 16)) {
|
|
const position = validate(candidate, groupAnchor);
|
|
if (position && finitePosition(position) && distance(position, current) >= MIN_RECOVERY_DISTANCE
|
|
&& distance(position, candidate) <= 0.85
|
|
&& Math.abs(position[1] - groupAnchor[1]) <= MAX_TRAIL_HEIGHT_DIFFERENCE) {
|
|
return { position: [...position], source: "trail" };
|
|
}
|
|
}
|
|
const position = validate(player, player);
|
|
return position && finitePosition(position) && distance(position, player) <= 0.85
|
|
? { position: [...position], source: "player" }
|
|
: null;
|
|
}
|