forked from phenom/healer-man
468 lines
18 KiB
TypeScript
468 lines
18 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { PLAYER_AURA_ENTITY_ID, resolvedAbilityCosts, useCombatStore } from "./combatStore";
|
|
import { abilityAtLevel, abilityById } from "./abilityCatalog";
|
|
import { applyAura } from "./combatAuras";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
|
import { EMPTY_ITEM_STATS } from "./itemStats";
|
|
import { usePartyStore } from "./partyStore";
|
|
import { WOW335_SHAMAN_MAELSTROM_WEAPON_AURA_TAG } from "./wow335ShamanMaelstromWeaponRuntime";
|
|
|
|
const TARGET_ID = "maelstrom-target";
|
|
const OFF_HAND_WEAPON_ID = "maelstrom-test-off-hand";
|
|
|
|
function runtimeManaCost(abilityId: string, state: ReturnType<typeof useCombatStore.getState>, now: number): number {
|
|
const catalogAbility = abilityById(abilityId);
|
|
expect(catalogAbility).not.toBeNull();
|
|
const rankedAbility = abilityAtLevel(catalogAbility!, state.level);
|
|
return resolvedAbilityCosts(rankedAbility, state.talentModifiers, { ...state, now })
|
|
.find((cost) => cost.resource === "mana")?.amount ?? 0;
|
|
}
|
|
|
|
function shamanAbility(name: string) {
|
|
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
|
|
expect(ability, `Shaman ${name}`).toBeDefined();
|
|
return ability!;
|
|
}
|
|
|
|
function setMaelstromRank(rank: number): void {
|
|
useCombatStore.setState((state) => ({
|
|
talentRanks: { ...state.talentRanks, "talent-2057": rank },
|
|
}));
|
|
}
|
|
|
|
function maelstromAura() {
|
|
return useCombatStore.getState().auras.find((aura) => (
|
|
aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.definition.utilityTags?.includes(WOW335_SHAMAN_MAELSTROM_WEAPON_AURA_TAG)
|
|
));
|
|
}
|
|
|
|
function stableCombatUnit(seed: string): number {
|
|
let hash = 2_166_136_261;
|
|
for (let index = 0; index < seed.length; index += 1) {
|
|
hash ^= seed.charCodeAt(index);
|
|
hash = Math.imul(hash, 16_777_619);
|
|
}
|
|
return (hash >>> 0) / 0x1_0000_0000;
|
|
}
|
|
|
|
function markTargetAsCharacterDefender(): void {
|
|
useCombatStore.setState((state) => ({
|
|
mobs: {
|
|
...state.mobs,
|
|
[TARGET_ID]: { ...state.mobs[TARGET_ID]!, characterDefender: { level: 80 } },
|
|
},
|
|
}));
|
|
}
|
|
|
|
function grantMaelstrom(stacks: number, now: number): void {
|
|
const definition = {
|
|
id: "wow335:shaman:maelstrom-weapon",
|
|
name: "Maelstrom Weapon",
|
|
description: "Your next listed spell has its cast time reduced by 20% per stack.",
|
|
disposition: "buff" as const,
|
|
durationMs: 30_000,
|
|
maxStacks: 5,
|
|
initialStacks: 1,
|
|
stackBehavior: "add" as const,
|
|
utilityTags: [WOW335_SHAMAN_MAELSTROM_WEAPON_AURA_TAG],
|
|
};
|
|
useCombatStore.setState((state) => ({
|
|
auras: [...applyAura(state.auras, {
|
|
definition,
|
|
sourceId: PLAYER_AURA_ENTITY_ID,
|
|
targetId: PLAYER_AURA_ENTITY_ID,
|
|
stacks,
|
|
now,
|
|
}).auras],
|
|
}));
|
|
}
|
|
|
|
function firstPlayerLightningBoltDamage(): number {
|
|
const event = useCombatStore.getState().combatEvents.find((candidate) => (
|
|
candidate.kind === "damage"
|
|
&& candidate.sourceActorId === PLAYER_AURA_ENTITY_ID
|
|
&& candidate.targetActorId === TARGET_ID
|
|
&& candidate.abilityId === shamanAbility("Lightning Bolt").id
|
|
));
|
|
expect(event, "player Lightning Bolt damage event").toBeDefined();
|
|
return event!.effectiveAmount;
|
|
}
|
|
|
|
function setupShaman(): void {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
const offHandStats = {
|
|
...EMPTY_ITEM_STATS,
|
|
meleeWeapon: { damageMin: 20, damageMax: 30, damageSchool: "physical" as const, speedMs: 5_000 },
|
|
};
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "shaman",
|
|
level: 80,
|
|
xp: 0,
|
|
inventory: [{
|
|
instanceId: OFF_HAND_WEAPON_ID,
|
|
itemId: 99001,
|
|
itemKey: OFF_HAND_WEAPON_ID,
|
|
name: "Slow off-hand weapon",
|
|
quality: "uncommon",
|
|
inventorySlot: "off-hand",
|
|
armorType: null,
|
|
weaponType: "mace",
|
|
itemLevel: 80,
|
|
sourceItemLevel: 80,
|
|
stats: offHandStats,
|
|
quantity: 1,
|
|
sourceBossKey: "test",
|
|
sourceBossName: "Test",
|
|
acquiredAt: 1,
|
|
}],
|
|
equipment: { player: { "off-hand": OFF_HAND_WEAPON_ID } },
|
|
});
|
|
useCombatStore.getState().resetSettings();
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
// Keep a distinct one-second main-hand value so the Lava Lash assertion
|
|
// proves that the proc uses its equipped five-second off-hand weapon.
|
|
useCombatStore.setState((state) => ({
|
|
gearStats: {
|
|
...state.gearStats,
|
|
meleeWeapon: { damageMin: 20, damageMax: 30, damageSchool: "physical", speedMs: 1_000 },
|
|
},
|
|
}));
|
|
usePartyStore.getState().initializeParty("wow-shaman-maelstrom-tests", 80, useCombatStore.getState().health);
|
|
useCombatStore.getState().registerMob(TARGET_ID, {
|
|
name: "Maelstrom target",
|
|
level: 80,
|
|
maxHealth: 1_000_000,
|
|
xpReward: 0,
|
|
});
|
|
setMobPosition(TARGET_ID, [3, 0, 0]);
|
|
useCombatStore.getState().selectMob(TARGET_ID);
|
|
}
|
|
|
|
function cast(abilityId: string, now: number, targetActorId: string | null = TARGET_ID) {
|
|
const result = useCombatStore.getState().castAbility(abilityId, { targetActorId, now });
|
|
expect(result.ok, JSON.stringify(result)).toBe(true);
|
|
return result;
|
|
}
|
|
|
|
describe("WoW Shaman Maelstrom Weapon combat runtime", () => {
|
|
beforeEach(setupShaman);
|
|
|
|
it("procs on landed melee hits using Lava Lash's equipped off-hand speed", () => {
|
|
setMaelstromRank(3);
|
|
const lavaLash = shamanAbility("Lava Lash");
|
|
const seedUnitAtMainHandRate = (now: number) => stableCombatUnit(
|
|
`${PLAYER_AURA_ENTITY_ID}:${lavaLash.id}:${TARGET_ID}:${now}:direct:wow335-shaman-maelstrom-weapon`,
|
|
);
|
|
const now = Array.from({ length: 1_000 }, (_, index) => 1_000 + index)
|
|
.find((candidate) => seedUnitAtMainHandRate(candidate) >= 0.2)!;
|
|
expect(now).toBeDefined();
|
|
|
|
cast(lavaLash.id, now);
|
|
|
|
expect(seedUnitAtMainHandRate(now)).toBeGreaterThanOrEqual(0.2);
|
|
expect(maelstromAura()).toMatchObject({ stacks: 1, expiresAt: now + 30_000 });
|
|
expect(useCombatStore.getState().combatEvents.some((event) => (
|
|
event.kind === "damage" && event.abilityId === lavaLash.id && event.effectiveAmount > 0
|
|
))).toBe(true);
|
|
});
|
|
|
|
it("uses the off-hand speed for Shaman off-hand auto-attack Maelstrom rolls", () => {
|
|
setMaelstromRank(3);
|
|
useCombatStore.setState((state) => ({
|
|
inventory: state.inventory.map((item) => item.instanceId === OFF_HAND_WEAPON_ID
|
|
? { ...item, stats: { ...item.stats, meleeWeapon: { ...item.stats.meleeWeapon!, speedMs: 2_000 } } }
|
|
: item),
|
|
gearStats: { ...state.gearStats, hitRating: 10_000, expertiseRating: 10_000 },
|
|
autoAttackTargetId: TARGET_ID,
|
|
nextAutoAttackAt: 1_000,
|
|
nextOffhandAutoAttackAt: 1_000,
|
|
}));
|
|
const mainHandRoll = (now: number) => stableCombatUnit(
|
|
`${PLAYER_AURA_ENTITY_ID}:auto:${TARGET_ID}:${now}:wow335-shaman-maelstrom-weapon`,
|
|
);
|
|
const offHandRoll = (now: number) => stableCombatUnit(
|
|
`${PLAYER_AURA_ENTITY_ID}:offhand-auto:${TARGET_ID}:${now}:wow335-shaman-maelstrom-weapon`,
|
|
);
|
|
const now = Array.from({ length: 50_000 }, (_, index) => 1_000 + index)
|
|
.find((candidate) => mainHandRoll(candidate) >= 0.2
|
|
&& offHandRoll(candidate) >= 0.2 && offHandRoll(candidate) < 0.4)!;
|
|
expect(now).toBeDefined();
|
|
|
|
useCombatStore.getState().tick(0.1, now);
|
|
|
|
const offhandEvents = useCombatStore.getState().combatEvents.filter((event) => (
|
|
event.kind === "damage" && event.abilityId === "player-auto-attack-offhand"
|
|
));
|
|
expect(offhandEvents, JSON.stringify(offhandEvents)).toHaveLength(1);
|
|
expect(mainHandRoll(now)).toBeGreaterThanOrEqual(0.2);
|
|
expect(offHandRoll(now)).toBeLessThan(0.4);
|
|
expect(maelstromAura(), JSON.stringify({
|
|
now,
|
|
roll: offHandRoll(now),
|
|
rank: useCombatStore.getState().talentRanks["talent-2057"],
|
|
weapon: useCombatStore.getState().inventory.find((item) => item.instanceId === OFF_HAND_WEAPON_ID)?.stats.meleeWeapon,
|
|
offhandEvents,
|
|
})).toMatchObject({ stacks: 1 });
|
|
});
|
|
|
|
it("resolves Stormstrike's hands independently and uses each weapon speed for Maelstrom", () => {
|
|
setMaelstromRank(3);
|
|
useCombatStore.setState((state) => ({
|
|
talentRanks: { ...state.talentRanks, "talent-901": 1 },
|
|
inventory: state.inventory.map((item) => item.instanceId === OFF_HAND_WEAPON_ID
|
|
? { ...item, stats: { ...item.stats, meleeWeapon: { ...item.stats.meleeWeapon!, speedMs: 2_000 } } }
|
|
: item),
|
|
gearStats: { ...state.gearStats, hitRating: 10_000, expertiseRating: 10_000 },
|
|
}));
|
|
const stormstrike = shamanAbility("Stormstrike");
|
|
const mainHandRoll = (now: number) => stableCombatUnit(
|
|
`${PLAYER_AURA_ENTITY_ID}:${stormstrike.id}:${TARGET_ID}:${now}:direct:main-hand:wow335-shaman-maelstrom-weapon`,
|
|
);
|
|
const offHandRoll = (now: number) => stableCombatUnit(
|
|
`${PLAYER_AURA_ENTITY_ID}:${stormstrike.id}:${TARGET_ID}:${now}:direct:off-hand:wow335-shaman-maelstrom-weapon`,
|
|
);
|
|
const now = Array.from({ length: 50_000 }, (_, index) => 1_000 + index)
|
|
.find((candidate) => mainHandRoll(candidate) >= 0.2
|
|
&& offHandRoll(candidate) >= 0.2 && offHandRoll(candidate) < 0.4)!;
|
|
expect(now).toBeDefined();
|
|
|
|
cast(stormstrike.id, now);
|
|
|
|
const hitEvents = useCombatStore.getState().combatEvents.filter((event) => (
|
|
event.kind === "damage" && event.abilityId === stormstrike.id && event.effectiveAmount > 0
|
|
));
|
|
expect(hitEvents, JSON.stringify(useCombatStore.getState().combatEvents.filter((event) => (
|
|
event.kind === "damage" && event.abilityId === stormstrike.id
|
|
)))).toHaveLength(2);
|
|
expect(mainHandRoll(now)).toBeGreaterThanOrEqual(0.2);
|
|
expect(offHandRoll(now)).toBeLessThan(0.4);
|
|
expect(maelstromAura(), JSON.stringify({
|
|
now,
|
|
roll: offHandRoll(now),
|
|
rank: useCombatStore.getState().talentRanks["talent-2057"],
|
|
weapon: useCombatStore.getState().inventory.find((item) => item.instanceId === OFF_HAND_WEAPON_ID)?.stats.meleeWeapon,
|
|
hitEvents,
|
|
})).toMatchObject({ stacks: 1 });
|
|
});
|
|
|
|
it("adds one stack per proc, refreshes the 30-second duration, and caps at five", () => {
|
|
setMaelstromRank(3);
|
|
const lavaLash = shamanAbility("Lava Lash");
|
|
const interval = Math.max(lavaLash.cooldownMs, lavaLash.gcdMs, 1_500) + 10;
|
|
let now = 1_000;
|
|
|
|
for (let hit = 1; hit <= 6; hit += 1) {
|
|
cast(lavaLash.id, now);
|
|
expect(maelstromAura()?.stacks).toBe(Math.min(hit, 5));
|
|
expect(maelstromAura()?.expiresAt).toBe(now + 30_000);
|
|
now += interval;
|
|
}
|
|
});
|
|
|
|
it("reduces Lightning Bolt cast time per stack, consumes the whole bundle at cast start, and still charges Mana", () => {
|
|
setMaelstromRank(3);
|
|
const lavaLash = shamanAbility("Lava Lash");
|
|
const lightningBolt = shamanAbility("Lightning Bolt");
|
|
const interval = Math.max(lavaLash.cooldownMs, lavaLash.gcdMs, 1_500) + 10;
|
|
let now = 1_000;
|
|
for (let hit = 0; hit < 5; hit += 1) {
|
|
cast(lavaLash.id, now);
|
|
now += interval;
|
|
}
|
|
expect(maelstromAura()?.stacks).toBe(5);
|
|
|
|
// Earth Shock is not listed by the generated Maelstrom tooltip and must
|
|
// leave the active next-spell bundle alone.
|
|
const earthShock = shamanAbility("Earth Shock");
|
|
cast(earthShock.id, now);
|
|
expect(maelstromAura()?.stacks).toBe(5);
|
|
|
|
now += Math.max(earthShock.cooldownMs, earthShock.gcdMs, 1_500) + 10;
|
|
const before = useCombatStore.getState();
|
|
const manaBefore = before.resource;
|
|
const expectedManaCost = runtimeManaCost(lightningBolt.id, before, now);
|
|
cast(lightningBolt.id, now);
|
|
|
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
|
expect(maelstromAura()).toBeUndefined();
|
|
expect(useCombatStore.getState().resource).toBe(manaBefore - expectedManaCost);
|
|
});
|
|
|
|
it("applies a partial cast-time reduction, consumes the bundle on start, and does not make the spell free", () => {
|
|
const lightningBolt = shamanAbility("Lightning Bolt");
|
|
cast(lightningBolt.id, 500);
|
|
const baselineCast = useCombatStore.getState().activeCast;
|
|
expect(baselineCast).not.toBeNull();
|
|
const baselineDurationMs = baselineCast!.completesAt - baselineCast!.startedAt;
|
|
|
|
setupShaman();
|
|
setMaelstromRank(3);
|
|
const lavaLash = shamanAbility("Lava Lash");
|
|
const rankedLightningBolt = shamanAbility("Lightning Bolt");
|
|
cast(lavaLash.id, 1_000);
|
|
expect(maelstromAura()?.stacks).toBe(1);
|
|
|
|
const now = 3_000;
|
|
const before = useCombatStore.getState();
|
|
const manaBefore = before.resource;
|
|
const expectedManaCost = runtimeManaCost(rankedLightningBolt.id, before, now);
|
|
cast(rankedLightningBolt.id, now);
|
|
const activeCast = useCombatStore.getState().activeCast;
|
|
expect(activeCast!.completesAt! - activeCast!.startedAt!)
|
|
.toBe(Math.round(baselineDurationMs * 0.8));
|
|
expect(maelstromAura()).toBeUndefined();
|
|
expect(useCombatStore.getState().resource).toBe(manaBefore);
|
|
|
|
useCombatStore.getState().tick(0.1, activeCast!.completesAt);
|
|
expect(useCombatStore.getState().resource).toBeLessThan(manaBefore);
|
|
expect(useCombatStore.getState().resource).toBeCloseTo(manaBefore - expectedManaCost, 0);
|
|
});
|
|
|
|
it("does not proc from a spell hit or a melee hit without a trained rank", () => {
|
|
const lavaLash = shamanAbility("Lava Lash");
|
|
const lightningBolt = shamanAbility("Lightning Bolt");
|
|
cast(lavaLash.id, 1_000);
|
|
expect(maelstromAura()).toBeUndefined();
|
|
|
|
setMaelstromRank(3);
|
|
cast(lightningBolt.id, 3_000);
|
|
expect(maelstromAura()).toBeUndefined();
|
|
});
|
|
|
|
it("does not proc from a missed melee attack", () => {
|
|
setMaelstromRank(3);
|
|
const lavaLash = shamanAbility("Lava Lash");
|
|
useCombatStore.getState().registerMob(TARGET_ID, {
|
|
name: "High level Maelstrom target",
|
|
level: 200,
|
|
maxHealth: 1_000_000,
|
|
xpReward: 0,
|
|
});
|
|
const now = 1_000;
|
|
|
|
cast(lavaLash.id, now);
|
|
|
|
const damageEvent = useCombatStore.getState().combatEvents.find((event) => (
|
|
event.kind === "damage" && event.abilityId === lavaLash.id
|
|
));
|
|
expect(damageEvent?.effectiveAmount).toBe(0);
|
|
expect(maelstromAura()).toBeUndefined();
|
|
});
|
|
|
|
it("ignores an expired Maelstrom aura when calculating cast time", () => {
|
|
setMaelstromRank(3);
|
|
const lavaLash = shamanAbility("Lava Lash");
|
|
const lightningBolt = shamanAbility("Lightning Bolt");
|
|
cast(lavaLash.id, 1_000);
|
|
const generatedAura = maelstromAura();
|
|
expect(generatedAura).toBeDefined();
|
|
useCombatStore.setState((state) => ({
|
|
auras: state.auras.map((aura) => aura.instanceId === generatedAura!.instanceId
|
|
? { ...aura, expiresAt: 3_000 }
|
|
: aura),
|
|
}));
|
|
|
|
cast(lightningBolt.id, 3_000);
|
|
|
|
const activeCast = useCombatStore.getState().activeCast;
|
|
expect(activeCast!.completesAt! - activeCast!.startedAt!).toBe(2_500);
|
|
expect(maelstromAura()?.expiresAt).toBe(3_000);
|
|
});
|
|
|
|
it("reduces instant Lightning Bolt damage only against a character defender, capped at five stacks", () => {
|
|
const run = (stacks: number, characterDefender: boolean): number => {
|
|
setupShaman();
|
|
setMaelstromRank(3);
|
|
if (characterDefender) markTargetAsCharacterDefender();
|
|
const lightningBolt = shamanAbility("Lightning Bolt");
|
|
if (stacks > 0) grantMaelstrom(stacks, 5_000);
|
|
|
|
cast(lightningBolt.id, 5_000);
|
|
const activeCast = useCombatStore.getState().activeCast;
|
|
if (activeCast) {
|
|
useCombatStore.setState((state) => ({
|
|
activeCast: state.activeCast ? { ...state.activeCast, completesAt: 7_500 } : null,
|
|
}));
|
|
useCombatStore.getState().tick(0.1, 7_500);
|
|
}
|
|
return firstPlayerLightningBoltDamage();
|
|
};
|
|
|
|
const pvpBaseline = run(0, true);
|
|
const pvpFiveStacks = run(5, true);
|
|
expect(pvpBaseline).toBeGreaterThan(0);
|
|
expect(pvpFiveStacks).toBe(Math.round(pvpBaseline * 0.9));
|
|
|
|
const npcBaseline = run(0, false);
|
|
const npcFiveStacks = run(5, false);
|
|
expect(npcFiveStacks).toBe(npcBaseline);
|
|
});
|
|
|
|
it("retains consumed Maelstrom stacks through cast completion and channel pulses", () => {
|
|
const runCast = (stacks: number): { readonly damage: number; readonly snapshot: unknown } => {
|
|
setupShaman();
|
|
setMaelstromRank(3);
|
|
markTargetAsCharacterDefender();
|
|
const lightningBolt = shamanAbility("Lightning Bolt");
|
|
if (stacks > 0) grantMaelstrom(stacks, 5_000);
|
|
cast(lightningBolt.id, 5_000);
|
|
const activeCast = useCombatStore.getState().activeCast;
|
|
expect(activeCast).not.toBeNull();
|
|
const snapshot = {
|
|
rank: activeCast!.maelstromWeaponRank,
|
|
stacks: activeCast!.maelstromWeaponStacksConsumed,
|
|
};
|
|
expect(maelstromAura()).toBeUndefined();
|
|
useCombatStore.setState((state) => ({
|
|
activeCast: state.activeCast ? { ...state.activeCast, completesAt: 20_000 } : null,
|
|
...(stacks > 0 ? { talentRanks: { ...state.talentRanks, "talent-2057": 0 } } : {}),
|
|
}));
|
|
useCombatStore.getState().tick(0.1, 20_000);
|
|
return { damage: firstPlayerLightningBoltDamage(), snapshot };
|
|
};
|
|
|
|
const castBaseline = runCast(0);
|
|
const castWithStacks = runCast(3);
|
|
expect(castWithStacks.snapshot).toEqual({ rank: 3, stacks: 3 });
|
|
expect(castWithStacks.damage).toBe(Math.round(castBaseline.damage * 0.94));
|
|
|
|
const runChannel = (stacks: number): { readonly damage: number; readonly snapshot: unknown } => {
|
|
setupShaman();
|
|
setMaelstromRank(3);
|
|
markTargetAsCharacterDefender();
|
|
const lightningBolt = shamanAbility("Lightning Bolt");
|
|
if (stacks > 0) grantMaelstrom(stacks, 5_000);
|
|
cast(lightningBolt.id, 5_000);
|
|
const activeCast = useCombatStore.getState().activeCast;
|
|
expect(activeCast).not.toBeNull();
|
|
const snapshot = {
|
|
rank: activeCast!.maelstromWeaponRank,
|
|
stacks: activeCast!.maelstromWeaponStacksConsumed,
|
|
};
|
|
expect(maelstromAura()).toBeUndefined();
|
|
useCombatStore.setState((state) => ({
|
|
activeCast: state.activeCast ? {
|
|
...state.activeCast,
|
|
mode: "channel",
|
|
totalTicks: 2,
|
|
ticksCompleted: 0,
|
|
originalDurationMs: 2_000,
|
|
completesAt: 7_000,
|
|
nextTickAt: 6_000,
|
|
} : null,
|
|
...(stacks > 0 ? { talentRanks: { ...state.talentRanks, "talent-2057": 0 } } : {}),
|
|
}));
|
|
useCombatStore.getState().tick(0.1, 6_000);
|
|
return { damage: firstPlayerLightningBoltDamage(), snapshot };
|
|
};
|
|
|
|
const channelBaseline = runChannel(0);
|
|
const channelWithStacks = runChannel(3);
|
|
expect(channelWithStacks.snapshot).toEqual({ rank: 3, stacks: 3 });
|
|
expect(channelWithStacks.damage).toBe(Math.round(channelBaseline.damage * 0.94));
|
|
});
|
|
});
|