Files
healer-man/src/game/enemyBlocking.ts
T
2026-10-07 13:05:04 -04:00

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;
}