import type { ActiveAura } from "./combatAuras"; import type { ItemStats, WeaponStats } from "./itemStats"; import type { EnemyDisarmAbilityRequirement, EnemyDisarmEquippedItem, EnemyDisarmTargetWeapons, EnemyDisarmWeaponChannel, EnemyWeaponDebuffProfile, } from "./enemyWeaponDebuffs"; const DISARM_TAG_PREFIX = "enemy-disarm:"; export interface DisarmLoadoutItem { readonly weaponType?: string | null; readonly inventorySlot?: string; readonly armorType?: string | null; readonly stats?: Pick; } export interface DisarmWeaponLoadout { readonly "main-hand"?: DisarmLoadoutItem | null; readonly "off-hand"?: DisarmLoadoutItem | null; readonly ranged?: DisarmLoadoutItem | null; } export interface ActiveEnemyDisarm { readonly profile: EnemyWeaponDebuffProfile; readonly channels: ReadonlySet; } export interface EnemyDisarmItemDependentAuraRequirement extends EnemyDisarmAbilityRequirement { /** AuraDefinition.id produced by the source ability. */ readonly auraDefinitionId: string; readonly passive: boolean; } export interface EnemyDisarmSpellWeaponMetadata { /** Spell.dbc DmgClass: 2 melee, 3 ranged; other classes do not select a weapon channel here. */ readonly damageClass: number; /** Spell.dbc AttributesEx: USES_RANGED_SLOT. */ readonly attributes0?: number; /** Spell.dbc AttributesEx3: required off-hand selects the off-hand attack. */ readonly attributes3?: number; /** Spell.dbc EquippedItemSubClassMask, used by core IsRangedWeaponSpell. */ readonly equippedItemSubclassMask?: number; /** Core's Hunter-family exception or a pre-resolved IsRangedWeaponSpell value. */ readonly isRangedWeaponSpell?: boolean; } const CHANNELS: readonly EnemyDisarmWeaponChannel[] = ["main-hand", "off-hand", "ranged"]; const SPELL_DAMAGE_CLASS_MELEE = 2; const SPELL_DAMAGE_CLASS_RANGED = 3; const SPELL_ATTR0_USES_RANGED_SLOT = 0x00000002; const SPELL_ATTR3_REQUIRES_OFF_HAND_WEAPON = 0x01000000; const RANGED_WEAPON_SUBCLASS_MASK = (1 << 2) | (1 << 3) | (1 << 16) | (1 << 18); /** AzerothCore's disarmed player base range and BASE_ATTACK_TIME. */ export const ENEMY_DISARM_UNARMED_MAIN_HAND: Readonly = Object.freeze({ damageMin: 1, damageMax: 2, damageSchool: "physical", speedMs: 2_000, }); /** * Read only exact tags emitted by the pinned source-backed 6713 contract. * Unknown tags/profiles are ignored so native RoM state cannot be mistaken * for a WoW disarm. */ export function activeEnemyDisarms( auras: readonly ActiveAura[], targetId: string, now: number, ): readonly ActiveEnemyDisarm[] { const byProfile = new Map>(); for (const aura of auras) { if (aura.targetId !== targetId || (aura.expiresAt !== null && aura.expiresAt <= now)) continue; for (const tag of aura.definition.utilityTags ?? []) { if (!tag.startsWith(DISARM_TAG_PREFIX)) continue; const [, profile, channelName] = tag.split(":"); if ((profile !== "ascension" && profile !== "epoch") || (channelName !== "main-hand" && channelName !== "off-hand" && channelName !== "ranged")) continue; const channels = byProfile.get(profile) ?? new Set(); channels.add(channelName); byProfile.set(profile, channels); } } return [...byProfile].map(([profile, channels]) => ({ profile, channels })); } /** The union of only currently active, recognized source Disarm channels. */ export function activeEnemyDisarmChannels( auras: readonly ActiveAura[], targetId: string, now: number, ): ReadonlySet { return new Set(activeEnemyDisarms(auras, targetId, now).flatMap((disarm) => [...disarm.channels])); } export function enemyDisarmBlocksActorChannel( auras: readonly ActiveAura[], targetId: string, now: number, channel: EnemyDisarmWeaponChannel, ): boolean { return activeEnemyDisarmChannels(auras, targetId, now).has(channel); } /** Build the core's usable-target-weapon check from an already-resolved loadout. */ export function enemyDisarmTargetWeaponsFromLoadout( loadout: DisarmWeaponLoadout, feralForm = false, ): EnemyDisarmTargetWeapons { const hasMeleeWeapon = (item: DisarmLoadoutItem | null | undefined): boolean => Boolean( item?.weaponType && item.stats?.meleeWeapon, ); return { mainHand: hasMeleeWeapon(loadout["main-hand"]), offHand: hasMeleeWeapon(loadout["off-hand"]), ranged: Boolean(loadout.ranged?.weaponType && loadout.ranged.stats?.rangedWeapon), feralForm, }; } /** Create a combat-only view matching core's disarmed main-hand punch. */ export function enemyDisarmAdjustedWeaponStats>( gearStats: T, channels: ReadonlySet, feralForm = false, ): T { if ((!channels.has("main-hand") || feralForm) && !channels.has("ranged")) return gearStats; return { ...gearStats, ...(channels.has("main-hand") && !feralForm ? { meleeWeapon: ENEMY_DISARM_UNARMED_MAIN_HAND } : {}), ...(channels.has("ranged") ? { rangedWeapon: null } : {}), }; } /** * Select Spell.cpp's weapon attack type from the source spell fields. Unknown * damage classes return undefined and leave their ordinary spell damage alone. */ export function enemyDisarmAbilityWeaponChannel( spell: EnemyDisarmSpellWeaponMetadata, ): EnemyDisarmWeaponChannel | undefined { if (spell.damageClass === SPELL_DAMAGE_CLASS_MELEE) { return ((spell.attributes3 ?? 0) & SPELL_ATTR3_REQUIRES_OFF_HAND_WEAPON) !== 0 ? "off-hand" : "main-hand"; } if (spell.damageClass !== SPELL_DAMAGE_CLASS_RANGED) return undefined; const isRangedWeaponSpell = spell.isRangedWeaponSpell ?? (((spell.attributes0 ?? 0) & SPELL_ATTR0_USES_RANGED_SLOT) !== 0 || (((spell.equippedItemSubclassMask ?? 0) & RANGED_WEAPON_SUBCLASS_MASK) !== 0)); return isRangedWeaponSpell ? "ranged" : "main-hand"; } /** * Return only the selected weapon's Disarm-adjusted contribution. A main-hand * punch retains source unarmed damage even though the equipped item remains; * disabled off-hand/ranged attacks have no weapon contribution. */ export function enemyDisarmAdjustedAbilityWeapon( weapon: WeaponStats | null | undefined, channel: EnemyDisarmWeaponChannel | undefined, disarmedChannels: ReadonlySet, feralForm = false, ): WeaponStats | null { if (!channel || !disarmedChannels.has(channel)) return weapon ?? null; if (channel === "main-hand") return feralForm ? weapon ?? null : ENEMY_DISARM_UNARMED_MAIN_HAND; return null; } /** * The creature Spell::CheckItems gate is limited to damage-class melee/ranged * spells and their selected attack channel. Player spell casts use different * item-requirement rules and must not call this as a blanket cast restriction. */ export function enemyDisarmBlocksNpcWeaponAbility( auras: readonly ActiveAura[], targetId: string, now: number, spell: EnemyDisarmSpellWeaponMetadata, ): boolean { const channel = enemyDisarmAbilityWeaponChannel(spell); return channel !== undefined && enemyDisarmBlocksActorChannel(auras, targetId, now, channel); } const WEAPON_SUBCLASS: Readonly> = Object.freeze({ axe: 0, bow: 2, gun: 3, mace: 4, polearm: 6, sword: 7, staff: 10, fist: 13, "fist-weapon": 13, dagger: 15, throwing: 16, thrown: 16, spear: 17, crossbow: 18, wand: 19, }); function itemInventoryType(item: DisarmLoadoutItem, channel: EnemyDisarmWeaponChannel): number | undefined { if (item.inventorySlot === "one-hand") return 13; if (item.inventorySlot === "two-hand") return 17; if (item.inventorySlot === "main-hand") return 21; if (item.inventorySlot === "off-hand") return 22; if (item.inventorySlot !== "ranged" && channel !== "ranged") return undefined; if (item.weaponType === "bow" || item.weaponType === "crossbow" || item.weaponType === "gun") return 15; if (item.weaponType === "throwing" || item.weaponType === "thrown") return 25; if (item.weaponType === "wand") return 26; return undefined; } /** Map recognized equipped weapon types to Spell.dbc class-2 subclasses. */ export function enemyDisarmEquippedWeaponItem( channel: EnemyDisarmWeaponChannel, item: DisarmLoadoutItem | null | undefined, ): EnemyDisarmEquippedItem | null { if (!item?.weaponType || !item.stats || (channel === "ranged" ? !item.stats.rangedWeapon : !item.stats.meleeWeapon)) return null; const baseSubclass = WEAPON_SUBCLASS[item.weaponType]; if (baseSubclass === undefined) return null; const subclass = item.weaponType === "axe" && item.inventorySlot === "two-hand" ? 1 : item.weaponType === "mace" && item.inventorySlot === "two-hand" ? 5 : item.weaponType === "sword" && item.inventorySlot === "two-hand" ? 8 : baseSubclass; return { channel, itemClass: 2, subclass, ...(itemInventoryType(item, channel) !== undefined ? { inventoryType: itemInventoryType(item, channel) } : {}), }; } export function enemyDisarmRequirementCandidates( loadout: DisarmWeaponLoadout, ): readonly EnemyDisarmEquippedItem[] { return CHANNELS.flatMap((channel) => { const item = channel === "main-hand" ? loadout["main-hand"] : channel === "off-hand" ? loadout["off-hand"] : loadout.ranged; const candidate = enemyDisarmEquippedWeaponItem(channel, item); return candidate ? [candidate] : []; }); } /** Source profile of one unexpired Disarm that removes every matching item. */ export function enemyDisarmInvalidatingProfile( auras: readonly ActiveAura[], targetId: string, now: number, requirement: EnemyDisarmAbilityRequirement, equippedItems: readonly EnemyDisarmEquippedItem[], ): EnemyWeaponDebuffProfile | undefined { if (requirement.equippedItemClass < 0) return undefined; const match = activeEnemyDisarms(auras, targetId, now).find(({ profile, channels }) => { const matching = equippedItems.filter((item) => equippedItemMatchesRequirement(item, requirement)); return matching.length > 0 && matching.every((item) => channels.has(item.channel)); }); return match?.profile; } function equippedItemMatchesRequirement( item: EnemyDisarmEquippedItem, requirement: EnemyDisarmAbilityRequirement, ): boolean { if (item.itemClass !== requirement.equippedItemClass) return false; if (requirement.equippedItemSubclassMask !== 0 && (!Number.isInteger(item.subclass) || item.subclass < 0 || item.subclass > 31 || ((requirement.equippedItemSubclassMask >>> item.subclass) & 1) === 0)) return false; if ((requirement.equippedItemInventoryMask ?? 0) !== 0 && (!Number.isInteger(item.inventoryType) || item.inventoryType! < 0 || item.inventoryType! > 31 || ((requirement.equippedItemInventoryMask! >>> item.inventoryType!) & 1) === 0)) return false; return true; } /** Checks the equipped item itself; Disarm usability has its separate source exceptions. */ export function enemySpellHasMatchingEquippedItem( requirement: EnemyDisarmAbilityRequirement, equippedItems: readonly EnemyDisarmEquippedItem[], ): boolean { return requirement.equippedItemClass < 0 || equippedItems.some((item) => equippedItemMatchesRequirement(item, requirement)); } /** * Identify self-cast passive item auras whose every matching equipped weapon * is currently disarmed. This is an effective-aura view: the persistent aura * objects stay available so the passive can become effective again when the * timed Disarm expires. Active nonpassive aura spells follow the core's * explicit keep-aura exception. */ export function enemyDisarmItemDependentAuraSuppressionIds( auras: readonly ActiveAura[], casterId: string, now: number, requirements: readonly EnemyDisarmItemDependentAuraRequirement[], loadout: DisarmWeaponLoadout, feralForm = false, ): ReadonlySet { if (feralForm) return new Set(); const disabledChannels = activeEnemyDisarmChannels(auras, casterId, now); if (disabledChannels.size === 0) return new Set(); const byAuraId = new Map(requirements.map((requirement) => [requirement.auraDefinitionId, requirement])); const equippedItems = enemyDisarmRequirementCandidates(loadout); const suppressed = new Set(); for (const aura of auras) { if (aura.sourceId !== casterId || (aura.expiresAt !== null && aura.expiresAt <= now)) continue; const requirement = byAuraId.get(aura.definition.id); if (!requirement?.passive || requirement.equippedItemClass < 0) continue; const matching = equippedItems.filter((item) => equippedItemMatchesRequirement(item, requirement)); if (matching.length > 0 && matching.every((item) => disabledChannels.has(item.channel))) { suppressed.add(aura.instanceId); } } return suppressed; } /** * Filter item-requirement aura effects for one attack channel, matching the * core's attack-type-specific weapon-aura recalculation. The same active aura * may therefore contribute to an enabled off-hand while being absent from a * disarmed main-hand calculation. */ export function enemyDisarmAurasForWeaponChannel( auras: readonly ActiveAura[], targetId: string, now: number, channel: EnemyDisarmWeaponChannel, requirements: readonly EnemyDisarmItemDependentAuraRequirement[], loadout: DisarmWeaponLoadout, feralForm = false, ): readonly ActiveAura[] { if (feralForm) return auras; const channels = activeEnemyDisarmChannels(auras, targetId, now); const byAuraId = new Map(requirements.map((requirement) => [requirement.auraDefinitionId, requirement])); const equippedItems = enemyDisarmRequirementCandidates(loadout); return auras.filter((aura) => { if (aura.targetId !== targetId || (aura.expiresAt !== null && aura.expiresAt <= now)) return true; const requirement = byAuraId.get(aura.definition.id); if (!requirement || requirement.equippedItemClass < 0) return true; return !channels.has(channel) && equippedItems.some((item) => item.channel === channel && equippedItemMatchesRequirement(item, requirement)); }); } export interface EnemyDisarmCastRequirement extends EnemyDisarmAbilityRequirement { readonly attributes3?: number; readonly passive?: boolean; readonly createsAura?: boolean; } const REQUIRES_MAIN_HAND_WEAPON_ATTRIBUTE3 = 0x00000400; const REQUIRES_OFF_HAND_WEAPON_ATTRIBUTE3 = 0x01000000; /** * Mirrors the player-side source cast gate for a DBC class-2 requirement. * Main/off-hand attribute checks are strict slot checks. Generic equipment * checks allow an aura-producing, nonpassive spell to complete when the * matching weapon remains equipped but Disarm makes it unusable. */ export function enemyDisarmBlocksAbilityCast( auras: readonly ActiveAura[], targetId: string, now: number, requirement: EnemyDisarmCastRequirement, equippedItems: readonly EnemyDisarmEquippedItem[], ): boolean { if (requirement.equippedItemClass < 0) return false; const active = activeEnemyDisarms(auras, targetId, now); if (active.length === 0) return false; const matching = equippedItems.filter((item) => equippedItemMatchesRequirement(item, requirement)); if (matching.length === 0) return false; const attributes3 = requirement.attributes3 ?? 0; const requiredSlots = [ ...((attributes3 & REQUIRES_MAIN_HAND_WEAPON_ATTRIBUTE3) !== 0 ? ["main-hand" as const] : []), ...((attributes3 & REQUIRES_OFF_HAND_WEAPON_ATTRIBUTE3) !== 0 ? ["off-hand" as const] : []), ]; if (requiredSlots.length > 0) { return requiredSlots.some((slot) => matching.some((item) => item.channel === slot) && active.some((disarm) => disarm.channels.has(slot))); } const allMatchingItemsDisabled = matching.every((item) => active.some((disarm) => ( disarm.channels.has(item.channel) ))); if (!allMatchingItemsDisabled) return false; if (!requirement.passive && requirement.createsAura) return false; return true; }