import type { AuraDefinition, AuraModifier, DamageSchool, DispelCategory } from "./combatAuras"; import { applyAura, type ActiveAura } from "./combatAuras"; import { enemyStanceContract, supportedEnemyStanceAuraDefinition } from "./enemyStances"; import { enemyBloodlustContract } from "./enemyBloodlust"; import { enemyWeaponDebuffAuraDefinition, enemyWeaponDebuffContract } from "./enemyWeaponDebuffs"; /** Spell.dbc profile selected by the dungeon recipe. */ export type EnemyAuraSourceProfile = "ascension" | "epoch"; export interface EnemyAuraEffectSignature { readonly effectType: number; readonly auraType: number; readonly basePoints: number; readonly mechanic: number; readonly implicitTargetA: number; readonly implicitTargetB: number; readonly radius: number; readonly miscValue: number; readonly periodMs: number; } export interface EnemyAuraSpellSignature { readonly rangeIndex: number; readonly schoolMask: number; readonly damageClass: number; /** The raw duration used by the compiler, including -1 for permanent. */ readonly durationMs: number; readonly effects: readonly EnemyAuraEffectSignature[]; } export type EnemyAuraTargetGeometry = | { readonly kind: "self" } | { readonly kind: "explicit-friendly"; readonly rangeYards: number } | { readonly kind: "explicit-hostile"; readonly rangeYards: number } | { readonly kind: "source-area-enemy"; readonly radiusYards: number } | { readonly kind: "caster-area-party"; readonly radiusYards: number }; export interface UnsupportedEnemyAuraEffect { readonly auraType: number; readonly basePoints: number; readonly miscValue: number; readonly reason: string; } export interface EnemySourceAuraContract { readonly spellId: number; readonly name: string; readonly profile: EnemyAuraSourceProfile; readonly signature: EnemyAuraSpellSignature; /** Runtime duration; null means until replacement or explicit removal. */ readonly durationMs: number | null; readonly targetGeometry: EnemyAuraTargetGeometry; readonly disposition: "buff" | "debuff"; readonly dispelCategory?: DispelCategory; /** DBC stackAmount is zero on these rows; no multi-stack behavior is sourced. */ readonly stackAmount: number; /** Child spells are not present in the selected Spell.dbc effects or linked-spell rows. */ readonly childTriggerSpellIds: readonly number[]; /** Exact mechanics this runtime can apply through ActiveAura modifiers. */ readonly modifiers: readonly AuraModifier[]; /** Sourced non-numeric state, such as disabling one weapon channel. */ readonly utilityTags?: readonly string[]; /** Source effects that remain intentionally unmodeled. */ readonly unsupportedEffects: readonly UnsupportedEnemyAuraEffect[]; } export const ENEMY_AURA_SOURCE_PINS = Object.freeze({ ascension: Object.freeze({ spellDbcPath: "../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc", spellDbcSha256: "2f77f18b91ffbae32a19568e4bc05ec78ee384b6ba8dd2a6e05f0e97abbb2dce", supportTables: Object.freeze({ source: "shared patch-S SpellDuration.dbc and SpellRadius.dbc fallback", durationDbcSha256: "eeae44ef0185924f3d6ec57ab2701c66566db86e51ab0bae017a9449126eb3a0", radiusDbcSha256: "dba6ed2f165f75c84dda5f32ae46b1a01fe562efd88fa51fd19365705c3e6a44", }), }), epoch: Object.freeze({ spellDbcPath: "../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc", spellDbcSha256: "d5cce1a83550dcfa9eb2f0251dbb11fd24c272534b2b1a9b230924a44d817ab3", supportTables: Object.freeze({ source: "shared patch-S SpellDuration.dbc and SpellRadius.dbc fallback", durationDbcSha256: "eeae44ef0185924f3d6ec57ab2701c66566db86e51ab0bae017a9449126eb3a0", radiusDbcSha256: "dba6ed2f165f75c84dda5f32ae46b1a01fe562efd88fa51fd19365705c3e6a44", }), }), }); const effect = ( effectType: number, auraType: number, basePoints: number, implicitTargetA: number, implicitTargetB: number, radius: number, miscValue: number, ): EnemyAuraEffectSignature => ({ effectType, auraType, basePoints, mechanic: 0, implicitTargetA, implicitTargetB, radius, miscValue, periodMs: 0, }); const signature = ( durationMs: number, effects: readonly EnemyAuraEffectSignature[], damageClass = 0, ): EnemyAuraSpellSignature => ({ rangeIndex: 1, schoolMask: 1, damageClass, durationMs, effects, }); const self = Object.freeze({ kind: "self" } as const); const stanceForm = (form: number): UnsupportedEnemyAuraEffect => Object.freeze({ auraType: 36, basePoints: 0, miscValue: form, reason: "MOD_SHAPESHIFT selects a stance/form slot; the form's stance-specific behavior is not represented as a damage modifier.", }); const blockChance = Object.freeze({ kind: "stat", stat: "block", operation: "flat", value: 75, } as const); const CONTRACTS: Readonly>>> = Object.freeze({ ascension: Object.freeze({ 8599: Object.freeze({ spellId: 8599, name: "Enrage", profile: "ascension", signature: signature(120_000, [ effect(6, 79, 30, 1, 0, 0, 1), effect(6, 138, 30, 1, 0, 0, 0), ]), durationMs: 120_000, targetGeometry: self, disposition: "buff", dispelCategory: "enrage", stackAmount: 0, childTriggerSpellIds: Object.freeze([]), modifiers: Object.freeze([ Object.freeze({ kind: "damage", direction: "dealt", school: "physical", operation: "percent", value: 0.30 }), Object.freeze({ kind: "stat", stat: "melee-haste", operation: "flat", value: 30 }), ]), unsupportedEffects: Object.freeze([]), }), 7164: Object.freeze({ spellId: 7164, name: "Defensive Stance", profile: "ascension", signature: signature(180_000, [effect(6, 36, 0, 1, 0, 0, 18)]), durationMs: 180_000, targetGeometry: self, disposition: "buff", stackAmount: 0, childTriggerSpellIds: Object.freeze([]), modifiers: Object.freeze([]), unsupportedEffects: Object.freeze([stanceForm(18)]), }), 7165: Object.freeze({ spellId: 7165, name: "Battle Stance", profile: "ascension", signature: signature(-1, [effect(6, 36, 0, 1, 0, 0, 17)], 2), durationMs: null, targetGeometry: self, disposition: "buff", stackAmount: 0, childTriggerSpellIds: Object.freeze([]), modifiers: Object.freeze([]), unsupportedEffects: Object.freeze([stanceForm(17)]), }), 16244: Object.freeze({ spellId: 16244, name: "Demoralizing Shout", profile: "ascension", signature: signature(15_000, [effect(6, 79, -10, 22, 15, 10, 1)], 1), durationMs: 15_000, targetGeometry: Object.freeze({ kind: "source-area-enemy", radiusYards: 10 }), disposition: "debuff", stackAmount: 0, childTriggerSpellIds: Object.freeze([]), modifiers: Object.freeze([ Object.freeze({ kind: "damage", direction: "dealt", school: "physical", operation: "percent", value: -0.10 }), ]), unsupportedEffects: Object.freeze([]), }), 13730: Object.freeze({ spellId: 13730, name: "Demoralizing Shout", profile: "ascension", signature: signature(30_000, [effect(6, 166, -15, 22, 15, 10, 0)], 1), durationMs: 30_000, targetGeometry: Object.freeze({ kind: "source-area-enemy", radiusYards: 10 }), disposition: "debuff", stackAmount: 0, childTriggerSpellIds: Object.freeze([]), modifiers: Object.freeze([ Object.freeze({ kind: "stat", stat: "attack-power", operation: "percent", value: -0.15 }), ]), unsupportedEffects: Object.freeze([]), }), 9128: Object.freeze({ spellId: 9128, name: "Battle Shout", profile: "ascension", signature: signature(120_000, [effect(6, 79, 15, 20, 0, 20, 1)], 1), durationMs: 120_000, targetGeometry: Object.freeze({ kind: "caster-area-party", radiusYards: 20 }), disposition: "buff", stackAmount: 0, childTriggerSpellIds: Object.freeze([]), modifiers: Object.freeze([ Object.freeze({ kind: "damage", direction: "dealt", school: "physical", operation: "percent", value: 0.15 }), ]), unsupportedEffects: Object.freeze([]), }), 3419: Object.freeze({ spellId: 3419, name: "Improved Blocking", profile: "ascension", signature: signature(6_000, [effect(6, 51, 75, 1, 0, 0, 0)]), durationMs: 6_000, targetGeometry: self, disposition: "buff", stackAmount: 0, childTriggerSpellIds: Object.freeze([]), modifiers: Object.freeze([blockChance]), unsupportedEffects: Object.freeze([]), }), }), epoch: Object.freeze({ 8599: Object.freeze({ spellId: 8599, name: "Enrage", profile: "epoch", signature: signature(120_000, [ effect(6, 79, 10, 1, 0, 0, 1), effect(6, 138, 30, 1, 0, 0, 0), ]), durationMs: 120_000, targetGeometry: self, disposition: "buff", dispelCategory: "enrage", stackAmount: 0, childTriggerSpellIds: Object.freeze([]), modifiers: Object.freeze([ Object.freeze({ kind: "damage", direction: "dealt", school: "physical", operation: "percent", value: 0.10 }), Object.freeze({ kind: "stat", stat: "melee-haste", operation: "flat", value: 30 }), ]), unsupportedEffects: Object.freeze([]), }), 7164: Object.freeze({ spellId: 7164, name: "Defensive Stance", profile: "epoch", signature: signature(180_000, [effect(6, 36, 0, 1, 0, 0, 18)]), durationMs: 180_000, targetGeometry: self, disposition: "buff", stackAmount: 0, childTriggerSpellIds: Object.freeze([]), modifiers: Object.freeze([]), unsupportedEffects: Object.freeze([stanceForm(18)]), }), 7165: Object.freeze({ spellId: 7165, name: "Battle Stance", profile: "epoch", signature: signature(-1, [effect(6, 36, 0, 1, 0, 0, 17)], 2), durationMs: null, targetGeometry: self, disposition: "buff", stackAmount: 0, childTriggerSpellIds: Object.freeze([]), modifiers: Object.freeze([]), unsupportedEffects: Object.freeze([stanceForm(17)]), }), 16244: Object.freeze({ spellId: 16244, name: "Demoralizing Shout", profile: "epoch", signature: signature(30_000, [effect(6, 99, -20, 22, 15, 10, 0)], 1), durationMs: 30_000, targetGeometry: Object.freeze({ kind: "source-area-enemy", radiusYards: 10 }), disposition: "debuff", stackAmount: 0, childTriggerSpellIds: Object.freeze([]), modifiers: Object.freeze([ Object.freeze({ kind: "stat", stat: "attack-power", operation: "flat", value: -20 }), ]), unsupportedEffects: Object.freeze([]), }), 13730: Object.freeze({ spellId: 13730, name: "Demoralizing Shout", profile: "epoch", signature: signature(30_000, [effect(6, 99, -10, 22, 15, 10, 0)], 1), durationMs: 30_000, targetGeometry: Object.freeze({ kind: "source-area-enemy", radiusYards: 10 }), disposition: "debuff", stackAmount: 0, childTriggerSpellIds: Object.freeze([]), modifiers: Object.freeze([ Object.freeze({ kind: "stat", stat: "attack-power", operation: "flat", value: -10 }), ]), unsupportedEffects: Object.freeze([]), }), 9128: Object.freeze({ spellId: 9128, name: "Battle Shout", profile: "epoch", signature: signature(120_000, [effect(6, 99, 4, 20, 0, 20, 0)], 1), durationMs: 120_000, targetGeometry: Object.freeze({ kind: "caster-area-party", radiusYards: 20 }), disposition: "buff", stackAmount: 0, childTriggerSpellIds: Object.freeze([]), modifiers: Object.freeze([ Object.freeze({ kind: "stat", stat: "attack-power", operation: "flat", value: 4 }), ]), unsupportedEffects: Object.freeze([]), }), 3419: Object.freeze({ spellId: 3419, name: "Improved Blocking", profile: "epoch", signature: signature(6_000, [effect(6, 51, 75, 1, 0, 0, 0)]), durationMs: 6_000, targetGeometry: self, disposition: "buff", stackAmount: 0, childTriggerSpellIds: Object.freeze([]), modifiers: Object.freeze([blockChance]), unsupportedEffects: Object.freeze([]), }), }), }); /** Return the reviewed source signature and supported portion for one profile. */ export function enemySourceAuraContract( spellId: number, profile: EnemyAuraSourceProfile, ): EnemySourceAuraContract | undefined { if (spellId === 6742) return enemyBloodlustContract(profile); const debuff = enemyWeaponDebuffContract(spellId, profile); if (debuff) return { ...debuff, targetGeometry: { kind: "explicit-hostile", rangeYards: 5 }, disposition: "debuff", childTriggerSpellIds: [], }; const contract = CONTRACTS[profile]?.[spellId]; const stance = enemyStanceContract(spellId, profile); return contract && stance ? { ...contract, modifiers: stance.modifiers, unsupportedEffects: stance.unsupportedEffects } : contract; } /** Build the subset that existing ActiveAura stat and damage resolvers can apply. */ export function supportedEnemySourceAuraDefinition( contract: EnemySourceAuraContract, ): AuraDefinition | undefined { const debuff = enemyWeaponDebuffContract(contract.spellId, contract.profile); if (debuff) return enemyWeaponDebuffAuraDefinition(debuff); const stance = enemyStanceContract(contract.spellId, contract.profile); if (stance) return supportedEnemyStanceAuraDefinition(stance); if (!contract.modifiers.length && !contract.utilityTags?.length) return undefined; return { id: `enemy-source-spell-${contract.spellId}`, name: contract.name, disposition: contract.disposition, durationMs: contract.durationMs, stackBehavior: "refresh", ...(contract.dispelCategory ? { dispelCategory: contract.dispelCategory } : {}), ...(contract.utilityTags?.length ? { utilityTags: contract.utilityTags } : {}), modifiers: contract.modifiers.map((modifier) => modifier.operation === "percent" || modifier.kind === "stat" && modifier.stat === "melee-haste" ? { ...modifier, multiplicative: true } : modifier), }; } /** Nonperiodic copies of the same source spell do not stack across casters. */ export function applyEnemySourceAura( current: readonly ActiveAura[], definition: AuraDefinition, sourceId: string, targetId: string, now: number, ): readonly ActiveAura[] { const other = current.find((aura) => aura.targetId === targetId && aura.definition.id === definition.id && aura.sourceId !== sourceId && (aura.expiresAt === null || aura.expiresAt > now)); // A second area-aura caster cannot refresh an existing recipient's aura. if (other && targetId !== sourceId) return current; const base = current.filter((aura) => aura.targetId !== targetId || aura.definition.id !== definition.id || aura.sourceId === sourceId); return applyAura(base, { definition, sourceId, targetId, now }).auras; } /** Timed direct utility casts on another actor outlive their removed caster. */ const PERSISTENT_TIMED_ENEMY_CONTROL_AURA_IDS = new Set([ "enemy-control:source-spell-20511", "enemy-control:source-spell-29544", "enemy-control:source-spell-5246", "enemy-control:source-spell-19134", ]); export function removeEnemyActorAuras( current: readonly ActiveAura[], actorId: string, ): readonly ActiveAura[] { return current.filter((aura) => aura.targetId !== actorId && ( aura.sourceId !== actorId || aura.definition.id === "enemy-source-spell-6742" || aura.definition.id === "enemy-source-spell-6713" || aura.definition.id === "enemy-source-spell-17230" || PERSISTENT_TIMED_ENEMY_CONTROL_AURA_IDS.has(aura.definition.id) )); } /** Direct Enrage/shout modifiers use physical mask 1; stances resolve mask 127 in enemyStances. */ export function enemySourceDamageSchoolForMask(mask: number): DamageSchool | null { return mask === 1 ? "physical" : null; }