Files
healer-man/src/game/enemyAttacks.ts
T
2026-10-04 13:13:35 -04:00

434 lines
17 KiB
TypeScript

import { enemyDamageTypeSourceForSpell, resolveEnemyDamageType, type EnemyDamageOrigin } from "./mobDamageTypes";
import { romNativeEnemyPeriodicDamageSources } from "./romNativePeriodicDamage";
import {
normalizeRunewakerDamageFormula,
type RunewakerDamageFormulaSource,
} from "./runewakerCombatFormula";
export type EnemyAttackAnimation = "attack" | "cast";
export type EnemyAttackDelivery = "melee" | "projectile" | "area";
export type EnemyAttackTarget =
| "primary"
| "random-party"
| "nearby-party"
| "self"
| "lowest-health-friendly";
export type EnemyDamageSchool =
| "physical"
| "arcane"
| "fire"
| "frost"
| "nature"
| "shadow"
| "holy";
export type EnemyControlMechanic = "confuse" | "fear" | "sleep" | "root" | "slow" | "stun" | "silence" | "interrupt";
/** Frost-style slows preserve actions while reducing authored movement speed. */
export const ENEMY_SLOW_MOVEMENT_MULTIPLIER = 0.6;
export function enemyControlPreventsActions(
mechanic: EnemyControlMechanic | null,
): boolean {
return mechanic !== null && mechanic !== "root" && mechanic !== "slow" && mechanic !== "interrupt";
}
export function enemyControlPreventsMovement(
mechanic: EnemyControlMechanic | null,
): boolean {
return mechanic !== null && mechanic !== "slow" && mechanic !== "interrupt";
}
export function enemyControlMovementMultiplier(
mechanic: EnemyControlMechanic | null,
): number {
return mechanic === "slow" ? ENEMY_SLOW_MOVEMENT_MULTIPLIER : 1;
}
/**
* A persistent enemy aura that procs a short-lived, charged critical-strike
* modifier from successful main-hand auto attacks.
*/
export interface EnemyMeleeAutoCriticalProc {
readonly chance: number;
readonly triggeredAura: string;
readonly durationMs: number;
readonly criticalChancePercent: number;
readonly charges: number;
}
export type EnemyTelegraphShape = "circle" | "cone" | "line";
export type EnemyTelegraphAnchor = "source" | "target";
/** World-space warning geometry shared by simulation and Three presentation. */
export interface EnemyAttackTelegraph {
readonly shape: EnemyTelegraphShape;
readonly anchor: EnemyTelegraphAnchor;
/** Minimum warning time before the mechanic resolves. */
readonly warningMs: number;
readonly radius?: number;
readonly length?: number;
readonly width?: number;
readonly angleDegrees?: number;
}
export type EnemyAtomicAbilityEffect =
| { readonly kind: "damage"; readonly multiplier: number }
| { readonly kind: "dot"; readonly multiplier: number; readonly ticks: number; readonly intervalMs: number;
readonly sourceSkillId?: number; readonly componentId?: number }
| { readonly kind: "heal"; readonly multiplier: number }
| {
readonly kind: "leech";
readonly damageMultiplier: number;
readonly healMultiplier: number;
}
| {
readonly kind: "aura";
readonly aura: string;
readonly durationMs: number;
readonly magnitude?: number;
/** Multiplier applied to damage received while this aura is active. */
readonly damageTakenMultiplier?: number;
readonly damageSchool?: EnemyDamageSchool;
/** Absorb capacity expressed in multiples of the caster's attack power. */
readonly absorbMultiplier?: number;
readonly reflectSpellChance?: number;
/** Portion of subsequent outgoing attack power returned as healing. */
readonly lifeStealPercent?: number;
readonly meleeAutoCriticalProc?: EnemyMeleeAutoCriticalProc;
}
| {
readonly kind: "target-aura";
readonly aura: string;
readonly durationMs: number;
readonly damageTakenMultiplier?: number;
readonly damageSchool?: EnemyDamageSchool;
}
| {
readonly kind: "control";
readonly mechanic: EnemyControlMechanic;
readonly durationMs: number;
/** Optional damage resolved against the same targets as the control. */
readonly damageMultiplier?: number;
}
| { readonly kind: "transform"; readonly form: string }
| { readonly kind: "pull"; readonly destination: "source" }
| {
readonly kind: "summon";
readonly creatureEntry: number;
readonly count: number;
/** Optional stable namespace for content-authored summoned entities. */
readonly entityId?: string;
/** Optional owner group used by encounter counters and reset cleanup. */
readonly spawnGroupId?: string;
}
| { readonly kind: "call-for-help"; readonly radius: number }
| { readonly kind: "encounter-event"; readonly eventId: string };
export type EnemyAbilityEffect = EnemyAtomicAbilityEffect | {
/** A single source spell that resolves more than one supported mechanic. */
readonly kind: "composite";
readonly effects: readonly EnemyAtomicAbilityEffect[];
};
export type EnemyAbilityCondition =
| { readonly kind: "in-combat" }
| {
readonly kind: "self-health-percent";
readonly minimum: number;
readonly maximum: number;
}
| {
readonly kind: "friendly-missing-health";
readonly amount: number;
readonly range: number;
}
| {
readonly kind: "encounter-state";
readonly encounterId: string;
readonly states: readonly string[];
};
export interface EnemyAbilitySource {
readonly entry: number;
readonly sourceType: number;
readonly rowId: number;
readonly eventType: number;
readonly actionType: number;
readonly targetType: number;
readonly comment: string;
}
export interface EnemyAttackDefinition {
readonly id: string;
readonly name: string;
readonly spellId?: number;
readonly delivery: EnemyAttackDelivery;
readonly target: EnemyAttackTarget;
readonly school: EnemyDamageSchool;
/** Hit-table channel, independent from projectile/area presentation and damage school. */
readonly attackKind?: "melee" | "ranged" | "spell";
readonly damageClass?: number;
/** Original WoW school bits; composite schools retain the authored scalar policy. */
readonly schoolMask?: number;
/** Source RuneWaker HP calculation channel (0 magic, 1 physical). */
readonly runewakerEffectType?: number;
readonly runewakerComponentId?: number;
readonly damageOrigin?: EnemyDamageOrigin;
/** Runtime pulse of a previously applied periodic debuff. */
readonly periodicTick?: boolean;
readonly targetMitigationSnapshotted?: boolean;
readonly periodicNoKill?: boolean;
readonly animation: EnemyAttackAnimation;
/** Zero keeps the legacy instant-execution behavior. */
readonly castTimeMs?: number;
/** Casts are interruptible unless explicitly disabled. */
readonly interruptible?: boolean;
readonly range: number;
readonly cooldownMs: number;
readonly initialCooldownMs?: readonly [minimum: number, maximum: number];
readonly repeatCooldownMs?: readonly [minimum: number, maximum: number];
readonly maximumCasts?: number;
readonly damageMultiplier: number;
/** Source-shaped RuneWaker formula evidence for imported damage mechanics. */
readonly runewakerFormula?: RunewakerDamageFormulaSource;
readonly radius?: number;
readonly telegraph?: EnemyAttackTelegraph;
readonly effect?: EnemyAbilityEffect;
readonly condition?: EnemyAbilityCondition;
readonly source?: EnemyAbilitySource;
}
export interface EnemyCombatDefinition {
readonly serverEntry?: number;
/** RuneWaker NPC source identity; independent from AzerothCore creature entries. */
readonly runewakerNpcId?: number;
readonly level?: number;
readonly healthMultiplier?: number;
readonly damageMultiplier?: number;
readonly moveSpeed?: number;
readonly aggroRange?: number;
readonly leashRange?: number;
/** Stable special-attack ids available for future encounter loadout randomization. */
readonly mechanicIds?: readonly string[];
readonly attacks: readonly EnemyAttackDefinition[];
}
export const DEFAULT_ENEMY_ATTACK: EnemyAttackDefinition = Object.freeze({
id: "basic-attack",
name: "Basic Attack",
delivery: "melee",
target: "primary",
school: "physical",
animation: "attack",
range: 4.5,
cooldownMs: 1_900,
damageMultiplier: 1,
});
function finiteAtLeast(value: number | undefined, fallback: number, minimum: number): number {
return Number.isFinite(value) ? Math.max(minimum, value!) : fallback;
}
function normalizeCooldownRange(
value: readonly [number, number] | undefined,
fallback: number,
minimum: number,
): readonly [number, number] {
const lower = finiteAtLeast(value?.[0], fallback, minimum);
const upper = finiteAtLeast(value?.[1], lower, lower);
return Object.freeze([Math.round(lower), Math.round(upper)]);
}
function harmfulEnemyAttack(value: EnemyAttackDefinition): boolean {
if (!value.effect) return true;
if (value.effect.kind === "composite") {
return value.effect.effects.some((effect) => (
effect.kind === "damage"
|| effect.kind === "dot"
|| effect.kind === "leech"
|| effect.kind === "control"
|| effect.kind === "target-aura"
|| effect.kind === "pull"
));
}
return value.effect.kind === "damage"
|| value.effect.kind === "dot"
|| value.effect.kind === "leech"
|| value.effect.kind === "control"
|| value.effect.kind === "target-aura"
|| value.effect.kind === "pull";
}
/**
* Legacy/imported encounters receive safe defaults while authored mechanics can
* override every dimension in the modular mechanic catalog.
*/
export function inferEnemyAttackTelegraph(
value: EnemyAttackDefinition,
): EnemyAttackTelegraph | undefined {
if (value.id === "basic-attack" || !harmfulEnemyAttack(value)) return undefined;
const radius = value.radius ?? Math.max(3, value.range * 0.35);
if (/\b(?:breath|cleave|frontal|sweep|cone)\b/i.test(value.name)) {
return {
shape: "cone",
anchor: "source",
warningMs: 1_650,
length: Math.max(5.5, value.range),
angleDegrees: /breath/i.test(value.name) ? 70 : 95,
};
}
if (/\b(?:beam|charge|line|lance)\b/i.test(value.name)) {
return {
shape: "line",
anchor: "source",
warningMs: 1_650,
length: Math.max(7, value.range),
width: Math.max(2, radius * 0.45),
};
}
if (value.delivery === "area" || value.radius !== undefined) {
const targetAnchored = /\b(?:blizzard|rain|barrage|volley|eruption|ground|meteor)\b/i
.test(value.name) && value.target !== "self";
return {
shape: "circle",
anchor: targetAnchored ? "target" : "source",
warningMs: 1_650,
radius,
};
}
return undefined;
}
function normalizeTelegraph(value: EnemyAttackTelegraph): EnemyAttackTelegraph {
const warningMs = Math.round(finiteAtLeast(value.warningMs, 1_650, 250));
if (value.shape === "circle") {
return Object.freeze({
shape: value.shape,
anchor: value.anchor,
warningMs,
radius: finiteAtLeast(value.radius, 4, 0.5),
});
}
if (value.shape === "cone") {
return Object.freeze({
shape: value.shape,
anchor: "source",
warningMs,
length: finiteAtLeast(value.length, 8, 1),
angleDegrees: Math.min(180, finiteAtLeast(value.angleDegrees, 90, 5)),
});
}
return Object.freeze({
shape: value.shape,
anchor: "source",
warningMs,
length: finiteAtLeast(value.length, 8, 1),
width: finiteAtLeast(value.width, 3, 0.5),
});
}
export function normalizeEnemyAttack(
value: EnemyAttackDefinition,
fallback: EnemyAttackDefinition = DEFAULT_ENEMY_ATTACK,
runewakerNpcId?: number,
): EnemyAttackDefinition {
const cooldownMs = Math.round(finiteAtLeast(value.cooldownMs, fallback.cooldownMs, 1));
const telegraph = value.telegraph ?? inferEnemyAttackTelegraph(value);
const inferredCastTimeMs = value.castTimeMs === undefined && value.animation === "cast"
? (fallback.castTimeMs ?? 1_500)
: telegraph?.warningMs ?? fallback.castTimeMs ?? 0;
const castTimeMs = Math.max(
Math.round(finiteAtLeast(value.castTimeMs, inferredCastTimeMs, 0)),
telegraph?.warningMs ?? 0,
);
const runewakerFormula = normalizeRunewakerDamageFormula(value.runewakerFormula);
const damageOrigin = value.damageOrigin ?? (runewakerFormula || value.id.startsWith("rw-") ? "runewaker" : "wow335");
const sourceType = value.spellId ? enemyDamageTypeSourceForSpell(damageOrigin, value.spellId) : undefined;
// Script/control placeholder spell IDs must not override authored damage packets.
const sourceHasDamage = sourceType && ("components" in sourceType
? sourceType.components.length > 0 : sourceType.damageEffects.length > 0);
const damageType = resolveEnemyDamageType({ ...value,
...(sourceHasDamage ? { origin: damageOrigin,
componentId: value.runewakerComponentId ?? runewakerFormula?.componentId } : {}),
});
const periodicSources = damageOrigin === "runewaker" && value.spellId
? romNativeEnemyPeriodicDamageSources(value.spellId, runewakerNpcId) : [];
const uniquePeriodicSources = periodicSources.filter((source, index, all) => all.findIndex((entry) =>
entry.sourceSkillId === source.sourceSkillId && entry.component.componentId === source.component.componentId) === index);
const periodicEffects: EnemyAtomicAbilityEffect[] = uniquePeriodicSources.map((source) => ({
kind: "dot", multiplier: value.damageMultiplier,
ticks: Math.max(1, Math.trunc(source.component.effectTimeSeconds / source.component.dotTimeSeconds)),
intervalMs: source.component.dotTimeSeconds * 1_000,
sourceSkillId: source.sourceSkillId, componentId: source.component.componentId,
}));
const baseEffect = value.effect ?? { kind: "damage" as const, multiplier: value.damageMultiplier };
const existingEffects = baseEffect.kind === "composite" ? baseEffect.effects : [baseEffect];
const directEffects = (runewakerFormula?.powerKind === "dot-only" ? [] : existingEffects)
.filter((effect) => !(effect.kind === "dot" && periodicEffects.some((periodic) => periodic.kind === "dot"
&& periodic.componentId === effect.componentId && periodic.sourceSkillId === effect.sourceSkillId)));
const resolvedEffect = periodicEffects.length
? { kind: "composite" as const, effects: [...directEffects, ...periodicEffects] }
: value.effect;
return Object.freeze({
id: value.id.trim() || fallback.id,
name: value.name.trim() || fallback.name,
...(Number.isInteger(value.spellId) && value.spellId! > 0
? { spellId: value.spellId }
: {}),
delivery: value.delivery,
target: value.target,
school: damageType.school ?? value.school,
damageOrigin,
...(damageType.attackKind ? { attackKind: damageType.attackKind } : {}),
...(damageType.damageClass !== undefined ? { damageClass: damageType.damageClass } : {}),
...(damageType.schoolMask !== undefined ? { schoolMask: damageType.schoolMask } : {}),
...(damageType.sourceDamageClass === "magical" ? { runewakerEffectType: 0 }
: damageType.sourceDamageClass === "physical" ? { runewakerEffectType: 1 }
: value.runewakerEffectType !== undefined ? { runewakerEffectType: value.runewakerEffectType } : {}),
...(value.runewakerComponentId ? { runewakerComponentId: value.runewakerComponentId } : {}),
animation: value.animation,
castTimeMs,
interruptible: value.interruptible ?? fallback.interruptible ?? true,
range: finiteAtLeast(value.range, fallback.range, 0),
cooldownMs,
...(value.initialCooldownMs
? { initialCooldownMs: normalizeCooldownRange(value.initialCooldownMs, cooldownMs, 0) }
: {}),
...(value.repeatCooldownMs
? { repeatCooldownMs: normalizeCooldownRange(value.repeatCooldownMs, cooldownMs, 1) }
: {}),
...(Number.isInteger(value.maximumCasts) && value.maximumCasts! > 0
? { maximumCasts: value.maximumCasts }
: {}),
damageMultiplier: finiteAtLeast(value.damageMultiplier, fallback.damageMultiplier, 0),
...(runewakerFormula ? { runewakerFormula } : {}),
...(value.radius !== undefined
? { radius: finiteAtLeast(value.radius, 0, 0) }
: {}),
...(telegraph ? { telegraph: normalizeTelegraph(telegraph) } : {}),
...(resolvedEffect ? { effect: resolvedEffect } : {}),
...(value.condition ? { condition: value.condition } : {}),
...(value.source ? { source: value.source } : {}),
});
}
export function normalizeEnemyAttacks(
attacks: readonly EnemyAttackDefinition[] | undefined,
fallback: EnemyAttackDefinition = DEFAULT_ENEMY_ATTACK,
runewakerNpcId?: number,
): readonly EnemyAttackDefinition[] {
const source = attacks?.length ? attacks : [fallback];
const normalized = source.map((attack) => normalizeEnemyAttack(attack, fallback, runewakerNpcId));
const seen = new Set<string>();
return Object.freeze(normalized.filter((attack) => {
if (seen.has(attack.id)) return false;
seen.add(attack.id);
return true;
}));
}
export function maximumEnemyAttackRange(attacks: readonly EnemyAttackDefinition[]): number {
return attacks.reduce((maximum, attack) => Math.max(maximum, attack.range), 0);
}