Update Healer Man server
This commit is contained in:
1 parent
6114a2295f
commit
69809f7228
593 files changed
+903643
-40774
No files matched your search
@@ -0,0 +1,394 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in new issue
Block a user