import type { ActiveAura, AuraDefinition } from "./combatAuras"; import type { EnemyAuraEffectSignature, EnemyAuraSpellSignature } from "./enemySourceAuras"; /** AzerothCore SpellAuraDefines.h aura ids consumed by ordinary NPC blocking. */ export const SPELL_AURA_MOD_BLOCK_PERCENT = 51; export const SPELL_AURA_MOD_BLOCK_CRIT_CHANCE = 253; export const SPELL_AURA_IGNORE_HIT_DIRECTION = 288; export type EnemyBlockAuraSourceProfile = "wow335" | "ascension" | "epoch"; export interface EnemyBlockAuraContract { readonly spellId: number; readonly name: string; readonly profile: EnemyBlockAuraSourceProfile; /** Raw Spell.dbc duration, with -1 representing the permanent duration row. */ readonly signature: EnemyAuraSpellSignature; /** Runtime duration; null means until explicitly removed. */ readonly durationMs: number | null; readonly disposition: "buff"; readonly target: "self"; readonly stackAmount: number; readonly effects: readonly EnemyAuraEffectSignature[]; readonly unsupportedEffects: readonly { readonly effectIndex: number; readonly auraType: number; readonly basePoints: number; readonly miscValue: number; readonly reason: string; }[]; readonly blockInputs: EnemyNpcBlockAuraInputs; } export interface EnemyNpcBlockAuraInputs { /** Sum of active SPELL_AURA_MOD_BLOCK_PERCENT amounts, in percentage points. */ readonly blockChanceAuraPercent: number; /** Integer chance passed to AzerothCore's roll_chance_i after a successful block roll. */ readonly criticalBlockChancePercent: number; /** Any active SPELL_AURA_IGNORE_HIT_DIRECTION effect bypasses facing. */ readonly ignoresHitDirection: boolean; } /** * Applicability evidence required before importing Critical Block's aura-107 * siblings as no-ops. The core pin describes the default AzerothCore path; * callers must not assert it when custom scripts/modules change that path. */ export interface EnemyNpcCriticalBlockImportProof { readonly defenderId: string; readonly casterId: string; readonly targetId: string; readonly defenderKind: "ordinary-creature"; readonly isNativeDefender: false; readonly isSummonedDefender: false; readonly isCharacterDefender: false; readonly aura107Applicability: "azerothcore-e822d718-default-spellmod-path"; } export const ENEMY_BLOCK_AURA_SOURCE_PINS = Object.freeze({ wow335: Object.freeze({ spellDbcPath: "../wow-pvt-server-backups-2/wow335a/Data/patch-A.MPQ/DBFilesClient/Spell.dbc", spellDbcSha256: "57011d528fd66cf39f5ab6fb168e389ab9e4cc1b8050f7201a54239d489b415c", durationDbcPath: "../LadiksMPQEditor/client-current/patch-S/DBFilesClient/SpellDuration.dbc", durationDbcSha256: "eeae44ef0185924f3d6ec57ab2701c66566db86e51ab0bae017a9449126eb3a0", }), ascension: Object.freeze({ spellDbcPath: "../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc", spellDbcSha256: "2f77f18b91ffbae32a19568e4bc05ec78ee384b6ba8dd2a6e05f0e97abbb2dce", durationDbcPath: "../LadiksMPQEditor/client-current/patch-S/DBFilesClient/SpellDuration.dbc", durationDbcSha256: "eeae44ef0185924f3d6ec57ab2701c66566db86e51ab0bae017a9449126eb3a0", }), epoch: Object.freeze({ spellDbcPath: "../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc", spellDbcSha256: "d5cce1a83550dcfa9eb2f0251dbb11fd24c272534b2b1a9b230924a44d817ab3", durationDbcPath: "../LadiksMPQEditor/client-current/patch-S/DBFilesClient/SpellDuration.dbc", durationDbcSha256: "eeae44ef0185924f3d6ec57ab2701c66566db86e51ab0bae017a9449126eb3a0", }), }); const tagPrefix = "enemy-npc-block-aura:"; const tag = (auraType: number, amount: number): string => `${tagPrefix}${auraType}:${amount}`; const auraInputTags = Object.freeze({ blockChance: (amount: number) => tag(SPELL_AURA_MOD_BLOCK_PERCENT, amount), criticalBlockChance: (amount: number) => tag(SPELL_AURA_MOD_BLOCK_CRIT_CHANCE, amount), ignoreHitDirection: tag(SPELL_AURA_IGNORE_HIT_DIRECTION, 0), }); const effect = ( effectType: number, auraType: number, basePoints: number, miscValue = 0, ): EnemyAuraEffectSignature => Object.freeze({ effectType, auraType, basePoints, mechanic: 0, implicitTargetA: 1, implicitTargetB: 0, radius: 0, miscValue, periodMs: 0, }); const signature = ( durationMs: number, effects: readonly EnemyAuraEffectSignature[], ): EnemyAuraSpellSignature => Object.freeze({ rangeIndex: 1, schoolMask: 1, damageClass: 0, durationMs, effects: Object.freeze([...effects]), }); const unsupported = ( effectIndex: number, auraType: number, basePoints: number, miscValue: number, ): EnemyBlockAuraContract["unsupportedEffects"][number] => Object.freeze({ effectIndex, auraType, basePoints, miscValue, reason: "This source effect is preserved in the signature but is outside ordinary NPC block aura inputs.", }); function blockChanceContract(profile: EnemyBlockAuraSourceProfile): EnemyBlockAuraContract { const sourceEffect = effect(6, SPELL_AURA_MOD_BLOCK_PERCENT, 75); return Object.freeze({ spellId: 3419, name: "Improved Blocking", profile, signature: signature(6_000, [sourceEffect]), durationMs: 6_000, disposition: "buff", target: "self", stackAmount: 0, effects: Object.freeze([sourceEffect]), unsupportedEffects: Object.freeze([]), blockInputs: Object.freeze({ blockChanceAuraPercent: 75, criticalBlockChancePercent: 0, ignoresHitDirection: false, }), }); } function criticalBlockContract( profile: EnemyBlockAuraSourceProfile, spellId: 47294 | 47295 | 47296, critChance: number, spellModifierRanks: readonly number[], ): EnemyBlockAuraContract { const effects = [ effect(6, SPELL_AURA_MOD_BLOCK_CRIT_CHANCE, critChance), ...spellModifierRanks.map((value, index) => effect(6, 107, value, index === 0 ? 7 : 3)), ]; return Object.freeze({ spellId, name: "Critical Block", profile, signature: signature(-1, effects), durationMs: null, disposition: "buff", target: "self", stackAmount: 0, effects: Object.freeze(effects), unsupportedEffects: Object.freeze(effects.slice(1).map((sourceEffect, index) => ( unsupported(index + 1, sourceEffect.auraType, sourceEffect.basePoints, sourceEffect.miscValue) ))), blockInputs: Object.freeze({ blockChanceAuraPercent: 0, criticalBlockChancePercent: critChance, ignoresHitDirection: false, }), }); } function deterrenceContract( profile: EnemyBlockAuraSourceProfile, spellId: 67801 | 667801 | 1167801, durationMs: number, additionalEffects: readonly EnemyAuraEffectSignature[], ): EnemyBlockAuraContract { const direction = effect(6, SPELL_AURA_IGNORE_HIT_DIRECTION, 100); const effects = [direction, ...additionalEffects]; return Object.freeze({ spellId, name: "Deterrence", profile, signature: signature(durationMs, effects), durationMs, disposition: "buff", target: "self", stackAmount: 0, effects: Object.freeze(effects), unsupportedEffects: Object.freeze(additionalEffects.map((sourceEffect, index) => ( unsupported(index + 1, sourceEffect.auraType, sourceEffect.basePoints, sourceEffect.miscValue) ))), blockInputs: Object.freeze({ blockChanceAuraPercent: 0, criticalBlockChancePercent: 0, ignoresHitDirection: true, }), }); } // Aura 185 is MOD_ATTACKER_RANGED_HIT_CHANCE; it is not a Disarm aura. const DETERRENCE_RANGED_HIT_PENALTY = effect(6, 185, -100); const DETERRENCE_UNKNOWN = effect(6, 27, 0); const CONTRACTS: Readonly>>> = Object.freeze({ wow335: Object.freeze({ 3419: blockChanceContract("wow335"), 47294: criticalBlockContract("wow335", 47294, 20, [5]), 47295: criticalBlockContract("wow335", 47295, 40, [10]), 47296: criticalBlockContract("wow335", 47296, 60, [15]), 67801: deterrenceContract("wow335", 67801, 5_000, [DETERRENCE_RANGED_HIT_PENALTY]), }), ascension: Object.freeze({ 3419: blockChanceContract("ascension"), 47294: criticalBlockContract("ascension", 47294, 100, [4, 8]), 47295: criticalBlockContract("ascension", 47295, 100, [7, 16]), 47296: criticalBlockContract("ascension", 47296, 100, [10, 25]), 67801: deterrenceContract("ascension", 67801, 60_000, [effect(6, 4, 0), DETERRENCE_UNKNOWN]), 667801: deterrenceContract("ascension", 667801, 5_000, [DETERRENCE_RANGED_HIT_PENALTY, DETERRENCE_UNKNOWN]), 1167801: deterrenceContract("ascension", 1167801, 5_000, [DETERRENCE_RANGED_HIT_PENALTY]), }), epoch: Object.freeze({ 3419: blockChanceContract("epoch"), 47294: criticalBlockContract("epoch", 47294, 20, [5]), 47295: criticalBlockContract("epoch", 47295, 40, [10]), 47296: criticalBlockContract("epoch", 47296, 60, [15]), 67801: deterrenceContract("epoch", 67801, 5_000, [DETERRENCE_RANGED_HIT_PENALTY]), }), }); interface ReviewedCriticalBlockRow { readonly criticalBlockChance: number; readonly spellModifierEffects: readonly { readonly basePoints: number; readonly miscValue: number; }[]; } const REVIEWED_CRITICAL_BLOCK_ROWS: Readonly>>> = Object.freeze({ wow335: Object.freeze({ 47294: Object.freeze({ criticalBlockChance: 20, spellModifierEffects: Object.freeze([{ basePoints: 5, miscValue: 7 }]) }), 47295: Object.freeze({ criticalBlockChance: 40, spellModifierEffects: Object.freeze([{ basePoints: 10, miscValue: 7 }]) }), 47296: Object.freeze({ criticalBlockChance: 60, spellModifierEffects: Object.freeze([{ basePoints: 15, miscValue: 7 }]) }), }), ascension: Object.freeze({ 47294: Object.freeze({ criticalBlockChance: 100, spellModifierEffects: Object.freeze([{ basePoints: 4, miscValue: 7 }, { basePoints: 8, miscValue: 3 }]) }), 47295: Object.freeze({ criticalBlockChance: 100, spellModifierEffects: Object.freeze([{ basePoints: 7, miscValue: 7 }, { basePoints: 16, miscValue: 3 }]) }), 47296: Object.freeze({ criticalBlockChance: 100, spellModifierEffects: Object.freeze([{ basePoints: 10, miscValue: 7 }, { basePoints: 25, miscValue: 3 }]) }), }), epoch: Object.freeze({ 47294: Object.freeze({ criticalBlockChance: 20, spellModifierEffects: Object.freeze([{ basePoints: 5, miscValue: 7 }]) }), 47295: Object.freeze({ criticalBlockChance: 40, spellModifierEffects: Object.freeze([{ basePoints: 10, miscValue: 7 }]) }), 47296: Object.freeze({ criticalBlockChance: 60, spellModifierEffects: Object.freeze([{ basePoints: 15, miscValue: 7 }]) }), }), }); /** Returns the reviewed Spell.dbc aura row for one supported source profile. */ export function enemyBlockAuraContract( spellId: number, profile: EnemyBlockAuraSourceProfile, ): EnemyBlockAuraContract | undefined { return CONTRACTS[profile]?.[spellId]; } /** * Builds an input-only aura fixture. It retains only aura 51/253/288 values; * callers must not use this as an imported full-spell definition when the * contract also has unsupported sibling effects. */ export function enemyBlockAuraInputDefinition(contract: EnemyBlockAuraContract): AuraDefinition { const tags: string[] = []; for (const sourceEffect of contract.effects) { if (sourceEffect.auraType === SPELL_AURA_MOD_BLOCK_PERCENT) { tags.push(auraInputTags.blockChance(sourceEffect.basePoints)); } else if (sourceEffect.auraType === SPELL_AURA_MOD_BLOCK_CRIT_CHANCE) { tags.push(auraInputTags.criticalBlockChance(sourceEffect.basePoints)); } else if (sourceEffect.auraType === SPELL_AURA_IGNORE_HIT_DIRECTION) { tags.push(auraInputTags.ignoreHitDirection); } } return Object.freeze({ id: `enemy-source-spell-${contract.spellId}`, name: contract.name, disposition: contract.disposition, durationMs: contract.durationMs, stackBehavior: "refresh", utilityTags: Object.freeze(tags), }); } /** Builds an importable definition only when every source effect is represented. */ export function supportedEnemyBlockAuraDefinition( contract: EnemyBlockAuraContract, ): AuraDefinition | undefined { if (contract.unsupportedEffects.length > 0 || contract.effects.some((sourceEffect) => ![ SPELL_AURA_MOD_BLOCK_PERCENT, SPELL_AURA_MOD_BLOCK_CRIT_CHANCE, SPELL_AURA_IGNORE_HIT_DIRECTION, ].includes(sourceEffect.auraType))) return undefined; return enemyBlockAuraInputDefinition(contract); } function sameEffectSignature( actual: EnemyAuraEffectSignature | undefined, expected: EnemyAuraEffectSignature, ): boolean { return actual !== undefined && actual.effectType === expected.effectType && actual.auraType === expected.auraType && actual.basePoints === expected.basePoints && actual.mechanic === expected.mechanic && actual.implicitTargetA === expected.implicitTargetA && actual.implicitTargetB === expected.implicitTargetB && actual.radius === expected.radius && actual.miscValue === expected.miscValue && actual.periodMs === expected.periodMs; } function isReviewedCriticalBlockContract(contract: EnemyBlockAuraContract | null | undefined): boolean { if (!contract || typeof contract !== "object") return false; const row = REVIEWED_CRITICAL_BLOCK_ROWS[contract.profile]?.[contract.spellId]; if (!row || !contract.blockInputs || !contract.signature || contract.name !== "Critical Block" || contract.durationMs !== null || contract.disposition !== "buff" || contract.target !== "self" || contract.stackAmount !== 0 || contract.blockInputs.blockChanceAuraPercent !== 0 || contract.blockInputs.criticalBlockChancePercent !== row.criticalBlockChance || contract.blockInputs.ignoresHitDirection !== false) return false; const expectedEffects = [ effect(6, SPELL_AURA_MOD_BLOCK_CRIT_CHANCE, row.criticalBlockChance), ...row.spellModifierEffects.map(({ basePoints, miscValue }) => effect(6, 107, basePoints, miscValue)), ]; const sourceEffects = contract.signature.effects; if (contract.signature.rangeIndex !== 1 || contract.signature.schoolMask !== 1 || contract.signature.damageClass !== 0 || contract.signature.durationMs !== -1 || !Array.isArray(sourceEffects) || sourceEffects.length !== expectedEffects.length || !Array.isArray(contract.effects) || contract.effects.length !== expectedEffects.length || !Array.isArray(contract.unsupportedEffects) || contract.unsupportedEffects.length !== expectedEffects.length - 1) return false; for (let index = 0; index < expectedEffects.length; index += 1) { if (!sameEffectSignature(sourceEffects[index], expectedEffects[index]) || !sameEffectSignature(contract.effects[index], expectedEffects[index])) return false; } return row.spellModifierEffects.every((sourceEffect, index) => { const unsupportedEffect = contract.unsupportedEffects[index]; return unsupportedEffect?.effectIndex === index + 1 && unsupportedEffect.auraType === 107 && unsupportedEffect.basePoints === sourceEffect.basePoints && unsupportedEffect.miscValue === sourceEffect.miscValue; }); } function isOrdinaryCreatureSelfTargetProof( proof: EnemyNpcCriticalBlockImportProof | null | undefined, ): boolean { if (!proof || typeof proof !== "object" || proof.defenderKind !== "ordinary-creature" || proof.isNativeDefender !== false || proof.isSummonedDefender !== false || proof.isCharacterDefender !== false || proof.aura107Applicability !== "azerothcore-e822d718-default-spellmod-path") return false; const { defenderId, casterId, targetId } = proof; return typeof defenderId === "string" && defenderId.trim().length > 0 && defenderId === casterId && casterId === targetId; } /** * Imports Critical Block as a full aura only for a reviewed ordinary Creature * self-cast. Aura 107 remains in the checked raw signature and is treated as a * no-op only under the pinned AzerothCore default spell-mod applicability. * The generic importer intentionally remains fail-closed for these rows. */ export function ordinaryCreatureSelfTargetCriticalBlockAuraDefinition( contract: EnemyBlockAuraContract | null | undefined, proof: EnemyNpcCriticalBlockImportProof | null | undefined, ): AuraDefinition | undefined { if (!isReviewedCriticalBlockContract(contract) || !isOrdinaryCreatureSelfTargetProof(proof)) return undefined; return enemyBlockAuraInputDefinition(contract!); } function decodeAuraTag(value: string): { readonly auraType: number; readonly basePoints: number } | null { if (!value.startsWith(tagPrefix)) return null; const match = /^(\d+):(-?\d+)$/.exec(value.slice(tagPrefix.length)); if (!match) return null; const auraType = Number(match[1]); const basePoints = Number(match[2]); if (!Number.isSafeInteger(auraType) || !Number.isSafeInteger(basePoints)) return null; return { auraType, basePoints }; } /** Sums block-related inputs from active, target-local enemy source auras. */ export function resolveEnemyNpcBlockAuraInputs(input: { readonly auras: readonly ActiveAura[]; readonly targetId: string; readonly now: number; }): EnemyNpcBlockAuraInputs | null { if (typeof input.targetId !== "string" || !input.targetId || !Number.isFinite(input.now) || !Array.isArray(input.auras)) return null; let blockChanceAuraPercent = 0; let criticalBlockChanceAuraPercent = 0; let ignoresHitDirection = false; for (const aura of input.auras) { if (aura.targetId !== input.targetId || (aura.expiresAt !== null && aura.expiresAt <= input.now)) continue; const tags = aura.definition.utilityTags ?? []; let carriesBlockChanceTag = false; for (const value of tags) { const decoded = decodeAuraTag(value); if (!decoded) continue; if (decoded.auraType === SPELL_AURA_MOD_BLOCK_PERCENT) { carriesBlockChanceTag = true; blockChanceAuraPercent += decoded.basePoints; } else if (decoded.auraType === SPELL_AURA_MOD_BLOCK_CRIT_CHANCE) { criticalBlockChanceAuraPercent += decoded.basePoints; } else if (decoded.auraType === SPELL_AURA_IGNORE_HIT_DIRECTION) { ignoresHitDirection = true; } } // Existing 3419 enemy-source aura definitions predate these utility tags; // their source-backed flat block stat still supplies the same aura-51 input. if (!carriesBlockChanceTag) { for (const modifier of aura.definition.modifiers ?? []) { if (modifier.kind === "stat" && modifier.stat === "block" && modifier.operation === "flat") { blockChanceAuraPercent += modifier.value; } } } } if (!Number.isFinite(blockChanceAuraPercent) || !Number.isFinite(criticalBlockChanceAuraPercent)) return null; return Object.freeze({ blockChanceAuraPercent, // Aura amounts are integer source values; roll_chance_i accepts an int. criticalBlockChancePercent: Math.trunc(criticalBlockChanceAuraPercent), ignoresHitDirection, }); } /** Mirrors the integer chance argument consumed by AzerothCore roll_chance_i. */ export function enemyNpcCriticalBlockChanceForRoll(chancePercent: number): number | null { if (!Number.isFinite(chancePercent)) return null; return Math.trunc(chancePercent); } /** Doubles ordinary creature block value only after the independent crit-block roll succeeds. */ export function enemyNpcBlockValueAfterCritical( blockValue: number, criticalBlockSucceeded: boolean, ): number | null { if (!Number.isSafeInteger(blockValue) || blockValue < 0 || typeof criticalBlockSucceeded !== "boolean") return null; const result = criticalBlockSucceeded ? blockValue * 2 : blockValue; return Number.isSafeInteger(result) ? result : null; }