Files
healer-man/src/game/wow335ShamanMaelstromWeaponCombatRuntime.test.ts
T
2026-10-02 10:38:12 -04:00

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));
});
});