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