309 lines
14 KiB
TypeScript
309 lines
14 KiB
TypeScript
/** AzerothCore CREATURE_FLAG_EXTRA_NO_BLOCK from CreatureData.h. */
|
|
export const CREATURE_FLAG_EXTRA_NO_BLOCK = 0x00000010;
|
|
|
|
/** AzerothCore Spell.dbc damage-class values used by spell hit resolution. */
|
|
export const SPELL_DAMAGE_CLASS_MAGIC = 1;
|
|
export const SPELL_DAMAGE_CLASS_MELEE = 2;
|
|
export const SPELL_DAMAGE_CLASS_RANGED = 3;
|
|
|
|
/** AzerothCore SpellSchoolMask bit for physical damage. */
|
|
export const SPELL_SCHOOL_MASK_NORMAL = 0x00000001;
|
|
|
|
/** Source-backed spell attributes relevant to blocking ordinary creatures. */
|
|
export const SPELL_ATTR0_NO_ACTIVE_DEFENSE = 0x00200000;
|
|
export const SPELL_ATTR3_COMPLETELY_BLOCKED = 0x00000008;
|
|
export const SPELL_ATTR3_ALWAYS_HIT = 0x00040000;
|
|
export const SPELL_ATTR0_CU_DIRECT_DAMAGE = 0x00000100;
|
|
export const SPELL_ATTR0_CU_REQ_CASTER_BEHIND_TARGET = 0x00020000;
|
|
export const SPELL_ATTR0_CU_BLOCKING_KNOWN_MASK = SPELL_ATTR0_CU_DIRECT_DAMAGE
|
|
| SPELL_ATTR0_CU_REQ_CASTER_BEHIND_TARGET;
|
|
|
|
export type EnemyNpcSpellBlockPhase = "none" | "hit-table" | "direct-damage";
|
|
export type EnemyNpcSpellSourceProfile = "wow335" | "ascension";
|
|
|
|
/**
|
|
* The exact spell scalars needed by the ordinary WoW creature block paths.
|
|
* `customAttributes` is optional because Spell.dbc does not store AzerothCore's
|
|
* computed AttributesCu word. Its absence deliberately prevents inferring that
|
|
* a non-direct spell is eligible for the hit-table full-block result.
|
|
*/
|
|
export interface EnemyNpcSpellDefenseMetadata {
|
|
readonly damageClass: number;
|
|
readonly schoolMask: number;
|
|
readonly attributes0: number;
|
|
/** Raw flags retained for source-proven immunity purge and controlled casting. */
|
|
readonly attributes1?: number;
|
|
readonly attributes3: number;
|
|
readonly attributes5?: number;
|
|
readonly directDamageFromEffects: boolean;
|
|
readonly sourceProfile: EnemyNpcSpellSourceProfile;
|
|
/** Full AzerothCore AttributesCu word, only when imported/reconstructed exactly. */
|
|
readonly customAttributes?: number;
|
|
/** Explicitly proven subset of AttributesCu when the full word is not known. */
|
|
readonly customAttributeEvidence?: {
|
|
readonly knownMask: number;
|
|
readonly value: number;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns the narrowly audited Shield Bash, Kick and Pummel hit-table contract. Its two block-
|
|
* relevant AttributesCu bits are sourced independently; this does not assert
|
|
* that all other custom bits are zero.
|
|
*/
|
|
export function enemyNpcSourceSpellBlockMetadata(
|
|
spellId: number,
|
|
sourceProfile: EnemyNpcSpellSourceProfile,
|
|
): EnemyNpcSpellDefenseMetadata | undefined {
|
|
if (![72, 1766, 6552].includes(spellId) || sourceProfile !== "wow335") return undefined;
|
|
return Object.freeze({
|
|
damageClass: SPELL_DAMAGE_CLASS_MELEE,
|
|
schoolMask: SPELL_SCHOOL_MASK_NORMAL,
|
|
attributes0: 327696,
|
|
attributes3: SPELL_ATTR3_COMPLETELY_BLOCKED,
|
|
directDamageFromEffects: false,
|
|
sourceProfile: "wow335",
|
|
customAttributeEvidence: Object.freeze({
|
|
knownMask: SPELL_ATTR0_CU_BLOCKING_KNOWN_MASK,
|
|
value: 0,
|
|
}),
|
|
});
|
|
}
|
|
|
|
function customAttributeBit(
|
|
spell: EnemyNpcSpellDefenseMetadata,
|
|
bit: number,
|
|
): boolean | null {
|
|
if (spell.customAttributes !== undefined) return (spell.customAttributes & bit) !== 0;
|
|
const evidence = spell.customAttributeEvidence;
|
|
if (!evidence || (evidence.knownMask & bit) !== bit) return null;
|
|
return (evidence.value & bit) !== 0;
|
|
}
|
|
|
|
function isSpellBlockClassAndSchool(spell: EnemyNpcSpellDefenseMetadata): boolean {
|
|
return (spell.damageClass === SPELL_DAMAGE_CLASS_MELEE || spell.damageClass === SPELL_DAMAGE_CLASS_RANGED)
|
|
&& (spell.schoolMask & SPELL_SCHOOL_MASK_NORMAL) !== 0;
|
|
}
|
|
|
|
/**
|
|
* Classifies which AzerothCore block phase a spell can enter for ordinary
|
|
* enemy Creatures. Direct-damage effects are a positive signal for the later
|
|
* damage-reduction phase. Hit-table full blocks require the computed core
|
|
* custom-attribute word so an absent CU_DIRECT_DAMAGE bit is never guessed
|
|
* from a client DBC row alone.
|
|
*/
|
|
export function classifyEnemyNpcSpellBlockPhase(
|
|
spell: EnemyNpcSpellDefenseMetadata,
|
|
): EnemyNpcSpellBlockPhase | null {
|
|
if (!Number.isInteger(spell.damageClass) || spell.damageClass < 0 || spell.damageClass > 3
|
|
|| !Number.isInteger(spell.schoolMask) || spell.schoolMask < 0 || spell.schoolMask > 0xFFFFFFFF
|
|
|| !Number.isInteger(spell.attributes0) || spell.attributes0 < 0 || spell.attributes0 > 0xFFFFFFFF
|
|
|| !Number.isInteger(spell.attributes3) || spell.attributes3 < 0 || spell.attributes3 > 0xFFFFFFFF
|
|
|| typeof spell.directDamageFromEffects !== "boolean"
|
|
|| (spell.sourceProfile !== "wow335" && spell.sourceProfile !== "ascension")) return null;
|
|
|
|
const hasCustomAttributes = spell.customAttributes !== undefined;
|
|
if (hasCustomAttributes && (!Number.isInteger(spell.customAttributes)
|
|
|| spell.customAttributes! < 0 || spell.customAttributes! > 0xFFFFFFFF)) return null;
|
|
const customEvidence = spell.customAttributeEvidence;
|
|
const invalidCustomEvidence = customEvidence && (!Number.isInteger(customEvidence.knownMask)
|
|
|| customEvidence.knownMask < 0 || customEvidence.knownMask > 0xFFFFFFFF
|
|
|| !Number.isInteger(customEvidence.value) || customEvidence.value < 0
|
|
|| customEvidence.value > 0xFFFFFFFF
|
|
|| (((customEvidence.value & ~customEvidence.knownMask) >>> 0) !== 0)
|
|
|| (hasCustomAttributes
|
|
&& (((spell.customAttributes! & customEvidence.knownMask) >>> 0) !== customEvidence.value)));
|
|
if (invalidCustomEvidence) return null;
|
|
|
|
// Both source paths bypass blocking for these attributes.
|
|
if ((spell.attributes0 & SPELL_ATTR0_NO_ACTIVE_DEFENSE) !== 0
|
|
|| (spell.attributes3 & SPELL_ATTR3_ALWAYS_HIT) !== 0) return "none";
|
|
|
|
const directDamageAttribute = customAttributeBit(spell, SPELL_ATTR0_CU_DIRECT_DAMAGE);
|
|
const hasDirectDamageAttribute = spell.directDamageFromEffects || directDamageAttribute === true;
|
|
if (hasDirectDamageAttribute) {
|
|
return isSpellBlockClassAndSchool(spell) ? "direct-damage" : "none";
|
|
}
|
|
|
|
// AttributesCu is assembled by AzerothCore from effects/corrections/SQL and
|
|
// is not a client Spell.dbc field. Without that exact word, fail closed.
|
|
if (directDamageAttribute !== false) return "none";
|
|
if ((spell.damageClass === SPELL_DAMAGE_CLASS_MELEE || spell.damageClass === SPELL_DAMAGE_CLASS_RANGED)
|
|
&& (spell.attributes3 & SPELL_ATTR3_COMPLETELY_BLOCKED) !== 0) return "hit-table";
|
|
return "none";
|
|
}
|
|
|
|
/**
|
|
* Skill-adjusted chance for the two spell paths. The hit-table path truncates
|
|
* the raw chance to integer hundredths and subtracts skill difference. The
|
|
* later direct-damage path follows isSpellBlocked's float formula and adds
|
|
* skill difference instead.
|
|
*/
|
|
export function enemyNpcSpellBlockChanceAfterSkill(input: {
|
|
readonly phase: Exclude<EnemyNpcSpellBlockPhase, "none">;
|
|
readonly blockPercent: number;
|
|
readonly attackerWeaponSkill: number;
|
|
readonly defenderLevel: number;
|
|
}): number | null {
|
|
if (input.phase !== "hit-table" && input.phase !== "direct-damage") return null;
|
|
if (!Number.isFinite(input.blockPercent) || input.blockPercent < 0
|
|
|| !Number.isSafeInteger(input.attackerWeaponSkill) || input.attackerWeaponSkill < 0
|
|
|| !Number.isInteger(input.defenderLevel) || input.defenderLevel < 1 || input.defenderLevel > 80) return null;
|
|
|
|
const skillDiff = input.attackerWeaponSkill - input.defenderLevel * 5;
|
|
if (input.phase === "direct-damage") {
|
|
return Math.max(0, input.blockPercent + skillDiff * 0.04);
|
|
}
|
|
|
|
return Math.max(0, Math.trunc(input.blockPercent * 100) - skillDiff * 4) / 100;
|
|
}
|
|
|
|
/**
|
|
* Applies source-backed hit/block gates for an ordinary enemy Creature spell.
|
|
* Direct-damage spells use the post-hit physical reduction phase; explicitly
|
|
* blockable non-direct spells use the separate hit-table phase. `attackerWeaponSkill`
|
|
* is required because player weapon skill cannot be recovered from spell DBC
|
|
* damage class or equipped-item requirements.
|
|
*/
|
|
export function canEnemyNpcBlockSpell(input: {
|
|
readonly spell: EnemyNpcSpellDefenseMetadata;
|
|
readonly blockPercent: number;
|
|
readonly flagsExtra: number;
|
|
readonly isTotem: boolean;
|
|
readonly attackerWeaponSkill: number;
|
|
readonly defenderLevel: number;
|
|
readonly attackerIsBehind: boolean;
|
|
readonly ignoresHitDirection: boolean;
|
|
readonly isCastingNonMeleeSpell: boolean;
|
|
readonly isControlled: boolean;
|
|
}): boolean {
|
|
if (!Number.isFinite(input.blockPercent) || input.blockPercent < 0
|
|
|| !Number.isInteger(input.flagsExtra) || input.flagsExtra < 0 || input.flagsExtra > 0xFFFFFFFF
|
|
|| typeof input.isTotem !== "boolean"
|
|
|| !Number.isSafeInteger(input.attackerWeaponSkill) || input.attackerWeaponSkill < 0
|
|
|| !Number.isInteger(input.defenderLevel) || input.defenderLevel < 1 || input.defenderLevel > 80
|
|
|| typeof input.attackerIsBehind !== "boolean" || typeof input.ignoresHitDirection !== "boolean"
|
|
|| typeof input.isCastingNonMeleeSpell !== "boolean" || typeof input.isControlled !== "boolean") return false;
|
|
|
|
const phase = classifyEnemyNpcSpellBlockPhase(input.spell);
|
|
if (phase === null || phase === "none") return false;
|
|
if (input.isTotem || (input.flagsExtra & CREATURE_FLAG_EXTRA_NO_BLOCK) !== 0) return false;
|
|
if (input.isCastingNonMeleeSpell || input.isControlled) return false;
|
|
|
|
// The hit-table path honors CU_REQ_CASTER_BEHIND_TARGET when checking the
|
|
// ignore-hit-direction aura exception. The later direct-damage helper only
|
|
// checks front-or-ignore-direction and has no separate spell-attribute gate.
|
|
if (input.attackerIsBehind) {
|
|
if (phase === "direct-damage") {
|
|
if (!input.ignoresHitDirection) return false;
|
|
} else {
|
|
const requiresCasterBehindTarget = customAttributeBit(
|
|
input.spell, SPELL_ATTR0_CU_REQ_CASTER_BEHIND_TARGET,
|
|
);
|
|
if (!input.ignoresHitDirection || requiresCasterBehindTarget !== false) return false;
|
|
}
|
|
}
|
|
|
|
const chanceAfterSkill = enemyNpcSpellBlockChanceAfterSkill({
|
|
phase,
|
|
blockPercent: input.blockPercent,
|
|
attackerWeaponSkill: input.attackerWeaponSkill,
|
|
defenderLevel: input.defenderLevel,
|
|
});
|
|
return chanceAfterSkill !== null && chanceAfterSkill > 0;
|
|
}
|
|
|
|
/**
|
|
* Resolves a normal WoW creature's passive block chance and block value from
|
|
* the historical AzerothCore formulas. `blockChanceAuraPercent` is the total
|
|
* percentage-point amount from active SPELL_AURA_MOD_BLOCK_PERCENT auras.
|
|
* Ordinary Creature objects in this AzerothCore branch leave UNIT_FIELD_STAT0
|
|
* at its zero-initialized value, so GetStat(STAT_STRENGTH) is 0 here.
|
|
*
|
|
* Returns null when the creature level is outside Healer Man's level-80
|
|
* source boundary, rather than clamping a missing or out-of-scope source row.
|
|
*/
|
|
export function resolveEnemyNpcBlockStats(input: {
|
|
readonly level: number;
|
|
readonly blockChanceAuraPercent: number;
|
|
readonly isTotem: boolean;
|
|
}): { readonly blockPercent: number; readonly blockValue: number } | null {
|
|
if (!Number.isInteger(input.level) || input.level < 1 || input.level > 80) return null;
|
|
if (!Number.isFinite(input.blockChanceAuraPercent)) return null;
|
|
if (typeof input.isTotem !== "boolean") return null;
|
|
|
|
const blockPercent = input.isTotem
|
|
? 0
|
|
: Math.max(0, 5 + input.blockChanceAuraPercent);
|
|
return Object.freeze({
|
|
blockPercent,
|
|
// Creature::GetShieldBlockValue(): level / 2 + uint32(GetStat(STR) / 20).
|
|
// Ordinary creatures have zero STR in this core path; integer division is
|
|
// observable for odd levels (for example, level 79 gives 39 block).
|
|
blockValue: Math.trunc(input.level / 2),
|
|
});
|
|
}
|
|
|
|
export type EnemyNpcBlockAttack = "melee-auto" | "ranged-auto" | "spell" | "other";
|
|
|
|
/**
|
|
* Resolves the ordinary weapon-hit chance after AzerothCore's skill adjustment.
|
|
* The simulator maps attacker weapon skill to attacker level * 5; NPC defense
|
|
* skill is the defender's effective level * 5. The returned chance preserves
|
|
* AzerothCore's integer-hundredths truncation before the skill modifier.
|
|
*/
|
|
export function enemyNpcBlockChanceAfterSkill(input: {
|
|
readonly blockPercent: number;
|
|
readonly attackerLevel: number;
|
|
readonly defenderLevel: number;
|
|
}): number | null {
|
|
if (!Number.isFinite(input.blockPercent)) return null;
|
|
if (input.blockPercent <= 0) return 0;
|
|
if (!Number.isInteger(input.attackerLevel) || input.attackerLevel < 1 || input.attackerLevel > 80) return null;
|
|
if (!Number.isInteger(input.defenderLevel) || input.defenderLevel < 1 || input.defenderLevel > 80) return null;
|
|
|
|
const attackerWeaponSkill = input.attackerLevel * 5;
|
|
const defenderMaxSkill = input.defenderLevel * 5;
|
|
const chanceHundredths = Math.trunc(input.blockPercent * 100)
|
|
- 4 * (attackerWeaponSkill - defenderMaxSkill);
|
|
return Math.max(0, chanceHundredths) / 100;
|
|
}
|
|
|
|
/**
|
|
* Mirrors the explicit block gates supported for ordinary enemy weapon
|
|
* auto-attacks. `blockPercent` is the raw pre-skill chance from
|
|
* `resolveEnemyNpcBlockStats`; this applies the level adjustment only for the
|
|
* positivity gate. Spell packets use the separate source-metadata helper
|
|
* `canEnemyNpcBlockSpell`; spell and other packets fail closed in this helper.
|
|
*/
|
|
export function canEnemyNpcBlock(input: {
|
|
readonly attack: EnemyNpcBlockAttack;
|
|
readonly blockPercent: number;
|
|
readonly flagsExtra: number;
|
|
readonly isTotem: boolean;
|
|
readonly attackerLevel: number;
|
|
readonly defenderLevel: number;
|
|
readonly attackerIsBehind: boolean;
|
|
readonly ignoresHitDirection: boolean;
|
|
readonly isCastingNonMeleeSpell: boolean;
|
|
readonly isControlled: boolean;
|
|
readonly isEvadingAttacks: boolean;
|
|
}): boolean {
|
|
if (!Number.isFinite(input.blockPercent) || input.blockPercent <= 0) return false;
|
|
if (!Number.isInteger(input.flagsExtra) || typeof input.isTotem !== "boolean") return false;
|
|
if (typeof input.attackerIsBehind !== "boolean" || typeof input.ignoresHitDirection !== "boolean"
|
|
|| typeof input.isCastingNonMeleeSpell !== "boolean" || typeof input.isControlled !== "boolean"
|
|
|| typeof input.isEvadingAttacks !== "boolean") return false;
|
|
if (input.attack !== "melee-auto" && input.attack !== "ranged-auto") return false;
|
|
const chanceAfterSkill = enemyNpcBlockChanceAfterSkill(input);
|
|
if (chanceAfterSkill === null || chanceAfterSkill <= 0) return false;
|
|
if (input.isTotem || (input.flagsExtra & CREATURE_FLAG_EXTRA_NO_BLOCK) !== 0) return false;
|
|
if (input.isCastingNonMeleeSpell || input.isControlled) return false;
|
|
|
|
// The ordinary weapon roll applies the facing gate to both melee and ranged
|
|
// weapon attacks. It has no ranged-attack exclusion for blocking.
|
|
if (input.attackerIsBehind && !input.ignoresHitDirection) return false;
|
|
if (input.isEvadingAttacks) return false;
|
|
return true;
|
|
}
|