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

195 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { applyAura } from "./combatAuras";
import { useCombatStore } from "./combatStore";
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { clearMobRuntimeRegistry, setMobPosition, type CombatPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { enemyTriggeredShoutMovementSpeedMultiplier } from "./enemyTriggeredShouts";
import { WOW335_PALADIN_AURA_MASTERY_EFFECTIVENESS_TAG } from "./wow335PaladinAuraMasteryRuntime";
const MOB = "source-triggered-shout";
const positions = new Map<string, CombatPosition>();
const sourceAuraId = (spellId: number) => `enemy-control:source-spell-${spellId}`;
const auras = (spellId: number) => useCombatStore.getState().auras.filter((aura) => aura.definition.id === sourceAuraId(spellId));
beforeEach(() => {
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); positions.clear();
resetCombatSpatialProvider();
});
afterEach(() => resetCombatSpatialProvider());
function prepare(playerPosition: CombatPosition, memberPositions: readonly CombatPosition[], statRuleset: "wow" | "rom" = "wow") {
useCombatStore.getState().initializeCharacter({
classId: statRuleset === "rom" ? "rom-priest" : "warrior",
...(statRuleset === "rom" ? { secondaryClassId: "rom-knight" as const } : {}), level: 30, statRuleset,
});
useCombatStore.getState().setPlayerPosition(playerPosition);
usePartyStore.getState().initializeParty("source-shout-fixture", 30, useCombatStore.getState().health,
"damage", 2, statRuleset === "rom" ? "rom" : "wow");
const original = usePartyStore.getState().members[0]!;
// Explicit spatial fixture, including >5 actors to exercise the source cap.
const members = memberPositions.map((_, index) => ({ ...original, id: `shout-member-${index}` }));
usePartyStore.setState({ members });
memberPositions.forEach((position, index) => {
registerPartyRuntimePosition(members[index]!.id, position); positions.set(members[index]!.id, position);
});
positions.set(PLAYER_AGGRO_ID, playerPosition); positions.set(MOB, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
registerCombatSpatialProvider({
actor: (id) => positions.has(id) ? { id, position: positions.get(id)!, yaw: 0 } : null,
hasLineOfSight: (_source, target) => target[0] !== 3,
projectGroundPoint: (position) => position,
});
return members;
}
function engage(spellId: 5246 | 19134, castTimeMs = 0, sourceProfile: "ascension" | "epoch" = "ascension") {
const attack = requireDungeonDefinition("stormwind-stockade").entities["entry-1663"]!.combat!.attacks
.find((candidate) => candidate.spellId === spellId)!;
expect(attack.effect?.kind).toBe("triggered-shout");
if (attack.effect?.kind !== "triggered-shout") throw new Error("Expected source shout fixture");
useCombatStore.getState().registerMob(MOB, { name: "Dextren source fixture", serverEntry: 1663,
level: 30, maxHealth: 100_000, xpReward: 0,
attacks: [{ ...attack, effect: { ...attack.effect, sourceProfile }, castTimeMs,
initialCooldownMs: [0, 0], repeatCooldownMs: [60_000, 60_000], maximumCasts: 1 }],
});
setMobPosition(MOB, [0, 0, 0]);
useCombatStore.getState().damageMob(MOB, 1, 1_000);
useCombatStore.getState().advanceMobCombat(1_000);
if (!castTimeMs) useCombatStore.getState().advanceMobCombat(2_650);
}
describe("actual source shout victim and area consumers", () => {
it.each(["player", "party"] as const)("shortens the whole parent fear/speed aura for a %s duration modifier", (recipient) => {
const [member] = prepare([2, 0, 0], [[5, 0, 0]]);
const actorId = recipient === "player" ? PLAYER_AGGRO_ID : member!.id;
useCombatStore.setState((state) => ({ auras: [...applyAura(state.auras, {
definition: { id: "synthetic-source-duration-modifier", name: "Synthetic sourced duration modifier",
disposition: "buff", durationMs: 10_000, utilityTags: [WOW335_PALADIN_AURA_MASTERY_EFFECTIVENESS_TAG] },
sourceId: actorId, targetId: actorId, now: 1_000,
}).auras] }));
engage(19134);
const parent = auras(19134).find((aura) => aura.targetId === actorId)!;
expect(parent.expiresAt! - parent.appliedAt).toBe(2_000);
expect(enemyTriggeredShoutMovementSpeedMultiplier(useCombatStore.getState().auras, actorId, parent.expiresAt! - 1)).toBe(1.25);
expect(enemyTriggeredShoutMovementSpeedMultiplier(useCombatStore.getState().auras, actorId, parent.expiresAt!)).toBe(1);
});
it.each(["player", "party"] as const)("rejects the whole shout and its riders on a fear-immune %s", (recipient) => {
const [member] = prepare([2, 0, 0], [[5, 0, 0]]);
const actorId = recipient === "player" ? PLAYER_AGGRO_ID : member!.id;
useCombatStore.setState((state) => ({ auras: [...applyAura(state.auras, {
definition: { id: "synthetic-fear-immunity", name: "Synthetic fear immunity",
disposition: "buff", durationMs: 10_000, controlImmunities: ["fear"] },
sourceId: actorId, targetId: actorId, now: 1_000,
}).auras] }));
engage(5246);
expect(auras(20511).filter((aura) => aura.targetId === actorId)).toHaveLength(0);
expect(auras(5246).filter((aura) => aura.targetId === actorId)).toHaveLength(0);
expect(enemyTriggeredShoutMovementSpeedMultiplier(useCombatStore.getState().auras, actorId, 2_650)).toBe(1);
expect(recipient === "player" ? useCombatStore.getState().controlledUntil
: usePartyStore.getState().members.find((entry) => entry.id === actorId)!.controlledUntil).toBe(0);
});
it("keeps the Ascension child physical rider under stun immunity and applies parent fear plus speed", () => {
prepare([2, 0, 0], []);
useCombatStore.setState((state) => ({ auras: [...applyAura(state.auras, {
definition: { id: "synthetic-stun-immunity", name: "Synthetic stun immunity",
disposition: "buff", durationMs: 10_000, controlImmunities: ["stun"] },
sourceId: PLAYER_AGGRO_ID, targetId: PLAYER_AGGRO_ID, now: 1_000,
}).auras] }));
engage(5246);
expect(auras(20511)).toHaveLength(1);
expect(auras(20511)[0]!.definition.utilityTags).not.toContain("enemy-control-source:stun");
expect(auras(20511)[0]!.definition.modifiers).toContainEqual({
kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.15,
});
expect(useCombatStore.getState().controlMechanic).toBe("fear");
expect(enemyTriggeredShoutMovementSpeedMultiplier(useCombatStore.getState().auras, PLAYER_AGGRO_ID, 2_650)).toBe(1.25);
});
it.each([
["ascension", 5246, 5_000], ["ascension", 19134, 4_000],
["epoch", 5246, 8_000], ["epoch", 19134, 6_000],
] as const)("stores %s spell %i speed for player and party until its %ims expiry", (profile, spellId, durationMs) => {
const [member] = prepare([2, 0, 0], [[5, 0, 0]]);
engage(spellId, 0, profile);
const state = useCombatStore.getState();
const expiresAt = auras(spellId)[0]!.expiresAt!;
for (const actorId of [PLAYER_AGGRO_ID, member!.id]) {
const parent = auras(spellId).find((aura) => aura.targetId === actorId)!;
expect(parent.expiresAt! - parent.appliedAt).toBe(durationMs);
expect(enemyTriggeredShoutMovementSpeedMultiplier(state.auras, actorId, parent.appliedAt)).toBe(1.25);
expect(enemyTriggeredShoutMovementSpeedMultiplier(state.auras, actorId, parent.expiresAt!)).toBe(1);
}
useCombatStore.getState().damageMob(MOB, 1_000_000, 2_651);
expect(enemyTriggeredShoutMovementSpeedMultiplier(useCombatStore.getState().auras, member!.id, 2_652)).toBe(1.25);
useCombatStore.getState().advanceMobCombat(expiresAt + 1);
expect(enemyTriggeredShoutMovementSpeedMultiplier(useCombatStore.getState().auras, member!.id, expiresAt + 1)).toBe(1);
});
it.each(["wow", "rom"] as const)("keeps the 19134 child on an outside-area %s victim and reaches nearby actors without area LOS", (statRuleset) => {
const [nearby, outside] = prepare([9, 0, 0], [[3, 0, 0], [12, 0, 0]], statRuleset);
engage(19134);
expect(auras(29544).map((aura) => aura.targetId)).toEqual([PLAYER_AGGRO_ID]);
expect(auras(19134).map((aura) => aura.targetId)).toEqual([nearby!.id]);
expect(auras(29544)[0]!.expiresAt! - auras(29544)[0]!.appliedAt).toBe(3_000);
expect(auras(19134)[0]!.expiresAt! - auras(19134)[0]!.appliedAt).toBe(4_000);
expect(usePartyStore.getState().members.find((member) => member.id === nearby!.id)!.controlMechanic).toBe("fear");
expect(usePartyStore.getState().members.find((member) => member.id === outside!.id)!.controlledUntil).toBe(0);
expect(useCombatStore.getState().combatEvents.filter((event) => event.sourceActorId === MOB && event.kind === "damage")).toHaveLength(0);
});
it("keeps the captured child victim when threat changes and stores child stun plus area fear", () => {
const [member] = prepare([2, 0, 0], [[5, 0, 0]]);
engage(5246, 2_000);
expect(useCombatStore.getState().mobs[MOB]!.activeCast?.targetActorId).toBe(PLAYER_AGGRO_ID);
useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [MOB]: { ...state.mobs[MOB]!,
threatByActor: { [PLAYER_AGGRO_ID]: 1, [member!.id]: 1_000 }, targetActorId: member!.id } } }));
useCombatStore.getState().advanceMobCombat(3_000);
expect(auras(20511).map((aura) => aura.targetId)).toEqual([PLAYER_AGGRO_ID]);
expect(new Set(auras(5246).map((aura) => aura.targetId))).toEqual(new Set([PLAYER_AGGRO_ID, member!.id]));
expect(auras(20511)[0]!.definition.modifiers).toContainEqual({
kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.15,
});
expect(useCombatStore.getState().controlMechanic).toBe("stun");
expect(auras(20511)[0]!.definition.dispelCategory).toBeUndefined();
useCombatStore.getState().damageMob(MOB, 1_000_000, 3_001);
expect(useCombatStore.getState().mobs[MOB]!.dead).toBe(true);
expect(auras(20511)).toHaveLength(1);
expect(auras(5246)).toHaveLength(2);
});
it("caps the shared 5246 area set at five while preserving the independently selected child", () => {
prepare([2, 0, 0], Array.from({ length: 6 }, (_, index) => [4, 0, index * 0.2] as CombatPosition));
engage(5246);
expect(auras(20511).map((aura) => aura.targetId)).toEqual([PLAYER_AGGRO_ID]);
expect(auras(5246)).toHaveLength(5);
expect(new Set(auras(5246).map((aura) => aura.targetId)).size).toBe(5);
});
it.each(["player", "party"] as const)("retains the %s source control when a separate magic control is dispelled", (recipient) => {
const [member] = prepare([2, 0, 0], [[3, 0, 0]]);
engage(19134);
const actorId = recipient === "player" ? PLAYER_AGGRO_ID : member!.id;
const source = auras(19134).find((aura) => aura.targetId === actorId)!;
const now = source.appliedAt + 1;
expect(useCombatStore.getState().dispelActor(actorId, ["magic"], 10, now)).toBe(0);
useCombatStore.setState((state) => ({
auras: [...applyAura(state.auras, { definition: {
id: "enemy-control:synthetic-extra-magic", name: "Synthetic separate magic control",
disposition: "debuff", durationMs: 10_000, dispelCategory: "magic",
}, sourceId: "synthetic-source", targetId: actorId, now }).auras],
...(recipient === "player" ? { controlledUntil: now + 10_000, controlMechanic: "stun" as const } : {}),
}));
expect(useCombatStore.getState().dispelActor(actorId, ["magic"], 1, now + 1)).toBe(1);
const state = useCombatStore.getState();
expect(recipient === "player" ? state.controlledUntil : state.actors[actorId]!.controlledUntil).toBe(source.expiresAt);
expect(recipient === "player" ? state.controlMechanic
: usePartyStore.getState().members.find((entry) => entry.id === actorId)!.controlMechanic).toBe("fear");
});
});