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

465 lines
19 KiB
TypeScript

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<Record<EnemyBlockAuraSourceProfile, Readonly<Record<number, EnemyBlockAuraContract>>>> = 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<Record<EnemyBlockAuraSourceProfile, Readonly<Record<number, ReviewedCriticalBlockRow>>>> = 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;
}