329 lines
15 KiB
TypeScript
329 lines
15 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
import { abilitiesForClass, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
|
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
|
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import {
|
|
clearPartyRuntimeRegistry,
|
|
registerPartyRuntimePosition,
|
|
updatePartyRuntimeTargetVisibility,
|
|
} from "./partyRuntimeRegistry";
|
|
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
|
import { EMPTY_ITEM_STATS } from "./itemStats";
|
|
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
|
import {
|
|
WOW335_SHAMAN_NATURES_GUARDIAN_COOLDOWN_TAG,
|
|
WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG,
|
|
} from "./wow335ShamanNaturesGuardianRuntime";
|
|
|
|
const TARGET_ID = "wow335-shaman-natures-guardian-target";
|
|
const ENEMY_HIT_AT = 1_550;
|
|
const PLAYER_NEXT_ACTION_AT = 2_000;
|
|
|
|
function natureGuardianAuras(actorId: string) {
|
|
return useCombatStore.getState().auras.filter((aura) => aura.sourceId === actorId && aura.targetId === actorId);
|
|
}
|
|
|
|
function armShamanForHit(targetActorId: string): void {
|
|
const actorId = targetActorId === PLAYER_AURA_ENTITY_ID ? PLAYER_AGGRO_ID : targetActorId;
|
|
useCombatStore.getState().registerMob(TARGET_ID, {
|
|
name: "Nature's Guardian target",
|
|
level: 80,
|
|
maxHealth: 1_000_000,
|
|
xpReward: 0,
|
|
attacks: [{
|
|
...DEFAULT_ENEMY_ATTACK,
|
|
id: "nature-guardian-incoming-hit",
|
|
name: "Nature Guardian hit",
|
|
delivery: "projectile",
|
|
school: "physical",
|
|
range: 30,
|
|
damageMultiplier: 6,
|
|
}],
|
|
});
|
|
setMobPosition(TARGET_ID, [2, 0, 0]);
|
|
useCombatStore.getState().damageMob(
|
|
TARGET_ID,
|
|
1,
|
|
100,
|
|
{ actorId, role: "damage", abilityId: "nature-guardian-test-engage" },
|
|
"physical",
|
|
);
|
|
}
|
|
|
|
describe("WoW Shaman Nature's Guardian player and companion integration", () => {
|
|
beforeEach(async () => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetPartyRuntimeClock();
|
|
await preloadAbilityCatalogsForClasses(["shaman", "mage"]);
|
|
useCombatStore.getState().initializeCharacter({ classId: "shaman", role: "damage", level: 80, xp: 0 });
|
|
useCombatStore.setState({ auras: [], effects: [], combatEvents: [] });
|
|
usePartyStore.getState().initializeParty(
|
|
"wow-shaman-natures-guardian-player",
|
|
80,
|
|
useCombatStore.getState().health,
|
|
"damage",
|
|
1,
|
|
);
|
|
});
|
|
|
|
it("arms on the player's direct hit below 30%, then spends one charge on a Nature damage spell", () => {
|
|
useCombatStore.setState((state) => ({
|
|
health: Math.round(state.maxHealth * 0.4),
|
|
talentRanks: { ...state.talentRanks, "talent-1699": 3 },
|
|
}));
|
|
armShamanForHit(PLAYER_AURA_ENTITY_ID);
|
|
useCombatStore.getState().advanceMobCombat(ENEMY_HIT_AT);
|
|
|
|
expect(useCombatStore.getState().health).toBeGreaterThan(0);
|
|
expect(useCombatStore.getState().health).toBeLessThan(useCombatStore.getState().maxHealth * 0.3);
|
|
expect(natureGuardianAuras(PLAYER_AURA_ENTITY_ID)).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ definition: expect.objectContaining({
|
|
utilityTags: expect.arrayContaining([WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG]),
|
|
}) }),
|
|
expect.objectContaining({ definition: expect.objectContaining({
|
|
utilityTags: expect.arrayContaining([WOW335_SHAMAN_NATURES_GUARDIAN_COOLDOWN_TAG]),
|
|
}) }),
|
|
]));
|
|
|
|
const lightningBolt = abilitiesForClass("shaman").find((ability) => ability.name === "Lightning Bolt");
|
|
expect(lightningBolt).toBeDefined();
|
|
const cast = useCombatStore.getState().castAbility(lightningBolt!.id, { targetActorId: TARGET_ID, now: PLAYER_NEXT_ACTION_AT });
|
|
expect(cast.ok, cast.reason).toBe(true);
|
|
const activeCast = useCombatStore.getState().activeCast;
|
|
if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt);
|
|
|
|
const damageEvent = [...useCombatStore.getState().combatEvents].reverse().find((event) => (
|
|
event.kind === "damage" && event.sourceActorId === PLAYER_AGGRO_ID && event.abilityId === lightningBolt!.id
|
|
));
|
|
expect(damageEvent?.critical).toBe(true);
|
|
expect(natureGuardianAuras(PLAYER_AURA_ENTITY_ID).some((aura) => (
|
|
aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG)
|
|
))).toBe(false);
|
|
expect(natureGuardianAuras(PLAYER_AURA_ENTITY_ID).some((aura) => (
|
|
aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_COOLDOWN_TAG)
|
|
))).toBe(true);
|
|
});
|
|
|
|
it("spends the player's charge on an accepted Nature healing spell and guarantees its critical heal", () => {
|
|
useCombatStore.setState((state) => ({
|
|
health: Math.round(state.maxHealth * 0.4),
|
|
talentRanks: { ...state.talentRanks, "talent-1699": 3 },
|
|
}));
|
|
armShamanForHit(PLAYER_AURA_ENTITY_ID);
|
|
useCombatStore.getState().advanceMobCombat(ENEMY_HIT_AT);
|
|
expect(natureGuardianAuras(PLAYER_AURA_ENTITY_ID)).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ definition: expect.objectContaining({
|
|
utilityTags: expect.arrayContaining([WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG]),
|
|
}) }),
|
|
]));
|
|
|
|
const healingWave = abilitiesForClass("shaman").find((ability) => ability.name === "Healing Wave");
|
|
expect(healingWave).toBeDefined();
|
|
const rejected = useCombatStore.getState().castAbility(healingWave!.id, {
|
|
targetActorId: "missing-friendly-target",
|
|
now: PLAYER_NEXT_ACTION_AT,
|
|
});
|
|
expect(rejected.ok).toBe(false);
|
|
expect(natureGuardianAuras(PLAYER_AURA_ENTITY_ID).some((aura) => (
|
|
aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG)
|
|
))).toBe(true);
|
|
const cast = useCombatStore.getState().castAbility(healingWave!.id, {
|
|
now: PLAYER_NEXT_ACTION_AT,
|
|
});
|
|
expect(cast.ok, cast.reason).toBe(true);
|
|
const activeCast = useCombatStore.getState().activeCast;
|
|
expect(activeCast, "Healing Wave remains charged while casting").toBeDefined();
|
|
expect(natureGuardianAuras(PLAYER_AURA_ENTITY_ID).some((aura) => (
|
|
aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG)
|
|
))).toBe(true);
|
|
expect(useCombatStore.getState().cancelCast("test interruption", PLAYER_NEXT_ACTION_AT + 500)).toBe(true);
|
|
expect(natureGuardianAuras(PLAYER_AURA_ENTITY_ID).some((aura) => (
|
|
aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG)
|
|
))).toBe(true);
|
|
const resolvedCast = useCombatStore.getState().castAbility(healingWave!.id, {
|
|
now: PLAYER_NEXT_ACTION_AT + 2_000,
|
|
});
|
|
expect(resolvedCast.ok, resolvedCast.reason).toBe(true);
|
|
const resolvedActiveCast = useCombatStore.getState().activeCast;
|
|
expect(resolvedActiveCast, "the retry begins a new Healing Wave cast").toBeDefined();
|
|
useCombatStore.getState().tick(0.1, resolvedActiveCast!.completesAt);
|
|
|
|
const healingEvent = [...useCombatStore.getState().combatEvents].reverse().find((event) => (
|
|
event.kind === "healing" && event.sourceActorId === PLAYER_AGGRO_ID
|
|
&& event.targetActorId === PLAYER_AURA_ENTITY_ID && event.abilityId === healingWave!.id
|
|
));
|
|
expect(healingEvent?.critical).toBe(true);
|
|
expect(natureGuardianAuras(PLAYER_AURA_ENTITY_ID).some((aura) => (
|
|
aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG)
|
|
))).toBe(false);
|
|
});
|
|
|
|
it("uses the companion's own rank for the incoming proc and consumes one charge after its accepted Nature spell", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80, xp: 0 });
|
|
usePartyStore.getState().initializeParty(
|
|
"wow-shaman-natures-guardian-companion",
|
|
80,
|
|
useCombatStore.getState().health,
|
|
"damage",
|
|
1,
|
|
);
|
|
const lightningBolt = abilitiesForClass("shaman").find((ability) => ability.name === "Lightning Bolt");
|
|
expect(lightningBolt).toBeDefined();
|
|
const shamanBase = createConfiguredPartyMember({
|
|
name: "Nature's Guardian Shaman",
|
|
classId: "shaman",
|
|
specialization: "Elemental",
|
|
specializationId: "shaman:elemental",
|
|
talentTreeId: "shaman-elemental",
|
|
talentRanks: { "talent-1699": 3 },
|
|
role: "damage",
|
|
}, 80, 0, "wow-shaman-natures-guardian-companion");
|
|
const disabledCooldowns = Object.fromEntries(abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000]));
|
|
const member = {
|
|
...shamanBase,
|
|
gearStats: EMPTY_ITEM_STATS,
|
|
health: Math.round(shamanBase.maxHealth * 0.4),
|
|
cooldowns: disabledCooldowns,
|
|
nextActionAt: 1_000_000,
|
|
activeCast: null,
|
|
activeCastTargetId: null,
|
|
status: "ready" as const,
|
|
};
|
|
usePartyStore.setState({ members: [member], command: "attack", activeMobId: TARGET_ID });
|
|
useCombatStore.getState().commitCompanionActors([member]);
|
|
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
|
useCombatStore.setState((state) => ({
|
|
actors: {
|
|
...state.actors,
|
|
[member.id]: { ...state.actors[member.id]!, health: member.health, alive: true },
|
|
},
|
|
}));
|
|
armShamanForHit(member.id);
|
|
updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true);
|
|
useCombatStore.getState().advanceMobCombat(ENEMY_HIT_AT);
|
|
|
|
const actorAfterHit = useCombatStore.getState().actors[member.id];
|
|
expect(actorAfterHit?.health).toBeGreaterThan(0);
|
|
expect(actorAfterHit?.health).toBeLessThan(actorAfterHit!.maxHealth * 0.3);
|
|
expect(natureGuardianAuras(member.id)).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ definition: expect.objectContaining({
|
|
utilityTags: expect.arrayContaining([WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG]),
|
|
}) }),
|
|
expect.objectContaining({ definition: expect.objectContaining({
|
|
utilityTags: expect.arrayContaining([WOW335_SHAMAN_NATURES_GUARDIAN_COOLDOWN_TAG]),
|
|
}) }),
|
|
]));
|
|
|
|
const readyForBolt = {
|
|
...usePartyStore.getState().members[0]!,
|
|
cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((ability) => [
|
|
ability.id,
|
|
ability.id === lightningBolt!.id ? 0 : 1_000_000,
|
|
])),
|
|
nextActionAt: PLAYER_NEXT_ACTION_AT,
|
|
activeCast: null,
|
|
activeCastTargetId: null,
|
|
};
|
|
usePartyStore.setState({ members: [readyForBolt] });
|
|
useCombatStore.getState().commitCompanionActors([readyForBolt]);
|
|
advanceDungeonPartyCombat(PLAYER_NEXT_ACTION_AT);
|
|
const started = usePartyStore.getState().members[0]?.activeCast;
|
|
expect(started, "the companion begins Lightning Bolt").toBeDefined();
|
|
advanceDungeonPartyCombat(started!.completesAt);
|
|
|
|
const damageEvent = [...useCombatStore.getState().combatEvents].reverse().find((event) => (
|
|
event.kind === "damage" && event.sourceActorId === member.id && event.abilityId === lightningBolt!.id
|
|
));
|
|
expect(damageEvent?.critical).toBe(true);
|
|
expect(natureGuardianAuras(member.id).some((aura) => (
|
|
aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG)
|
|
))).toBe(false);
|
|
expect(natureGuardianAuras(member.id).some((aura) => (
|
|
aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_COOLDOWN_TAG)
|
|
))).toBe(true);
|
|
});
|
|
|
|
it("spends the companion's charge on its accepted Nature healing spell", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80, xp: 0 });
|
|
usePartyStore.getState().initializeParty(
|
|
"wow-shaman-natures-guardian-companion-heal",
|
|
80,
|
|
useCombatStore.getState().health,
|
|
"damage",
|
|
1,
|
|
);
|
|
const healingWave = abilitiesForClass("shaman").find((ability) => ability.name === "Healing Wave");
|
|
expect(healingWave).toBeDefined();
|
|
const shamanBase = createConfiguredPartyMember({
|
|
name: "Nature's Guardian Healing Shaman",
|
|
classId: "shaman",
|
|
specialization: "Restoration",
|
|
specializationId: "shaman:restoration",
|
|
talentTreeId: "shaman-restoration",
|
|
talentRanks: { "talent-1699": 3 },
|
|
role: "healer",
|
|
}, 80, 0, "wow-shaman-natures-guardian-companion-heal");
|
|
const disabledCooldowns = Object.fromEntries(abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000]));
|
|
const member = {
|
|
...shamanBase,
|
|
gearStats: EMPTY_ITEM_STATS,
|
|
health: Math.round(shamanBase.maxHealth * 0.4),
|
|
cooldowns: disabledCooldowns,
|
|
nextActionAt: 1_000_000,
|
|
activeCast: null,
|
|
activeCastTargetId: null,
|
|
status: "ready" as const,
|
|
};
|
|
usePartyStore.setState({ members: [member], command: "attack", activeMobId: TARGET_ID });
|
|
useCombatStore.getState().commitCompanionActors([member]);
|
|
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
|
useCombatStore.setState((state) => ({
|
|
actors: {
|
|
...state.actors,
|
|
[member.id]: { ...state.actors[member.id]!, health: member.health, alive: true },
|
|
},
|
|
}));
|
|
armShamanForHit(member.id);
|
|
updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true);
|
|
useCombatStore.getState().advanceMobCombat(ENEMY_HIT_AT);
|
|
|
|
const actorAfterHit = useCombatStore.getState().actors[member.id];
|
|
expect(actorAfterHit?.health).toBeLessThan(actorAfterHit!.maxHealth * 0.3);
|
|
expect(natureGuardianAuras(member.id).some((aura) => (
|
|
aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG)
|
|
))).toBe(true);
|
|
const readyForHeal = {
|
|
...usePartyStore.getState().members[0]!,
|
|
health: actorAfterHit!.health,
|
|
cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((ability) => [
|
|
ability.id,
|
|
ability.id === healingWave!.id ? 0 : 1_000_000,
|
|
])),
|
|
nextActionAt: PLAYER_NEXT_ACTION_AT,
|
|
activeCast: null,
|
|
activeCastTargetId: null,
|
|
};
|
|
usePartyStore.setState({ members: [readyForHeal] });
|
|
useCombatStore.getState().commitCompanionActors([readyForHeal]);
|
|
advanceDungeonPartyCombat(PLAYER_NEXT_ACTION_AT);
|
|
const activeCast = usePartyStore.getState().members[0]?.activeCast;
|
|
expect(activeCast?.abilityId).toBe(healingWave!.id);
|
|
expect(natureGuardianAuras(member.id).some((aura) => (
|
|
aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG)
|
|
))).toBe(true);
|
|
advanceDungeonPartyCombat(activeCast!.completesAt);
|
|
|
|
const healingEvent = [...useCombatStore.getState().combatEvents].reverse().find((event) => (
|
|
event.kind === "healing" && event.sourceActorId === member.id
|
|
&& event.targetActorId === member.id && event.abilityId === healingWave!.id
|
|
));
|
|
expect(healingEvent?.critical).toBe(true);
|
|
expect(natureGuardianAuras(member.id).some((aura) => (
|
|
aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG)
|
|
))).toBe(false);
|
|
});
|
|
});
|