434 lines
17 KiB
TypeScript
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);
|
|
}
|