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 { 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, 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>>> = 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; } /** 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 }); }