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

524 lines
23 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { abilityAtLevel, abilitiesForClass, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import {
clearPartyRuntimeRegistry,
registerPartyRuntimePosition,
updatePartyRuntimeTargetVisibility,
} from "./partyRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { createConfiguredPartyMember, usePartyStore, type PartyMember } from "./partyStore";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { partyAbilityAmount, partyAbilityCosts } from "./partyAbilityAi";
import { WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG } from "./wow335ShamanTidalWavesRuntime";
function stableUnit(seed: string): number {
let hash = 2_166_136_261;
for (const character of seed) {
hash ^= character.charCodeAt(0);
hash = Math.imul(hash, 16_777_619);
}
return (hash >>> 0) / 0x1_0000_0000;
}
function shamanAbility(name: string) {
const source = abilitiesForClass("shaman").find((candidate) => candidate.name === name);
expect(source, `generated Shaman ${name}`).toBeDefined();
return abilityAtLevel(source!, 80);
}
function onlyReady(member: PartyMember, abilityName: string): PartyMember {
const readyAbility = shamanAbility(abilityName);
return {
...member,
cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((candidate) => [
candidate.id,
candidate.id === readyAbility.id ? 0 : 1_000_000,
])),
activeCast: null,
activeCastTargetId: null,
nextActionAt: 0,
};
}
function noAbilitiesReady(member: PartyMember): PartyMember {
return {
...member,
cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((candidate) => [candidate.id, 1_000_000])),
activeCast: null,
activeCastTargetId: null,
nextActionAt: 0,
};
}
async function setupShamanParty(options: {
readonly tidalRank?: number;
readonly riptideRank?: number;
readonly waterShieldRank?: number;
readonly improvedShieldsRank?: number;
readonly elementalFocusRank?: number;
readonly secondShaman?: boolean;
readonly secondShamanTidalRank?: number;
readonly spellPower?: number;
readonly criticalStrikeRating?: number;
} = {}) {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
await preloadAbilityCatalogsForClasses(["shaman", "warrior"]);
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
useCombatStore.setState({ auras: [], effects: [], combatEvents: [] });
usePartyStore.getState().initializeParty("shaman-party-runtime", 80, useCombatStore.getState().health, "damage", 3);
const talentRanks = {
"talent-2063": options.tidalRank ?? 0,
"talent-2064": options.riptideRank ?? 0,
"talent-583": options.waterShieldRank ?? 0,
"talent-607": options.improvedShieldsRank ?? 0,
"talent-574": options.elementalFocusRank ?? 0,
};
const firstShamanBase = createConfiguredPartyMember({
name: "Party Shaman",
classId: "shaman",
specialization: "Restoration",
specializationId: "shaman:restoration",
talentTreeId: "shaman-restoration",
talentRanks,
role: "healer",
}, 80, 0, "shaman-party-runtime:first");
const spellPower = options.spellPower ?? 0;
const criticalStrikeRating = options.criticalStrikeRating ?? 0;
const firstShaman: PartyMember = {
...onlyReady(firstShamanBase, "Healing Wave"),
gearStats: { ...EMPTY_ITEM_STATS, spellPower, criticalStrikeRating },
resource: 10_000,
maxResource: 20_000,
resourcePools: { ...firstShamanBase.resourcePools, mana: 10_000 },
health: firstShamanBase.maxHealth,
};
const tankBase = createConfiguredPartyMember({
name: "Party Tank",
classId: "warrior",
specialization: "Protection",
specializationId: "warrior:protection",
talentTreeId: "warrior-protection",
role: "tank",
}, 80, 1, "shaman-party-runtime:tank");
const tank: PartyMember = {
...tankBase,
health: Math.round(tankBase.maxHealth * 0.25),
activeCast: null,
activeCastTargetId: null,
nextActionAt: 0,
};
const members: PartyMember[] = [firstShaman, tank];
if (options.secondShaman) {
const secondBase = createConfiguredPartyMember({
name: "Second Party Shaman",
classId: "shaman",
specialization: "Restoration",
specializationId: "shaman:restoration",
talentTreeId: "shaman-restoration",
talentRanks: {
"talent-2063": options.secondShamanTidalRank ?? 0,
"talent-2064": 1,
"talent-583": 0,
},
role: "healer",
}, 80, 2, "shaman-party-runtime:second");
members.push({
...onlyReady(secondBase, "Healing Wave"),
gearStats: { ...EMPTY_ITEM_STATS },
resource: 10_000,
maxResource: 20_000,
resourcePools: { ...secondBase.resourcePools, mana: 10_000 },
health: secondBase.maxHealth,
});
}
usePartyStore.setState({ members });
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
return usePartyStore.getState().members;
}
function findPartyHealTime(
memberId: string,
abilityId: string,
targetId: string,
predicate: (roll: number, now: number) => boolean,
from = 20_000,
): number {
for (let now = from; now < from + 100_000; now += 1) {
if (predicate(stableUnit(`${memberId}:${abilityId}:${targetId}:${now}:wow335-shaman-improved-water-shield`), now)) {
return now;
}
}
throw new Error("Could not find a seeded Shaman party healing proc timestamp.");
}
describe("party WoW Shaman healing talents", () => {
it("grants and consumes party Elemental Focus Clearcasting after a critical elemental spell", async () => {
const members = await setupShamanParty({ elementalFocusRank: 2, criticalStrikeRating: 1_000_000 });
const shaman = members[0];
const bolt = shamanAbility("Lightning Bolt");
const targetId = "shaman-elemental-focus-party-target";
useCombatStore.getState().registerMob(targetId, {
name: "Elemental Focus target",
level: 80,
maxHealth: 100_000,
xpReward: 0,
});
setMobPosition(targetId, [2, 0, 0]);
useCombatStore.getState().damageMob(targetId, 1, 500);
updatePartyRuntimeTargetVisibility(shaman.id, targetId, true);
const damageShaman: PartyMember = {
...onlyReady(shaman, "Lightning Bolt"),
role: "damage",
health: shaman.maxHealth,
globalCooldownEndsAt: 0,
nextActionAt: 0,
};
const fullManaCost = partyAbilityCosts(damageShaman, bolt, 1_000)
.find((cost) => cost.resource === "mana")?.amount;
usePartyStore.setState((state) => ({
command: "attack",
activeMobId: targetId,
members: state.members.map((member) => member.id === shaman.id
? damageShaman
: { ...member, cooldowns: Object.fromEntries(abilitiesForClass("warrior").map((ability) => [ability.id, 1_000_000])) }),
}));
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
advanceDungeonPartyCombat(1_000);
const firstCast = usePartyStore.getState().members.find((member) => member.id === shaman.id)?.activeCast;
expect(firstCast?.abilityId).toBe(bolt.id);
advanceDungeonPartyCombat(firstCast!.completesAt);
const hit = [...useCombatStore.getState().combatEvents].reverse().find((event) => (
event.kind === "damage" && event.sourceActorId === shaman.id && event.abilityId === bolt.id
));
expect(hit).toMatchObject({ critical: true });
const clearcasting = () => useCombatStore.getState().auras.find((aura) => (
aura.sourceId === shaman.id
&& aura.targetId === shaman.id
&& aura.definition.utilityTags?.includes("wow335-shaman-elemental-focus-clearcasting")
));
expect(clearcasting()).toMatchObject({ stacks: 2, expiresAt: hit!.occurredAt + 15_000 });
expect(fullManaCost).toBeGreaterThan(0);
expect(partyAbilityCosts(shaman, bolt, hit!.occurredAt)
.find((cost) => cost.resource === "mana")?.amount).toBe(Math.round(fullManaCost! * 0.7));
const currentShaman = usePartyStore.getState().members.find((member) => member.id === shaman.id)!;
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === shaman.id
? { ...onlyReady(currentShaman, "Lightning Bolt"), globalCooldownEndsAt: 0, nextActionAt: 0 }
: member),
}));
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
advanceDungeonPartyCombat(hit!.occurredAt + 1_000);
expect(usePartyStore.getState().members.find((member) => member.id === shaman.id)?.activeCast?.abilityId)
.toBe(bolt.id);
expect(clearcasting()?.stacks).toBe(1);
});
it("procs rank-owned Improved Water Shield from its own critical Healing Wave and restores only its mana", async () => {
const members = await setupShamanParty({
tidalRank: 3,
riptideRank: 1,
waterShieldRank: 3,
improvedShieldsRank: 3,
spellPower: 1_000,
criticalStrikeRating: 100_000,
secondShaman: true,
});
const shaman = members[0];
const tank = members[1];
const secondShaman = members[2];
const playerManaBefore = useCombatStore.getState().resourcePools.mana;
const riptide = shamanAbility("Riptide");
const healingWave = shamanAbility("Healing Wave");
const riptideAt = findPartyHealTime(
shaman.id,
riptide.id,
tank.id,
(roll, now) => roll >= 0.3 && stableUnit(
`${shaman.id}:${healingWave.id}:${tank.id}:${now + 3_500}:wow335-shaman-improved-water-shield`,
) < 0.6,
);
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === shaman.id
? onlyReady(member, "Riptide")
: member.id === secondShaman.id ? noAbilitiesReady(member) : member),
}));
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
advanceDungeonPartyCombat(riptideAt);
const tidalAura = useCombatStore.getState().auras.find((aura) => (
aura.sourceId === shaman.id
&& aura.targetId === shaman.id
&& aura.definition.utilityTags?.includes(WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG)
));
expect(tidalAura).toMatchObject({ stacks: 2, expiresAt: riptideAt + 15_000 });
expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({
kind: "hot",
sourceActorId: shaman.id,
sourceAbilityId: riptide.id,
targetId: tank.id,
}));
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === shaman.id
? onlyReady(member, "Healing Wave")
: member.id === secondShaman.id ? noAbilitiesReady(member) : member),
}));
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
advanceDungeonPartyCombat(riptideAt + 1_500);
const activeCast = usePartyStore.getState().members.find((member) => member.id === shaman.id)?.activeCast;
expect(activeCast).toMatchObject({
abilityId: healingWave.id,
originalDurationMs: Math.round(healingWave.castTimeMs * 0.8),
tidalWavesChargesConsumed: 2,
});
expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === tidalAura?.instanceId)?.stacks).toBe(1);
advanceDungeonPartyCombat(activeCast!.completesAt);
const combat = useCombatStore.getState();
const healingEvent = [...combat.combatEvents].reverse().find((event) => (
event.kind === "healing" && event.sourceActorId === shaman.id && event.abilityId === healingWave.id
));
const waterShieldEvent = [...combat.combatEvents].reverse().find((event) => (
event.kind === "resource" && event.sourceActorId === shaman.id
&& event.abilityId === "wow335-shaman-improved-water-shield"
));
const effect = healingWave.effects.find((candidate) => candidate.kind === "heal");
expect(effect).toBeDefined();
const ordinaryHeal = partyAbilityAmount(shaman, healingWave, effect!);
expect(healingEvent).toMatchObject({ targetActorId: tank.id, school: "nature", critical: true });
expect(healingEvent?.rawAmount).toBe(Math.round((ordinaryHeal + 120) * 1.5));
expect(waterShieldEvent).toMatchObject({
targetActorId: shaman.id,
rawAmount: 460,
effectiveAmount: 460,
overheal: 0,
});
expect(combat.combatEvents).not.toContainEqual(expect.objectContaining({
kind: "resource",
sourceActorId: secondShaman.id,
abilityId: "wow335-shaman-improved-water-shield",
}));
expect(combat.actors[shaman.id]?.resources.mana.current).toBe(
usePartyStore.getState().members.find((member) => member.id === shaman.id)?.resourcePools.mana,
);
expect(combat.resourcePools.mana).toBe(playerManaBefore);
expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({
kind: "hot",
sourceActorId: shaman.id,
sourceAbilityId: riptide.id,
targetId: tank.id,
}));
});
it("adds party Tidal Waves charges to Lesser Healing Wave crit chance and consumes one charge", async () => {
const members = await setupShamanParty({ tidalRank: 3, riptideRank: 1 });
const shaman = members[0];
const tank = members[1];
const riptide = shamanAbility("Riptide");
const lesserHealingWave = shamanAbility("Lesser Healing Wave");
let riptideAt = 1_000;
for (; riptideAt < 100_000; riptideAt += 1) {
if (stableUnit(`${shaman.id}:${lesserHealingWave.id}:${tank.id}:${riptideAt + 3_000}:heal`) < 0.2) break;
}
expect(riptideAt).toBeLessThan(100_000);
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === shaman.id ? onlyReady(member, "Riptide") : member),
}));
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
advanceDungeonPartyCombat(riptideAt);
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
sourceId: shaman.id,
targetId: shaman.id,
stacks: 2,
definition: expect.objectContaining({ utilityTags: expect.arrayContaining([WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG]) }),
}));
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === shaman.id
? onlyReady(member, "Lesser Healing Wave")
: member),
}));
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
advanceDungeonPartyCombat(riptideAt + 1_500);
const activeCast = usePartyStore.getState().members.find((member) => member.id === shaman.id)?.activeCast;
expect(activeCast).toMatchObject({
abilityId: lesserHealingWave.id,
originalDurationMs: lesserHealingWave.castTimeMs,
tidalWavesChargesConsumed: 2,
});
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
sourceId: shaman.id,
targetId: shaman.id,
stacks: 1,
definition: expect.objectContaining({ utilityTags: expect.arrayContaining([WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG]) }),
}));
advanceDungeonPartyCombat(activeCast!.completesAt);
const healingEvent = [...useCombatStore.getState().combatEvents].reverse().find((event) => (
event.kind === "healing" && event.sourceActorId === shaman.id && event.abilityId === lesserHealingWave.id
));
expect(healingEvent).toMatchObject({ targetActorId: tank.id, critical: true });
});
it("expires party Tidal Waves after 15 seconds and ignores another Shaman's Riptide HoT", async () => {
const members = await setupShamanParty({ tidalRank: 3, riptideRank: 1, secondShaman: true });
const shaman = members[0];
const tank = members[1];
const secondShaman = members[2];
const riptide = shamanAbility("Riptide");
const chainHeal = shamanAbility("Chain Heal");
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === shaman.id
? onlyReady(member, "Riptide")
: member.id === secondShaman.id
? { ...onlyReady(member, "Healing Wave"), cooldowns: Object.fromEntries(
abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000]),
) }
: member),
}));
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
const riptideAt = 1_000;
advanceDungeonPartyCombat(riptideAt);
const aura = useCombatStore.getState().auras.find((candidate) => (
candidate.sourceId === shaman.id
&& candidate.targetId === shaman.id
&& candidate.definition.utilityTags?.includes(WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG)
));
expect(aura).toMatchObject({ stacks: 2, expiresAt: 16_000 });
const initialHot = useCombatStore.getState().effects.find((effect) => (
effect.kind === "hot" && effect.sourceActorId === shaman.id && effect.targetId === tank.id
));
expect(initialHot?.sourceAbilityId).toBe(riptide.id);
const chainEffect = chainHeal.effects.find((effect) => effect.kind === "heal");
expect(chainEffect).toBeDefined();
const baseAmount = partyAbilityAmount(secondShaman, chainHeal, chainEffect!);
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === secondShaman.id
? onlyReady(member, "Chain Heal")
: member.id === shaman.id
? { ...member, cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000])) }
: member),
}));
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
advanceDungeonPartyCombat(riptideAt + 1_000);
const secondCast = usePartyStore.getState().members.find((member) => member.id === secondShaman.id)?.activeCast;
expect(secondCast?.abilityId).toBe(chainHeal.id);
advanceDungeonPartyCombat(secondCast!.completesAt);
const secondHeal = [...useCombatStore.getState().combatEvents].reverse().find((event) => (
event.kind === "healing" && event.sourceActorId === secondShaman.id
&& event.abilityId === chainHeal.id && event.targetActorId === tank.id
));
expect(secondHeal?.rawAmount).toBe(baseAmount);
expect(useCombatStore.getState().auras).not.toContainEqual(expect.objectContaining({
sourceId: secondShaman.id,
targetId: secondShaman.id,
definition: expect.objectContaining({ utilityTags: expect.arrayContaining([WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG]) }),
}));
expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({
id: initialHot?.id,
sourceActorId: shaman.id,
targetId: tank.id,
}));
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === shaman.id
? onlyReady(member, "Healing Wave")
: member.id === secondShaman.id
? { ...member, cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000])) }
: member),
}));
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
advanceDungeonPartyCombat(16_000);
const expiredCast = usePartyStore.getState().members.find((member) => member.id === shaman.id)?.activeCast;
expect(expiredCast?.abilityId).toBe(shamanAbility("Healing Wave").id);
expect(expiredCast?.originalDurationMs).toBe(shamanAbility("Healing Wave").castTimeMs);
expect(useCombatStore.getState().auras.find((candidate) => candidate.instanceId === aura?.instanceId)?.stacks).toBe(2);
});
it("consumes only the Riptide caster's matching HoT for the Chain Heal primary target", async () => {
const members = await setupShamanParty({
riptideRank: 1,
secondShaman: true,
secondShamanTidalRank: 3,
});
const shaman = members[0];
const tank = members[1];
const secondShaman = members[2];
const riptide = shamanAbility("Riptide");
const chainHeal = shamanAbility("Chain Heal");
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === shaman.id
? onlyReady(member, "Riptide")
: member.id === secondShaman.id
? { ...onlyReady(member, "Healing Wave"), cooldowns: Object.fromEntries(
abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000]),
) }
: member),
}));
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
advanceDungeonPartyCombat(1_000);
const riptideHot = useCombatStore.getState().effects.find((effect) => (
effect.kind === "hot" && effect.sourceActorId === shaman.id
&& effect.sourceAbilityId === riptide.id && effect.targetId === tank.id
));
expect(riptideHot).toBeDefined();
const baseAmount = partyAbilityAmount(secondShaman, chainHeal, chainHeal.effects.find((effect) => effect.kind === "heal")!);
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === secondShaman.id
? onlyReady(member, "Chain Heal")
: member.id === shaman.id
? { ...member, cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000])) }
: member),
}));
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
advanceDungeonPartyCombat(2_500);
const otherCasterCast = usePartyStore.getState().members.find((member) => member.id === secondShaman.id)?.activeCast;
advanceDungeonPartyCombat(otherCasterCast!.completesAt);
const otherCasterHeal = [...useCombatStore.getState().combatEvents].reverse().find((event) => (
event.kind === "healing" && event.sourceActorId === secondShaman.id
&& event.abilityId === chainHeal.id && event.targetActorId === tank.id
));
expect(otherCasterHeal?.rawAmount).toBe(baseAmount);
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
sourceId: secondShaman.id,
targetId: secondShaman.id,
stacks: 3,
definition: expect.objectContaining({
utilityTags: expect.arrayContaining([WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG]),
}),
}));
expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({ id: riptideHot?.id }));
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === shaman.id
? onlyReady(member, "Chain Heal")
: member.id === secondShaman.id
? { ...member, cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000])) }
: member),
}));
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
advanceDungeonPartyCombat(otherCasterCast!.completesAt + 1_000);
const ownCasterCast = usePartyStore.getState().members.find((member) => member.id === shaman.id)?.activeCast;
advanceDungeonPartyCombat(ownCasterCast!.completesAt);
const ownCasterHeal = [...useCombatStore.getState().combatEvents].reverse().find((event) => (
event.kind === "healing" && event.sourceActorId === shaman.id
&& event.abilityId === chainHeal.id && event.targetActorId === tank.id
));
expect(ownCasterHeal?.rawAmount).toBe(Math.round(baseAmount * 1.25));
expect(useCombatStore.getState().effects).not.toContainEqual(expect.objectContaining({ id: riptideHot?.id }));
});
});