424 lines
16 KiB
TypeScript
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;
|
|
}
|