forked from phenom/healer-man
116 lines
7.7 KiB
TypeScript
116 lines
7.7 KiB
TypeScript
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import { abilitiesForClass, abilityAtLevel, abilityTalentNodeId, preloadAbilityCatalogsForClasses, type AbilityDefinition } from "./abilityCatalog";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
import { applyAura } from "./combatAuras";
|
|
import { useCombatStore } from "./combatStore";
|
|
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
|
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
|
|
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
|
import { enemyControlImmunityPurgeProofForAbility } from "./enemyControlImmunityPurge";
|
|
|
|
beforeAll(async () => { await preloadAbilityCatalogsForClasses(["warrior", "death-knight"]); });
|
|
afterEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); });
|
|
function selected(classId: "warrior" | "death-knight", name: string): AbilityDefinition {
|
|
return abilityAtLevel(abilitiesForClass(classId).find((ability) => ability.name === name)!, 80);
|
|
}
|
|
function prepare(recipient: "player" | "party", mechanic: "fear" | "stun", spellId: 19134 | 20511 = 19134) {
|
|
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 80 });
|
|
const member = createConfiguredPartyMember({ name: "Synthetic Protection Warrior", classId: "warrior",
|
|
specialization: "Protection", specializationId: "warrior:protection", talentTreeId: "warrior-protection",
|
|
talentRanks: {}, role: "tank" }, 80, 0, "purge-fixture");
|
|
usePartyStore.setState({ members: [{ ...member, controlledUntil: 9_000, controlMechanic: mechanic }] });
|
|
const id = recipient === "player" ? PLAYER_AGGRO_ID : member.id;
|
|
useCombatStore.getState().synchronizeActors();
|
|
useCombatStore.setState((state) => ({ controlledUntil: recipient === "player" ? 9_000 : 0,
|
|
controlMechanic: recipient === "player" ? mechanic : null,
|
|
auras: [...applyAura(state.auras, { definition: {
|
|
id: `enemy-control:source-spell-${spellId}`, name: "Synthetic pinned shout aura",
|
|
disposition: "debuff", durationMs: 8_000, utilityTags: [`enemy-control-source:${mechanic}`],
|
|
modifiers: [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.15 }],
|
|
}, sourceId: "synthetic-source-caster", targetId: id, now: 1_000 }).auras] }));
|
|
return id;
|
|
}
|
|
function grant(id: string, ability: AbilityDefinition, auraType: number) {
|
|
const effect = ability.effects.find((entry) => entry.kind === "apply-aura" && entry.sourceAuraType === auraType);
|
|
if (!effect || effect.kind !== "apply-aura") throw new Error("Missing actual selected immunity grant");
|
|
// An accepted grant fixture exercises the real shared player/party consumer.
|
|
return useCombatStore.getState().applyActorAura(id, id, ability, effect, 2_000);
|
|
}
|
|
describe("source immunity grants through actual runtime consumers", () => {
|
|
it("casts actual Berserker Rage while feared and removes the source aura permanently", () => {
|
|
prepare("player", "fear");
|
|
const ability = selected("warrior", "Berserker Rage");
|
|
expect(enemyControlImmunityPurgeProofForAbility(ability)).toBeDefined();
|
|
expect(useCombatStore.getState().castAbility(ability.id, { now: 2_000 }).ok).toBe(true);
|
|
expect(useCombatStore.getState().controlledUntil).toBe(0);
|
|
expect(useCombatStore.getState().controlMechanic).toBeNull();
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "enemy-control:source-spell-19134")).toBe(false);
|
|
useCombatStore.getState().tick(0.1, 2_100);
|
|
expect(useCombatStore.getState().controlledUntil).toBe(0);
|
|
});
|
|
it("escapes the source FEAR-mechanic child stun through an actual Berserker Rage cast", () => {
|
|
prepare("player", "stun", 20511);
|
|
expect(useCombatStore.getState().castAbility(selected("warrior", "Berserker Rage").id, { now: 2_000 }).ok).toBe(true);
|
|
expect(useCombatStore.getState().controlMechanic).toBeNull();
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "enemy-control:source-spell-20511")).toBe(false);
|
|
});
|
|
it("blocks Berserker Rage when a separate generic stun overlaps the longer source fear", () => {
|
|
prepare("player", "fear");
|
|
useCombatStore.setState((state) => ({ auras: [...applyAura(state.auras, { definition: {
|
|
id: "enemy-control:another-caster:another-spell:stun", name: "Shorter generic stun",
|
|
disposition: "debuff", durationMs: 3_000,
|
|
}, sourceId: "another-caster", targetId: PLAYER_AGGRO_ID, now: 1_000 }).auras] }));
|
|
expect(useCombatStore.getState().castAbility(selected("warrior", "Berserker Rage").id, { now: 2_000 }))
|
|
.toMatchObject({ ok: false, reason: "player-controlled" });
|
|
});
|
|
it.each([
|
|
["fear", 19134],
|
|
["stun", 20511],
|
|
] as const)("rejects learned Bladestorm while under source %s without spending resources or purging", (mechanic, spellId) => {
|
|
prepare("player", mechanic, spellId);
|
|
const ability = selected("warrior", "Bladestorm");
|
|
const nodeId = abilityTalentNodeId(ability);
|
|
expect(nodeId).not.toBeNull();
|
|
// Synthetic learned rank isolates the cast-control gate from talent-tree allocation.
|
|
useCombatStore.setState({ talentRanks: { [nodeId!]: 1 } });
|
|
const before = useCombatStore.getState();
|
|
expect(useCombatStore.getState().castAbility(ability.id, { now: 2_000 }))
|
|
.toMatchObject({ ok: false, reason: "player-controlled" });
|
|
const after = useCombatStore.getState();
|
|
expect(after.auras).toEqual(before.auras);
|
|
expect(after.controlledUntil).toBe(before.controlledUntil);
|
|
expect(after.controlMechanic).toBe(mechanic);
|
|
expect(after.resourcePools).toEqual(before.resourcePools);
|
|
expect(after.cooldowns).toEqual(before.cooldowns);
|
|
expect(after.globalCooldownEndsAt).toBe(before.globalCooldownEndsAt);
|
|
});
|
|
it.each(["player", "party"] as const)("purges the whole FEAR-mechanic child and its rider for %s on an accepted Bladestorm grant", (recipient) => {
|
|
const id = prepare(recipient, "stun", 20511);
|
|
const ability = selected("warrior", "Bladestorm");
|
|
expect(enemyControlImmunityPurgeProofForAbility(ability)).toBeDefined();
|
|
expect(grant(id, ability, 147)).toBe(true);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === id
|
|
&& aura.definition.id === "enemy-control:source-spell-20511")).toBe(false);
|
|
expect(recipient === "player" ? useCombatStore.getState().controlledUntil
|
|
: usePartyStore.getState().members[0]!.controlledUntil).toBe(0);
|
|
expect(useCombatStore.getState().auras.find((aura) => aura.targetId === id
|
|
&& aura.definition.controlImmunities?.includes("fear"))?.definition.controlImmunities).toContain("stun");
|
|
});
|
|
it.each(["player", "party"] as const)("preserves source stun and its rider under non-purging Icebound Fortitude for %s", (recipient) => {
|
|
const id = prepare(recipient, "stun", 20511);
|
|
expect(grant(id, selected("death-knight", "Icebound Fortitude"), 77)).toBe(true);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === id
|
|
&& aura.definition.id === "enemy-control:source-spell-20511")).toBe(true);
|
|
expect(recipient === "player" ? useCombatStore.getState().controlledUntil
|
|
: usePartyStore.getState().members[0]!.controlledUntil).toBe(9_000);
|
|
});
|
|
it("retains a separate generic stun after a bounded source shout purge", () => {
|
|
const id = prepare("party", "fear");
|
|
useCombatStore.setState((state) => ({ auras: [...applyAura(state.auras, { definition: {
|
|
id: "enemy-control:synthetic-other:cast:stun", name: "Separate generic stun", disposition: "debuff", durationMs: 5_000,
|
|
}, sourceId: "another-caster", targetId: id, now: 1_000 }).auras] }));
|
|
expect(grant(id, selected("warrior", "Berserker Rage"), 77)).toBe(true);
|
|
expect(usePartyStore.getState().members[0]).toMatchObject({ controlledUntil: 6_000, controlMechanic: "stun" });
|
|
});
|
|
});
|