195 lines
12 KiB
TypeScript
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");
|
|
});
|
|
});
|