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

258 lines
12 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { applyAura } from "./combatAuras";
import { applyEnemySourceAura } from "./enemySourceAuras";
import {
enemyTriggeredShoutContract,
enemyTriggeredShoutControlTag,
enemyTriggeredShoutMovementSpeedEffect,
enemyTriggeredShoutMovementSpeedMultiplier,
enemyTriggeredShoutMovementSpeedTag,
planEnemyTriggeredShoutTargets,
resolveEnemyTriggeredShoutControl,
} from "./enemyTriggeredShouts";
describe("pinned triggered enemy shout contracts", () => {
it("pins only parent spells with exact DBC child trigger IDs", () => {
for (const profile of ["ascension", "epoch"] as const) {
expect(enemyTriggeredShoutContract(5246, profile)).toMatchObject({
parentSpellId: 5246,
childSpellId: 20511,
triggerEffectIndex: 0,
parentSignature: {
spellMechanic: 5,
dispel: 0,
durationMs: profile === "ascension" ? 5_000 : 8_000,
attributes0: profile === "epoch" ? 327_696 : 0,
attributes1: profile === "epoch" ? 136 : 0,
attributes2: 0,
attributes3: 0,
attributes4: 0,
attributes5: 0,
attributes6: profile === "epoch" ? 8_388_608 : 33_554_432,
attributes7: 0,
damageClass: 1,
maxAffectedTargets: 5,
effects: [
{ effectType: 64, implicitTargetA: 6, triggerSpellId: 20511 },
{ effectType: 6, auraType: 7, implicitTargetA: 22, implicitTargetB: 15, radiusIndex: 14 },
{ effectType: 6, auraType: 31, implicitTargetA: 22, implicitTargetB: 15, radiusIndex: 14 },
],
},
childSignature: {
spellMechanic: 5,
dispel: 0,
stackAmount: 0,
damageClass: profile === "ascension" ? 1 : 0,
attributes0: 0,
attributes1: 0,
attributes2: 0,
attributes3: profile === "ascension" ? 589_824 : 0,
attributes4: profile === "ascension" ? 8_388_608 : 0,
attributes5: 0,
attributes6: 0,
attributes7: 0,
effects: profile === "ascension"
? [{ effectType: 6, auraType: 12, implicitTargetA: 6 }, { effectType: 6, auraType: 87, basePoints: 15, miscValue: 1 }]
: [{ effectType: 6, auraType: 12, implicitTargetA: 6 }],
},
childEffects: profile === "ascension"
? [{ kind: "control", mechanic: "stun", durationMs: 5_000 }, {
kind: "damage-taken", damageSchool: "physical", operation: "percent", value: 0.15, durationMs: 5_000,
}]
: [{ kind: "control", mechanic: "stun", durationMs: 8_000 }],
});
expect(enemyTriggeredShoutContract(19134, profile)).toMatchObject({
parentSpellId: 19134,
childSpellId: 29544,
triggerEffectIndex: 0,
parentSignature: {
spellMechanic: 5,
dispel: 0,
durationMs: profile === "ascension" ? 4_000 : 6_000,
attributes0: 327_696,
attributes1: 0,
attributes2: 0,
attributes3: 0,
attributes4: 0,
attributes5: 0,
attributes6: 0,
attributes7: 0,
damageClass: 1,
maxAffectedTargets: 0,
effects: [
{ effectType: 64, implicitTargetA: 6, triggerSpellId: 29544 },
{ effectType: 6, auraType: 7, implicitTargetA: 22, implicitTargetB: 15, radiusIndex: 14 },
{ effectType: 6, auraType: 31, implicitTargetA: 22, implicitTargetB: 15, radiusIndex: 14 },
],
},
childSignature: {
spellMechanic: 5,
dispel: 0,
stackAmount: 0,
damageClass: 0,
durationMs: profile === "ascension" ? 3_000 : 6_000,
attributes0: 0,
attributes1: 0,
attributes2: 0,
attributes3: 0,
attributes4: 0,
attributes5: 0,
attributes6: 0,
attributes7: 0,
},
childEffects: [{ kind: "control", mechanic: "fear", durationMs: profile === "ascension" ? 3_000 : 6_000 }],
});
}
expect(enemyTriggeredShoutContract(9128, "ascension")).toBeUndefined();
expect(enemyTriggeredShoutContract(16244, "epoch")).toBeUndefined();
expect(enemyTriggeredShoutContract(13730, "ascension")).toBeUndefined();
});
it("pins spell-mechanic immunity and the absence of DBC always-hit or immunity bypass flags", () => {
const bypassFlags = {
attributes0: 0x20000000,
attributes2: 0x04000000,
attributes3: 0x00040000,
attributes4: 0x00000800,
attributes7: 0x02000000,
} as const;
for (const profile of ["ascension", "epoch"] as const) {
const spells = [5246, 19134].flatMap((parentSpellId) => {
const contract = enemyTriggeredShoutContract(parentSpellId, profile)!;
return [contract.parentSignature, contract.childSignature];
});
expect(spells.every((signature) => signature.spellMechanic === 5)).toBe(true);
for (const signature of spells) {
expect(signature.dispel).toBe(0);
expect(signature.attributes0 & bypassFlags.attributes0).toBe(0);
expect(signature.attributes2 & bypassFlags.attributes2).toBe(0);
expect(signature.attributes3 & bypassFlags.attributes3).toBe(0);
expect(signature.attributes4 & bypassFlags.attributes4).toBe(0);
expect(signature.attributes7 & bypassFlags.attributes7).toBe(0);
}
expect(enemyTriggeredShoutContract(5246, profile)!.parentSignature.damageClass).toBe(1);
expect(enemyTriggeredShoutContract(19134, profile)!.parentSignature.damageClass).toBe(1);
expect(enemyTriggeredShoutContract(5246, profile)!.childSignature.damageClass)
.toBe(profile === "ascension" ? 1 : 0);
expect(enemyTriggeredShoutContract(19134, profile)!.childSignature.damageClass).toBe(0);
}
});
it("sources the parent aura-31 speed bonus and refreshes its target-local timer", () => {
const durationFor = (parentSpellId: number, profile: "ascension" | "epoch") => (
parentSpellId === 5246 ? profile === "ascension" ? 5_000 : 8_000 : profile === "ascension" ? 4_000 : 6_000
);
const makeSpeedDefinition = (parentSpellId: number, profile: "ascension" | "epoch") => {
const sourced = enemyTriggeredShoutMovementSpeedEffect(enemyTriggeredShoutContract(parentSpellId, profile)!);
expect(sourced).toMatchObject({
effectIndex: 2,
auraType: 31,
increasePercent: 25,
durationMs: durationFor(parentSpellId, profile),
utilityTag: "enemy-triggered-shout:parent-movement-speed",
modifier: { kind: "stat", stat: "movement-speed", operation: "percent", value: 0.25 },
});
return {
id: `enemy-triggered-shout-speed:${parentSpellId}`,
name: "Enemy shout movement speed",
disposition: "buff" as const,
durationMs: sourced!.durationMs,
stackBehavior: "refresh" as const,
utilityTags: [sourced!.utilityTag],
modifiers: [sourced!.modifier],
};
};
for (const profile of ["ascension", "epoch"] as const) {
for (const parentSpellId of [5246, 19134]) {
const effect = enemyTriggeredShoutMovementSpeedEffect(enemyTriggeredShoutContract(parentSpellId, profile)!);
expect(effect?.durationMs).toBe(durationFor(parentSpellId, profile));
expect(effect?.utilityTag).toBe(enemyTriggeredShoutMovementSpeedTag());
}
}
const contract = enemyTriggeredShoutContract(5246, "ascension")!;
const definition = makeSpeedDefinition(5246, "ascension");
let auras = applyEnemySourceAura([], definition, "caster-a", "victim", 0);
expect(enemyTriggeredShoutMovementSpeedMultiplier(auras, "victim", 4_999)).toBe(1.25);
expect(enemyTriggeredShoutMovementSpeedMultiplier(auras, "other-victim", 1_000)).toBe(1);
auras = applyEnemySourceAura(auras, definition, "caster-a", "victim", 2_000);
expect(auras.find((aura) => aura.definition.id === definition.id)?.expiresAt).toBe(7_000);
expect(enemyTriggeredShoutMovementSpeedMultiplier(auras, "victim", 6_999)).toBe(1.25);
expect(enemyTriggeredShoutMovementSpeedMultiplier(auras, "victim", 7_000)).toBe(1);
const otherCaster = applyEnemySourceAura(auras, definition, "caster-b", "victim", 3_000);
expect(otherCaster).toBe(auras);
const secondShout = enemyTriggeredShoutMovementSpeedEffect(enemyTriggeredShoutContract(19134, "epoch")!);
const otherSpellAura = applyAura(auras, {
definition: {
id: "enemy-triggered-shout-speed:19134",
name: "Other shout movement speed",
disposition: "buff",
durationMs: secondShout!.durationMs,
stackBehavior: "refresh",
utilityTags: [secondShout!.utilityTag],
modifiers: [secondShout!.modifier],
},
sourceId: "caster-b",
targetId: "victim",
now: 2_000,
}).auras;
expect(enemyTriggeredShoutMovementSpeedMultiplier(otherSpellAura, "victim", 3_000)).toBe(1.25);
expect(enemyTriggeredShoutContract(contract.parentSpellId, "ascension")?.parentSignature.effects[2])
.toMatchObject({ effectType: 6, auraType: 31, basePoints: 25, implicitTargetA: 22, implicitTargetB: 15, radiusIndex: 14 });
});
it("routes only the cast victim to a child and leaves the area list independent", () => {
const contract = enemyTriggeredShoutContract(5246, "ascension")!;
const plan = planEnemyTriggeredShoutTargets(contract, {
explicitVictimId: "victim",
eligibleExplicitVictimIds: ["victim"],
areaRecipientIds: ["victim", "nearby-a", "nearby-b", "nearby-c", "nearby-d", "nearby-e"],
});
expect(plan).toEqual({
childTargetIds: ["victim"],
parentAreaTargetIds: ["victim", "nearby-a", "nearby-b", "nearby-c", "nearby-d", "nearby-e"],
childMaximumTargets: 1,
parentAreaMaximumTargets: 5,
});
const noLongerEligible = planEnemyTriggeredShoutTargets(contract, {
explicitVictimId: "victim",
eligibleExplicitVictimIds: [],
areaRecipientIds: ["nearby-a"],
});
expect(noLongerEligible.childTargetIds).toEqual([]);
expect(noLongerEligible.parentAreaTargetIds).toEqual(["nearby-a"]);
expect(enemyTriggeredShoutContract(19134, "ascension")!.targeting.parentArea.maximumTargets).toBe(0);
});
it("resolves remaining overlapping control by longest expiry, then stun on a tie", () => {
const sourceAura = (id: string, mechanic: "fear" | "stun", targetId: string, durationMs: number) => applyAura([], {
definition: {
id,
name: id,
disposition: "debuff",
durationMs,
stackBehavior: "refresh",
utilityTags: [enemyTriggeredShoutControlTag(mechanic)],
},
sourceId: "shout-caster",
targetId,
now: 0,
}).aura;
const parentFear = sourceAura("enemy-control:source-spell-19134", "fear", "victim", 4_000);
const childFear = sourceAura("enemy-control:source-spell-29544", "fear", "victim", 3_000);
const otherTargetStun = sourceAura("enemy-control:source-spell-20511", "stun", "other", 8_000);
expect(resolveEnemyTriggeredShoutControl([parentFear, childFear, otherTargetStun], "victim", 1_000))
.toEqual({ mechanic: "fear", endsAt: 4_000 });
const parentFearTie = sourceAura("enemy-control:source-spell-5246", "fear", "victim", 8_000);
const childStunTie = sourceAura("enemy-control:source-spell-20511", "stun", "victim", 8_000);
expect(resolveEnemyTriggeredShoutControl([parentFearTie, childStunTie], "victim", 1_000))
.toEqual({ mechanic: "stun", endsAt: 8_000 });
expect(resolveEnemyTriggeredShoutControl([parentFear, childFear], "victim", 4_000)).toBeNull();
expect(resolveEnemyTriggeredShoutControl([parentFear], "missing", 1_000)).toBeNull();
});
});