Files
healer-man/src/game/enemySourceAuras.ts
T
2026-10-06 22:25:34 -04:00

424 lines
16 KiB
TypeScript

import type { AuraDefinition, AuraModifier, DamageSchool, DispelCategory } from "./combatAuras";
import { applyAura, type ActiveAura } from "./combatAuras";
import { enemyStanceContract, supportedEnemyStanceAuraDefinition } from "./enemyStances";
import { enemyBloodlustContract } from "./enemyBloodlust";
import { enemyWeaponDebuffAuraDefinition, enemyWeaponDebuffContract } from "./enemyWeaponDebuffs";
/** Spell.dbc profile selected by the dungeon recipe. */
export type EnemyAuraSourceProfile = "ascension" | "epoch";
export interface EnemyAuraEffectSignature {
readonly effectType: number;
readonly auraType: number;
readonly basePoints: number;
readonly mechanic: number;
readonly implicitTargetA: number;
readonly implicitTargetB: number;
readonly radius: number;
readonly miscValue: number;
readonly periodMs: number;
}
export interface EnemyAuraSpellSignature {
readonly rangeIndex: number;
readonly schoolMask: number;
readonly damageClass: number;
/** The raw duration used by the compiler, including -1 for permanent. */
readonly durationMs: number;
readonly effects: readonly EnemyAuraEffectSignature[];
}
export type EnemyAuraTargetGeometry =
| { readonly kind: "self" }
| { readonly kind: "explicit-friendly"; readonly rangeYards: number }
| { readonly kind: "explicit-hostile"; readonly rangeYards: number }
| { readonly kind: "source-area-enemy"; readonly radiusYards: number }
| { readonly kind: "caster-area-party"; readonly radiusYards: number };
export interface UnsupportedEnemyAuraEffect {
readonly auraType: number;
readonly basePoints: number;
readonly miscValue: number;
readonly reason: string;
}
export interface EnemySourceAuraContract {
readonly spellId: number;
readonly name: string;
readonly profile: EnemyAuraSourceProfile;
readonly signature: EnemyAuraSpellSignature;
/** Runtime duration; null means until replacement or explicit removal. */
readonly durationMs: number | null;
readonly targetGeometry: EnemyAuraTargetGeometry;
readonly disposition: "buff" | "debuff";
readonly dispelCategory?: DispelCategory;
/** DBC stackAmount is zero on these rows; no multi-stack behavior is sourced. */
readonly stackAmount: number;
/** Child spells are not present in the selected Spell.dbc effects or linked-spell rows. */
readonly childTriggerSpellIds: readonly number[];
/** Exact mechanics this runtime can apply through ActiveAura modifiers. */
readonly modifiers: readonly AuraModifier[];
/** Sourced non-numeric state, such as disabling one weapon channel. */
readonly utilityTags?: readonly string[];
/** Source effects that remain intentionally unmodeled. */
readonly unsupportedEffects: readonly UnsupportedEnemyAuraEffect[];
}
export const ENEMY_AURA_SOURCE_PINS = Object.freeze({
ascension: Object.freeze({
spellDbcPath: "../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc",
spellDbcSha256: "2f77f18b91ffbae32a19568e4bc05ec78ee384b6ba8dd2a6e05f0e97abbb2dce",
supportTables: Object.freeze({
source: "shared patch-S SpellDuration.dbc and SpellRadius.dbc fallback",
durationDbcSha256: "eeae44ef0185924f3d6ec57ab2701c66566db86e51ab0bae017a9449126eb3a0",
radiusDbcSha256: "dba6ed2f165f75c84dda5f32ae46b1a01fe562efd88fa51fd19365705c3e6a44",
}),
}),
epoch: Object.freeze({
spellDbcPath: "../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc",
spellDbcSha256: "d5cce1a83550dcfa9eb2f0251dbb11fd24c272534b2b1a9b230924a44d817ab3",
supportTables: Object.freeze({
source: "shared patch-S SpellDuration.dbc and SpellRadius.dbc fallback",
durationDbcSha256: "eeae44ef0185924f3d6ec57ab2701c66566db86e51ab0bae017a9449126eb3a0",
radiusDbcSha256: "dba6ed2f165f75c84dda5f32ae46b1a01fe562efd88fa51fd19365705c3e6a44",
}),
}),
});
const effect = (
effectType: number,
auraType: number,
basePoints: number,
implicitTargetA: number,
implicitTargetB: number,
radius: number,
miscValue: number,
): EnemyAuraEffectSignature => ({
effectType,
auraType,
basePoints,
mechanic: 0,
implicitTargetA,
implicitTargetB,
radius,
miscValue,
periodMs: 0,
});
const signature = (
durationMs: number,
effects: readonly EnemyAuraEffectSignature[],
damageClass = 0,
): EnemyAuraSpellSignature => ({
rangeIndex: 1,
schoolMask: 1,
damageClass,
durationMs,
effects,
});
const self = Object.freeze({ kind: "self" } as const);
const stanceForm = (form: number): UnsupportedEnemyAuraEffect => Object.freeze({
auraType: 36,
basePoints: 0,
miscValue: form,
reason: "MOD_SHAPESHIFT selects a stance/form slot; the form's stance-specific behavior is not represented as a damage modifier.",
});
const blockChance = Object.freeze({
kind: "stat", stat: "block", operation: "flat", value: 75,
} as const);
const CONTRACTS: Readonly<Record<EnemyAuraSourceProfile, Readonly<Record<number, EnemySourceAuraContract>>>> = Object.freeze({
ascension: Object.freeze({
8599: Object.freeze({
spellId: 8599,
name: "Enrage",
profile: "ascension",
signature: signature(120_000, [
effect(6, 79, 30, 1, 0, 0, 1),
effect(6, 138, 30, 1, 0, 0, 0),
]),
durationMs: 120_000,
targetGeometry: self,
disposition: "buff",
dispelCategory: "enrage",
stackAmount: 0,
childTriggerSpellIds: Object.freeze([]),
modifiers: Object.freeze([
Object.freeze({ kind: "damage", direction: "dealt", school: "physical", operation: "percent", value: 0.30 }),
Object.freeze({ kind: "stat", stat: "melee-haste", operation: "flat", value: 30 }),
]),
unsupportedEffects: Object.freeze([]),
}),
7164: Object.freeze({
spellId: 7164,
name: "Defensive Stance",
profile: "ascension",
signature: signature(180_000, [effect(6, 36, 0, 1, 0, 0, 18)]),
durationMs: 180_000,
targetGeometry: self,
disposition: "buff",
stackAmount: 0,
childTriggerSpellIds: Object.freeze([]),
modifiers: Object.freeze([]),
unsupportedEffects: Object.freeze([stanceForm(18)]),
}),
7165: Object.freeze({
spellId: 7165,
name: "Battle Stance",
profile: "ascension",
signature: signature(-1, [effect(6, 36, 0, 1, 0, 0, 17)], 2),
durationMs: null,
targetGeometry: self,
disposition: "buff",
stackAmount: 0,
childTriggerSpellIds: Object.freeze([]),
modifiers: Object.freeze([]),
unsupportedEffects: Object.freeze([stanceForm(17)]),
}),
16244: Object.freeze({
spellId: 16244,
name: "Demoralizing Shout",
profile: "ascension",
signature: signature(15_000, [effect(6, 79, -10, 22, 15, 10, 1)], 1),
durationMs: 15_000,
targetGeometry: Object.freeze({ kind: "source-area-enemy", radiusYards: 10 }),
disposition: "debuff",
stackAmount: 0,
childTriggerSpellIds: Object.freeze([]),
modifiers: Object.freeze([
Object.freeze({ kind: "damage", direction: "dealt", school: "physical", operation: "percent", value: -0.10 }),
]),
unsupportedEffects: Object.freeze([]),
}),
13730: Object.freeze({
spellId: 13730,
name: "Demoralizing Shout",
profile: "ascension",
signature: signature(30_000, [effect(6, 166, -15, 22, 15, 10, 0)], 1),
durationMs: 30_000,
targetGeometry: Object.freeze({ kind: "source-area-enemy", radiusYards: 10 }),
disposition: "debuff",
stackAmount: 0,
childTriggerSpellIds: Object.freeze([]),
modifiers: Object.freeze([
Object.freeze({ kind: "stat", stat: "attack-power", operation: "percent", value: -0.15 }),
]),
unsupportedEffects: Object.freeze([]),
}),
9128: Object.freeze({
spellId: 9128,
name: "Battle Shout",
profile: "ascension",
signature: signature(120_000, [effect(6, 79, 15, 20, 0, 20, 1)], 1),
durationMs: 120_000,
targetGeometry: Object.freeze({ kind: "caster-area-party", radiusYards: 20 }),
disposition: "buff",
stackAmount: 0,
childTriggerSpellIds: Object.freeze([]),
modifiers: Object.freeze([
Object.freeze({ kind: "damage", direction: "dealt", school: "physical", operation: "percent", value: 0.15 }),
]),
unsupportedEffects: Object.freeze([]),
}),
3419: Object.freeze({
spellId: 3419,
name: "Improved Blocking",
profile: "ascension",
signature: signature(6_000, [effect(6, 51, 75, 1, 0, 0, 0)]),
durationMs: 6_000,
targetGeometry: self,
disposition: "buff",
stackAmount: 0,
childTriggerSpellIds: Object.freeze([]),
modifiers: Object.freeze([blockChance]),
unsupportedEffects: Object.freeze([]),
}),
}),
epoch: Object.freeze({
8599: Object.freeze({
spellId: 8599,
name: "Enrage",
profile: "epoch",
signature: signature(120_000, [
effect(6, 79, 10, 1, 0, 0, 1),
effect(6, 138, 30, 1, 0, 0, 0),
]),
durationMs: 120_000,
targetGeometry: self,
disposition: "buff",
dispelCategory: "enrage",
stackAmount: 0,
childTriggerSpellIds: Object.freeze([]),
modifiers: Object.freeze([
Object.freeze({ kind: "damage", direction: "dealt", school: "physical", operation: "percent", value: 0.10 }),
Object.freeze({ kind: "stat", stat: "melee-haste", operation: "flat", value: 30 }),
]),
unsupportedEffects: Object.freeze([]),
}),
7164: Object.freeze({
spellId: 7164,
name: "Defensive Stance",
profile: "epoch",
signature: signature(180_000, [effect(6, 36, 0, 1, 0, 0, 18)]),
durationMs: 180_000,
targetGeometry: self,
disposition: "buff",
stackAmount: 0,
childTriggerSpellIds: Object.freeze([]),
modifiers: Object.freeze([]),
unsupportedEffects: Object.freeze([stanceForm(18)]),
}),
7165: Object.freeze({
spellId: 7165,
name: "Battle Stance",
profile: "epoch",
signature: signature(-1, [effect(6, 36, 0, 1, 0, 0, 17)], 2),
durationMs: null,
targetGeometry: self,
disposition: "buff",
stackAmount: 0,
childTriggerSpellIds: Object.freeze([]),
modifiers: Object.freeze([]),
unsupportedEffects: Object.freeze([stanceForm(17)]),
}),
16244: Object.freeze({
spellId: 16244,
name: "Demoralizing Shout",
profile: "epoch",
signature: signature(30_000, [effect(6, 99, -20, 22, 15, 10, 0)], 1),
durationMs: 30_000,
targetGeometry: Object.freeze({ kind: "source-area-enemy", radiusYards: 10 }),
disposition: "debuff",
stackAmount: 0,
childTriggerSpellIds: Object.freeze([]),
modifiers: Object.freeze([
Object.freeze({ kind: "stat", stat: "attack-power", operation: "flat", value: -20 }),
]),
unsupportedEffects: Object.freeze([]),
}),
13730: Object.freeze({
spellId: 13730,
name: "Demoralizing Shout",
profile: "epoch",
signature: signature(30_000, [effect(6, 99, -10, 22, 15, 10, 0)], 1),
durationMs: 30_000,
targetGeometry: Object.freeze({ kind: "source-area-enemy", radiusYards: 10 }),
disposition: "debuff",
stackAmount: 0,
childTriggerSpellIds: Object.freeze([]),
modifiers: Object.freeze([
Object.freeze({ kind: "stat", stat: "attack-power", operation: "flat", value: -10 }),
]),
unsupportedEffects: Object.freeze([]),
}),
9128: Object.freeze({
spellId: 9128,
name: "Battle Shout",
profile: "epoch",
signature: signature(120_000, [effect(6, 99, 4, 20, 0, 20, 0)], 1),
durationMs: 120_000,
targetGeometry: Object.freeze({ kind: "caster-area-party", radiusYards: 20 }),
disposition: "buff",
stackAmount: 0,
childTriggerSpellIds: Object.freeze([]),
modifiers: Object.freeze([
Object.freeze({ kind: "stat", stat: "attack-power", operation: "flat", value: 4 }),
]),
unsupportedEffects: Object.freeze([]),
}),
3419: Object.freeze({
spellId: 3419,
name: "Improved Blocking",
profile: "epoch",
signature: signature(6_000, [effect(6, 51, 75, 1, 0, 0, 0)]),
durationMs: 6_000,
targetGeometry: self,
disposition: "buff",
stackAmount: 0,
childTriggerSpellIds: Object.freeze([]),
modifiers: Object.freeze([blockChance]),
unsupportedEffects: Object.freeze([]),
}),
}),
});
/** Return the reviewed source signature and supported portion for one profile. */
export function enemySourceAuraContract(
spellId: number,
profile: EnemyAuraSourceProfile,
): EnemySourceAuraContract | undefined {
if (spellId === 6742) return enemyBloodlustContract(profile);
const debuff = enemyWeaponDebuffContract(spellId, profile);
if (debuff) return {
...debuff, targetGeometry: { kind: "explicit-hostile", rangeYards: 5 },
disposition: "debuff", childTriggerSpellIds: [],
};
const contract = CONTRACTS[profile]?.[spellId];
const stance = enemyStanceContract(spellId, profile);
return contract && stance
? { ...contract, modifiers: stance.modifiers, unsupportedEffects: stance.unsupportedEffects }
: contract;
}
/** Build the subset that existing ActiveAura stat and damage resolvers can apply. */
export function supportedEnemySourceAuraDefinition(
contract: EnemySourceAuraContract,
): AuraDefinition | undefined {
const debuff = enemyWeaponDebuffContract(contract.spellId, contract.profile);
if (debuff) return enemyWeaponDebuffAuraDefinition(debuff);
const stance = enemyStanceContract(contract.spellId, contract.profile);
if (stance) return supportedEnemyStanceAuraDefinition(stance);
if (!contract.modifiers.length && !contract.utilityTags?.length) return undefined;
return {
id: `enemy-source-spell-${contract.spellId}`,
name: contract.name,
disposition: contract.disposition,
durationMs: contract.durationMs,
stackBehavior: "refresh",
...(contract.dispelCategory ? { dispelCategory: contract.dispelCategory } : {}),
...(contract.utilityTags?.length ? { utilityTags: contract.utilityTags } : {}),
modifiers: contract.modifiers.map((modifier) => modifier.operation === "percent"
|| modifier.kind === "stat" && modifier.stat === "melee-haste"
? { ...modifier, multiplicative: true } : modifier),
};
}
/** Nonperiodic copies of the same source spell do not stack across casters. */
export function applyEnemySourceAura(
current: readonly ActiveAura[], definition: AuraDefinition,
sourceId: string, targetId: string, now: number,
): readonly ActiveAura[] {
const other = current.find((aura) => aura.targetId === targetId && aura.definition.id === definition.id
&& aura.sourceId !== sourceId && (aura.expiresAt === null || aura.expiresAt > now));
// A second area-aura caster cannot refresh an existing recipient's aura.
if (other && targetId !== sourceId) return current;
const base = current.filter((aura) => aura.targetId !== targetId || aura.definition.id !== definition.id || aura.sourceId === sourceId);
return applyAura(base, { definition, sourceId, targetId, now }).auras;
}
/** Timed direct utility casts on another actor outlive their removed caster. */
const PERSISTENT_TIMED_ENEMY_CONTROL_AURA_IDS = new Set([
"enemy-control:source-spell-20511",
"enemy-control:source-spell-29544",
"enemy-control:source-spell-5246",
"enemy-control:source-spell-19134",
]);
export function removeEnemyActorAuras(
current: readonly ActiveAura[], actorId: string,
): readonly ActiveAura[] {
return current.filter((aura) => aura.targetId !== actorId && (
aura.sourceId !== actorId
|| aura.definition.id === "enemy-source-spell-6742"
|| aura.definition.id === "enemy-source-spell-6713"
|| aura.definition.id === "enemy-source-spell-17230"
|| PERSISTENT_TIMED_ENEMY_CONTROL_AURA_IDS.has(aura.definition.id)
));
}
/** Direct Enrage/shout modifiers use physical mask 1; stances resolve mask 127 in enemyStances. */
export function enemySourceDamageSchoolForMask(mask: number): DamageSchool | null {
return mask === 1 ? "physical" : null;
}