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

458 lines
16 KiB
TypeScript

import type { ActiveAura, AuraModifier } from "./combatAuras";
/**
* Source-pinned triggered child effects for enemy shout spells.
*
* The parent shout's explicit victim and its caster-centered area recipients
* are separate target sets. This module describes that split without owning
* actor eligibility, spatial queries, or combat-store state.
*/
export type EnemyTriggeredShoutProfile = "ascension" | "epoch";
/** Utility tag identifying the control contributed by a separate source aura. */
export function enemyTriggeredShoutControlTag(mechanic: "fear" | "stun"): string {
return `enemy-control-source:${mechanic}`;
}
export interface EnemyTriggeredShoutEffectSignature {
readonly effectType: number;
readonly auraType: number;
readonly basePoints: number;
readonly mechanic: number;
readonly implicitTargetA: number;
readonly implicitTargetB: number;
readonly radiusIndex: number;
readonly miscValue: number;
readonly triggerSpellId: number;
readonly periodMs: number;
}
export interface EnemyTriggeredShoutSpellSignature {
readonly spellId: number;
readonly name: string;
/** Spell.dbc Mechanic, distinct from each effect's EffectMechanic. */
readonly spellMechanic: number;
readonly dispel: number;
readonly rangeIndex: number;
readonly rangeYards: number;
readonly durationMs: number;
readonly schoolMask: number;
readonly damageClass: number;
readonly stackAmount: number;
readonly maxAffectedTargets: number;
readonly attributes0: number;
readonly attributes1: number;
readonly attributes2: number;
readonly attributes3: number;
readonly attributes4: number;
readonly attributes5: number;
readonly attributes6: number;
readonly attributes7: number;
readonly effects: readonly EnemyTriggeredShoutEffectSignature[];
}
export type EnemyTriggeredShoutChildEffect =
| {
readonly kind: "control";
readonly effectIndex: number;
readonly mechanic: "fear" | "stun";
readonly durationMs: number;
}
| {
readonly kind: "damage-taken";
readonly effectIndex: number;
readonly damageSchool: "physical";
readonly operation: "percent";
readonly value: number;
readonly durationMs: number;
};
export interface EnemyTriggeredShoutContract {
readonly parentSpellId: number;
readonly sourceProfile: EnemyTriggeredShoutProfile;
readonly parentSignature: EnemyTriggeredShoutSpellSignature;
readonly triggerEffectIndex: 0;
readonly childSpellId: number;
readonly childSignature: EnemyTriggeredShoutSpellSignature;
readonly childEffects: readonly EnemyTriggeredShoutChildEffect[];
readonly targeting: {
/** The trigger effect and child spell both name the parent's explicit hostile victim. */
readonly childVictim: {
readonly targetA: 6;
readonly targetB: 0;
readonly maximumTargets: 1;
readonly rangeYards: number;
readonly requiresLineOfSight: true;
};
/** Independent parent effect pair TARGET_SRC_CASTER + TARGET_UNIT_SRC_AREA_ENEMY. */
readonly parentArea: {
readonly center: "caster";
readonly targetA: 22;
readonly targetB: 15;
readonly relation: "hostile";
readonly radiusIndex: 14;
readonly radiusYards: 8;
/** Spell.dbc zero means the core applies no MaxAffectedTargets limit. */
readonly maximumTargets: number;
readonly effectIndexes: readonly [1, 2];
/** The area search does not remove the explicit victim when both sets overlap. */
readonly includesExplicitVictimWhenEligible: true;
/** Core area target search checks radius/targetability, not per-recipient LOS. */
readonly requiresLineOfSight: false;
};
};
/** Source child auras have StackAmount 0 and no AuraUnique attribute. */
readonly recast: {
readonly sameCaster: "refresh";
readonly differentCaster: "preserve-existing";
};
readonly unsupportedParentEffectIndexes: readonly number[];
}
const effect = (
effectType: number,
auraType: number,
basePoints: number,
implicitTargetA: number,
implicitTargetB: number,
radiusIndex = 0,
miscValue = 0,
triggerSpellId = 0,
): EnemyTriggeredShoutEffectSignature => Object.freeze({
effectType,
auraType,
basePoints,
mechanic: 0,
implicitTargetA,
implicitTargetB,
radiusIndex,
miscValue,
triggerSpellId,
periodMs: 0,
});
const shoutSpeedTag = "enemy-triggered-shout:parent-movement-speed";
function sourceAttributes(
attributes0: number,
attributes1: number,
attributes2 = 0,
attributes3 = 0,
attributes4 = 0,
attributes5 = 0,
attributes6 = 0,
attributes7 = 0,
): Pick<EnemyTriggeredShoutSpellSignature,
"spellMechanic" | "attributes0" | "attributes1" | "attributes2" | "attributes3" | "attributes4"
| "attributes5" | "attributes6" | "attributes7"> {
return Object.freeze({
spellMechanic: 5,
attributes0,
attributes1,
attributes2,
attributes3,
attributes4,
attributes5,
attributes6,
attributes7,
});
}
const spell = (
spellId: number,
name: string,
rangeIndex: number,
rangeYards: number,
durationMs: number,
damageClass: number,
maxAffectedTargets: number,
attributes: Pick<EnemyTriggeredShoutSpellSignature,
"spellMechanic" | "attributes0" | "attributes1" | "attributes2" | "attributes3" | "attributes4"
| "attributes5" | "attributes6" | "attributes7">,
effects: readonly EnemyTriggeredShoutEffectSignature[],
): EnemyTriggeredShoutSpellSignature => Object.freeze({
spellId,
name,
...attributes,
dispel: 0,
rangeIndex,
rangeYards,
durationMs,
schoolMask: 1,
damageClass,
stackAmount: 0,
maxAffectedTargets,
effects: Object.freeze([...effects]),
});
const target = (
rangeYards: number,
maximumTargets: number,
): EnemyTriggeredShoutContract["targeting"] => Object.freeze({
childVictim: Object.freeze({
targetA: 6,
targetB: 0,
maximumTargets: 1,
rangeYards,
requiresLineOfSight: true,
}),
parentArea: Object.freeze({
center: "caster",
targetA: 22,
targetB: 15,
relation: "hostile",
radiusIndex: 14,
radiusYards: 8,
maximumTargets,
effectIndexes: Object.freeze([1, 2]) as readonly [1, 2],
includesExplicitVictimWhenEligible: true,
requiresLineOfSight: false,
}),
});
const control = (
mechanic: "fear" | "stun",
durationMs: number,
): EnemyTriggeredShoutChildEffect => Object.freeze({ kind: "control", effectIndex: 0, mechanic, durationMs });
const movementSpeed = (signature: EnemyTriggeredShoutSpellSignature) => {
const sourceEffect = signature.effects[2];
if (signature.durationMs <= 0
|| sourceEffect?.effectType !== 6
|| sourceEffect.auraType !== 31
|| sourceEffect.basePoints !== 25
|| sourceEffect.implicitTargetA !== 22
|| sourceEffect.implicitTargetB !== 15
|| sourceEffect.radiusIndex !== 14) return undefined;
return Object.freeze({
effectIndex: 2,
auraType: 31,
increasePercent: sourceEffect.basePoints,
durationMs: signature.durationMs,
utilityTag: shoutSpeedTag,
});
};
const contracts: Readonly<Record<EnemyTriggeredShoutProfile, Readonly<Record<number, EnemyTriggeredShoutContract>>>> = Object.freeze({
ascension: Object.freeze({
5246: Object.freeze({
parentSpellId: 5246,
sourceProfile: "ascension",
parentSignature: spell(5246, "Intimidating Shout", 137, 8, 5_000, 1, 5, sourceAttributes(0, 0, 0, 0, 0, 0, 33_554_432), [
effect(64, 0, 1, 6, 0, 0, 0, 20511),
effect(6, 7, 1, 22, 15, 14),
effect(6, 31, 25, 22, 15, 14),
]),
triggerEffectIndex: 0,
childSpellId: 20511,
childSignature: spell(20511, "Intimidating Shout", 11, 15, 5_000, 1, 0, sourceAttributes(0, 0, 0, 589_824, 8_388_608), [
effect(6, 12, 0, 6, 0),
effect(6, 87, 15, 6, 0, 0, 1),
]),
childEffects: Object.freeze([
control("stun", 5_000),
Object.freeze({
kind: "damage-taken",
effectIndex: 1,
damageSchool: "physical",
operation: "percent",
value: 0.15,
durationMs: 5_000,
}),
]),
targeting: target(8, 5),
recast: Object.freeze({ sameCaster: "refresh", differentCaster: "preserve-existing" }),
unsupportedParentEffectIndexes: Object.freeze([]),
}),
19134: Object.freeze({
parentSpellId: 19134,
sourceProfile: "ascension",
parentSignature: spell(19134, "Frightening Shout", 7, 10, 4_000, 1, 0, sourceAttributes(327_696, 0), [
effect(64, 0, 1, 6, 0, 0, 0, 29544),
effect(6, 7, 1, 22, 15, 14),
effect(6, 31, 25, 22, 15, 14),
]),
triggerEffectIndex: 0,
childSpellId: 29544,
childSignature: spell(29544, "Frightening Shout", 11, 15, 3_000, 0, 0, sourceAttributes(0, 0), [
effect(6, 7, 0, 6, 0),
]),
childEffects: Object.freeze([control("fear", 3_000)]),
targeting: target(10, 0),
recast: Object.freeze({ sameCaster: "refresh", differentCaster: "preserve-existing" }),
unsupportedParentEffectIndexes: Object.freeze([]),
}),
}),
epoch: Object.freeze({
5246: Object.freeze({
parentSpellId: 5246,
sourceProfile: "epoch",
parentSignature: spell(5246, "Intimidating Shout", 137, 8, 8_000, 1, 5, sourceAttributes(327_696, 136, 0, 0, 0, 0, 8_388_608), [
effect(64, 0, 1, 6, 0, 0, 0, 20511),
effect(6, 7, 1, 22, 15, 14),
effect(6, 31, 25, 22, 15, 14),
]),
triggerEffectIndex: 0,
childSpellId: 20511,
childSignature: spell(20511, "Intimidating Shout", 11, 15, 8_000, 0, 0, sourceAttributes(0, 0), [
effect(6, 12, 0, 6, 0),
]),
childEffects: Object.freeze([control("stun", 8_000)]),
targeting: target(8, 5),
recast: Object.freeze({ sameCaster: "refresh", differentCaster: "preserve-existing" }),
unsupportedParentEffectIndexes: Object.freeze([]),
}),
19134: Object.freeze({
parentSpellId: 19134,
sourceProfile: "epoch",
parentSignature: spell(19134, "Frightening Shout", 7, 10, 6_000, 1, 0, sourceAttributes(327_696, 0), [
effect(64, 0, 1, 6, 0, 0, 0, 29544),
effect(6, 7, 1, 22, 15, 14),
effect(6, 31, 25, 22, 15, 14),
]),
triggerEffectIndex: 0,
childSpellId: 29544,
childSignature: spell(29544, "Frightening Shout", 11, 15, 6_000, 0, 0, sourceAttributes(0, 0), [
effect(6, 7, 0, 6, 0),
]),
childEffects: Object.freeze([control("fear", 6_000)]),
targeting: target(10, 0),
recast: Object.freeze({ sameCaster: "refresh", differentCaster: "preserve-existing" }),
unsupportedParentEffectIndexes: Object.freeze([]),
}),
}),
});
/** Return one exact parent-to-child DBC trigger contract for a pinned profile. */
export function enemyTriggeredShoutContract(
parentSpellId: number,
sourceProfile: EnemyTriggeredShoutProfile,
): EnemyTriggeredShoutContract | undefined {
return contracts[sourceProfile]?.[parentSpellId];
}
export interface EnemyTriggeredShoutMovementSpeedEffect {
readonly effectIndex: 2;
readonly auraType: 31;
readonly increasePercent: 25;
readonly durationMs: number;
readonly utilityTag: string;
readonly modifier: Extract<AuraModifier, { readonly kind: "stat" }>;
}
/** Utility tag for the sourced parent-area AuraType 31 speed bonus. */
export function enemyTriggeredShoutMovementSpeedTag(): string {
return shoutSpeedTag;
}
/**
* Return parent effect 2 only when its pinned effect, target pair and radius
* still match the source AuraType 31 +25% movement-speed row.
*/
export function enemyTriggeredShoutMovementSpeedEffect(
contract: EnemyTriggeredShoutContract,
): EnemyTriggeredShoutMovementSpeedEffect | undefined {
const source = movementSpeed(contract.parentSignature);
if (!source
|| contract.targeting.parentArea.effectIndexes[1] !== source.effectIndex
|| contract.targeting.parentArea.radiusIndex !== 14
|| contract.targeting.parentArea.radiusYards !== 8) return undefined;
return Object.freeze({
...source,
modifier: Object.freeze({
kind: "stat",
stat: "movement-speed",
operation: "percent",
value: source.increasePercent / 100,
}),
});
}
/** Return the strongest active sourced shout speed boost on one target. */
export function enemyTriggeredShoutMovementSpeedMultiplier(
auras: readonly ActiveAura[],
targetId: string,
now: number,
): number {
if (!Number.isFinite(now)) return 1;
let multiplier = 1;
for (const aura of auras) {
if (aura.targetId !== targetId
|| aura.expiresAt === null
|| aura.expiresAt <= now
|| !aura.definition.utilityTags?.includes(shoutSpeedTag)) continue;
for (const modifier of aura.definition.modifiers ?? []) {
if (modifier.kind !== "stat" || modifier.stat !== "movement-speed" || modifier.operation !== "percent"
|| !Number.isFinite(modifier.value)) continue;
const stacks = modifier.perStack === false ? 1 : aura.stacks;
multiplier = Math.max(multiplier, 1 + modifier.value * stacks);
}
}
return multiplier;
}
export interface EnemyTriggeredShoutTargetPlanInput {
/** Target id snapshotted by the active parent cast. */
readonly explicitVictimId: string | undefined;
/** Explicit parent targets that pass the caller's hostile/range/LOS recheck. */
readonly eligibleExplicitVictimIds: readonly string[];
/** Parent caster-centered hostile area recipients after the caller's area query. */
readonly areaRecipientIds: readonly string[];
}
export interface EnemyTriggeredShoutTargetPlan {
/** At most one child recipient: the eligible explicit victim from the parent cast. */
readonly childTargetIds: readonly string[];
/** Unchanged area set, including the explicit victim if it also satisfies the area query. */
readonly parentAreaTargetIds: readonly string[];
readonly childMaximumTargets: 1;
/** Zero is the DBC sentinel for no MaxAffectedTargets limit. */
readonly parentAreaMaximumTargets: number;
}
/** Keep child-victim routing independent from the parent's area-recipient selection. */
export function planEnemyTriggeredShoutTargets(
contract: EnemyTriggeredShoutContract,
input: EnemyTriggeredShoutTargetPlanInput,
): EnemyTriggeredShoutTargetPlan {
const explicitVictimIsEligible = Boolean(input.explicitVictimId)
&& input.eligibleExplicitVictimIds.includes(input.explicitVictimId!);
return Object.freeze({
childTargetIds: Object.freeze(explicitVictimIsEligible ? [input.explicitVictimId!] : []),
parentAreaTargetIds: Object.freeze([...input.areaRecipientIds]),
childMaximumTargets: 1,
parentAreaMaximumTargets: contract.targeting.parentArea.maximumTargets,
});
}
export interface ResolvedEnemyTriggeredShoutControl {
readonly mechanic: "fear" | "stun";
readonly endsAt: number;
}
/**
* Resolve overlapping source shout controls for one actor. The longest active
* control owns the runtime timer; a stun wins when expiries tie. These pinned
* shout controls are all timed, so permanent tagged auras are ignored.
*/
export function resolveEnemyTriggeredShoutControl(
auras: readonly ActiveAura[],
targetId: string,
now: number,
): ResolvedEnemyTriggeredShoutControl | null {
const candidates = auras.flatMap((aura) => {
if (aura.targetId !== targetId || aura.expiresAt === null || aura.expiresAt <= now) return [];
const mechanic: "fear" | "stun" | undefined = aura.definition.utilityTags?.includes(enemyTriggeredShoutControlTag("stun"))
? "stun"
: aura.definition.utilityTags?.includes(enemyTriggeredShoutControlTag("fear"))
? "fear"
: undefined;
return mechanic ? [{ mechanic, endsAt: aura.expiresAt }] : [];
});
if (!candidates.length) return null;
candidates.sort((left, right) => right.endsAt - left.endsAt
|| (left.mechanic === right.mechanic ? 0 : left.mechanic === "stun" ? -1 : 1));
return Object.freeze({ mechanic: candidates[0]!.mechanic, endsAt: candidates[0]!.endsAt });
}