404 lines
16 KiB
TypeScript
404 lines
16 KiB
TypeScript
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<ItemStats, "meleeWeapon" | "rangedWeapon">;
|
|
}
|
|
|
|
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<EnemyDisarmWeaponChannel>;
|
|
}
|
|
|
|
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<WeaponStats> = 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<EnemyWeaponDebuffProfile, Set<EnemyDisarmWeaponChannel>>();
|
|
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<EnemyDisarmWeaponChannel>();
|
|
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<EnemyDisarmWeaponChannel> {
|
|
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<T extends Pick<ItemStats, "meleeWeapon" | "rangedWeapon">>(
|
|
gearStats: T,
|
|
channels: ReadonlySet<EnemyDisarmWeaponChannel>,
|
|
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<EnemyDisarmWeaponChannel>,
|
|
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<Record<string, number>> = 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<string> {
|
|
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<string>();
|
|
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;
|
|
}
|