forked from phenom/healer-man
332 lines
13 KiB
TypeScript
332 lines
13 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
|
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
|
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
|
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
|
import { usePartyStore } from "./partyStore";
|
|
import { talentModifiersForRanks } from "./talentModifiers";
|
|
import { deriveCharacterStats } from "./wotlkStats";
|
|
import { WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG } from "./wow335ShamanTidalWavesRuntime";
|
|
|
|
function shamanAbility(name: string) {
|
|
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
|
|
expect(ability, `Shaman ${name}`).toBeDefined();
|
|
return ability!;
|
|
}
|
|
|
|
function setTidalWavesRank(rank: number): void {
|
|
useCombatStore.setState((state) => {
|
|
const talentRanks = { ...state.talentRanks, "talent-2063": rank };
|
|
const passiveModifiers = state.talentModifiers.filter((modifier) => (
|
|
modifier.sourceTalentId?.startsWith("class-passive:")
|
|
));
|
|
return {
|
|
talentRanks,
|
|
talentModifiers: [...passiveModifiers, ...talentModifiersForRanks(talentRanks, "shaman")],
|
|
};
|
|
});
|
|
}
|
|
|
|
function setRiptideRank(rank = 1): void {
|
|
useCombatStore.setState((state) => {
|
|
const talentRanks = { ...state.talentRanks, "talent-2064": rank };
|
|
const passiveModifiers = state.talentModifiers.filter((modifier) => (
|
|
modifier.sourceTalentId?.startsWith("class-passive:")
|
|
));
|
|
return {
|
|
talentRanks,
|
|
talentModifiers: [...passiveModifiers, ...talentModifiersForRanks(talentRanks, "shaman")],
|
|
};
|
|
});
|
|
}
|
|
|
|
async function setupShaman(): Promise<void> {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
await preloadAbilityCatalogsForClasses(["shaman"]);
|
|
useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 });
|
|
useCombatStore.getState().resetSettings();
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
usePartyStore.getState().initializeParty("wow-shaman-tidal-waves", 80, useCombatStore.getState().health);
|
|
usePartyStore.getState().members.forEach((member, index) => {
|
|
registerPartyRuntimePosition(member.id, [index + 1, 0, 0]);
|
|
});
|
|
useCombatStore.setState((state) => {
|
|
const talentRanks = { "talent-2064": 1 };
|
|
const passiveModifiers = state.talentModifiers.filter((modifier) => (
|
|
modifier.sourceTalentId?.startsWith("class-passive:")
|
|
));
|
|
return {
|
|
health: Math.max(1, state.maxHealth - 5_000),
|
|
talentRanks,
|
|
talentModifiers: [...passiveModifiers, ...talentModifiersForRanks(talentRanks, "shaman")],
|
|
auras: [],
|
|
effects: [],
|
|
activeCast: null,
|
|
gearStats: { ...state.gearStats, criticalStrikeRating: 0, hasteRating: 0, spellPower: 0 },
|
|
resource: 8_000,
|
|
maxResource: 10_000,
|
|
resourcePools: { ...state.resourcePools, mana: 8_000 },
|
|
maxResourcePools: { ...state.maxResourcePools, mana: 10_000 },
|
|
};
|
|
});
|
|
}
|
|
|
|
function cast(name: string, now: number, targetActorId: string | null = null) {
|
|
const ability = shamanAbility(name);
|
|
const result = useCombatStore.getState().castAbility(ability.id, { targetActorId, now });
|
|
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
|
|
return { ability, result, activeCast: useCombatStore.getState().activeCast };
|
|
}
|
|
|
|
function finishCast(now: number): void {
|
|
const activeCast = useCombatStore.getState().activeCast;
|
|
if (activeCast) useCombatStore.getState().tick(0.1, now);
|
|
}
|
|
|
|
function tidalWavesAura() {
|
|
return useCombatStore.getState().auras.find((aura) => (
|
|
aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.definition.utilityTags?.includes(WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG)
|
|
));
|
|
}
|
|
|
|
function healRoll(abilityId: string, targetId: string, impactAt: number): number {
|
|
let hash = 2_166_136_261;
|
|
for (const character of `${PLAYER_AURA_ENTITY_ID}:${abilityId}:${targetId}:${impactAt}:heal`) {
|
|
hash ^= character.charCodeAt(0);
|
|
hash = Math.imul(hash, 16_777_619);
|
|
}
|
|
return (hash >>> 0) / 0x1_0000_0000;
|
|
}
|
|
|
|
function findHealImpactAt(
|
|
abilityId: string,
|
|
minimumChancePercent: number,
|
|
maximumChancePercent: number,
|
|
startAt: number,
|
|
endAt: number,
|
|
targetId = PLAYER_AURA_ENTITY_ID,
|
|
): number {
|
|
for (let impactAt = startAt; impactAt <= endAt; impactAt += 1) {
|
|
const roll = healRoll(abilityId, targetId, impactAt);
|
|
if (roll >= minimumChancePercent / 100 && roll < maximumChancePercent / 100) return impactAt;
|
|
}
|
|
throw new Error("Could not find a seeded Shaman healing roll in the requested interval.");
|
|
}
|
|
|
|
function lastHealingEvent(abilityId: string) {
|
|
return [...useCombatStore.getState().combatEvents].reverse().find((event) => (
|
|
event.kind === "healing" && event.abilityId === abilityId
|
|
));
|
|
}
|
|
|
|
describe("WoW Shaman Tidal Waves", () => {
|
|
beforeEach(setupShaman);
|
|
|
|
it.each([
|
|
{ rank: 1, now: 1_500, expectedCharges: 2 },
|
|
{ rank: 1, now: 1_000, expectedCharges: 0 },
|
|
{ rank: 2, now: 1_500, expectedCharges: 2 },
|
|
{ rank: 2, now: 1_000, expectedCharges: 0 },
|
|
{ rank: 3, now: 1_000, expectedCharges: 2 },
|
|
])("uses rank $rank's actual Riptide proc chance at $now", ({ rank, now, expectedCharges }) => {
|
|
setRiptideRank();
|
|
setTidalWavesRank(rank);
|
|
expect(talentModifiersForRanks({ "talent-2063": rank }, "shaman")).toEqual([]);
|
|
|
|
const { ability } = cast("Riptide", now);
|
|
expect(lastHealingEvent(ability.id)).toBeDefined();
|
|
finishCast(now);
|
|
|
|
expect(tidalWavesAura()?.stacks ?? 0).toBe(expectedCharges);
|
|
if (expectedCharges > 0) expect(tidalWavesAura()?.expiresAt).toBe(now + 15_000);
|
|
});
|
|
|
|
it("grants three Chain Heal charges and consumes one per Healing Wave cast", () => {
|
|
setTidalWavesRank(3);
|
|
const { ability: chainHeal, activeCast: chainCast } = cast("Chain Heal", 1_000);
|
|
expect(chainCast?.completesAt).toBe(3_500);
|
|
finishCast(3_500);
|
|
expect(lastHealingEvent(chainHeal.id)).toBeDefined();
|
|
expect(tidalWavesAura()?.stacks).toBe(3);
|
|
expect(tidalWavesAura()?.expiresAt).toBe(18_500);
|
|
|
|
const expectedDurations = [2_100, 2_400, 2_700];
|
|
const startsAt = [4_000, 6_200, 8_700];
|
|
for (const [index, startAt] of startsAt.entries()) {
|
|
const { activeCast } = cast("Healing Wave", startAt);
|
|
expect(activeCast?.originalDurationMs).toBe(expectedDurations[index]);
|
|
expect(tidalWavesAura()?.stacks ?? 0).toBe(2 - index);
|
|
finishCast(activeCast!.completesAt);
|
|
}
|
|
expect(tidalWavesAura()).toBeUndefined();
|
|
});
|
|
|
|
it("uses Riptide charges for Lesser Healing Wave crit chance and spends one charge", () => {
|
|
setRiptideRank();
|
|
setTidalWavesRank(3);
|
|
cast("Riptide", 1_000);
|
|
expect(tidalWavesAura()?.stacks).toBe(2);
|
|
|
|
const lesserHealingWave = shamanAbility("Lesser Healing Wave");
|
|
const state = useCombatStore.getState();
|
|
const baseCritPercent = deriveCharacterStats(
|
|
state.classId,
|
|
state.level,
|
|
state.gearStats,
|
|
state.raceId,
|
|
{},
|
|
state.secondaryClassId,
|
|
).spellCritPercent;
|
|
const impactAt = findHealImpactAt(
|
|
lesserHealingWave.id,
|
|
baseCritPercent,
|
|
baseCritPercent + 20,
|
|
8_000,
|
|
15_000,
|
|
);
|
|
const { activeCast } = cast("Lesser Healing Wave", impactAt - 1_500);
|
|
expect(activeCast?.completesAt).toBe(impactAt);
|
|
expect(activeCast?.tidalWavesChargesConsumed).toBe(2);
|
|
expect(tidalWavesAura()?.stacks).toBe(1);
|
|
finishCast(impactAt);
|
|
expect(lastHealingEvent(lesserHealingWave.id)?.critical).toBe(true);
|
|
});
|
|
|
|
it("consumes only the primary target's Riptide HoT for its Chain Heal bonus", async () => {
|
|
expect(talentModifiersForRanks({ "talent-2064": 1 }, "shaman")).toEqual([]);
|
|
const chainHeal = shamanAbility("Chain Heal");
|
|
const primaryTarget = usePartyStore.getState().members[0];
|
|
expect(primaryTarget).toBeDefined();
|
|
const baselineState = useCombatStore.getState();
|
|
const baselineCrit = deriveCharacterStats(
|
|
baselineState.classId,
|
|
baselineState.level,
|
|
baselineState.gearStats,
|
|
baselineState.raceId,
|
|
{},
|
|
baselineState.secondaryClassId,
|
|
).spellCritPercent;
|
|
const impactAt = findHealImpactAt(
|
|
chainHeal.id,
|
|
baselineCrit,
|
|
100,
|
|
5_000,
|
|
10_000,
|
|
primaryTarget.id,
|
|
);
|
|
|
|
const { activeCast: baselineCast } = cast("Chain Heal", impactAt - 2_500, primaryTarget.id);
|
|
expect(baselineCast?.completesAt).toBe(impactAt);
|
|
finishCast(impactAt);
|
|
const baselineEvent = [...useCombatStore.getState().combatEvents].reverse().find((event) => (
|
|
event.kind === "healing" && event.abilityId === chainHeal.id && event.targetActorId === primaryTarget.id
|
|
));
|
|
expect(baselineEvent?.critical).toBe(false);
|
|
expect(baselineEvent?.rawAmount).toBeGreaterThan(0);
|
|
|
|
await setupShaman();
|
|
const sameTarget = usePartyStore.getState().members[0];
|
|
const sameTargetRiptide = cast("Riptide", 1_000, sameTarget.id);
|
|
const riptideHot = useCombatStore.getState().effects.find((effect) => (
|
|
effect.kind === "hot"
|
|
&& effect.sourceAbilityId === sameTargetRiptide.ability.id
|
|
&& effect.targetId === sameTarget.id
|
|
));
|
|
expect(riptideHot).toMatchObject({ remainingTicks: 5, intervalMs: 3_000 });
|
|
const { activeCast: empoweredCast } = cast("Chain Heal", impactAt - 2_500, sameTarget.id);
|
|
expect(empoweredCast?.completesAt).toBe(impactAt);
|
|
finishCast(impactAt);
|
|
const empoweredEvent = [...useCombatStore.getState().combatEvents].reverse().find((event) => (
|
|
event.kind === "healing" && event.abilityId === chainHeal.id && event.targetActorId === sameTarget.id
|
|
));
|
|
expect(empoweredEvent?.critical).toBe(false);
|
|
expect(empoweredEvent?.rawAmount).toBe(Math.round((baselineEvent?.rawAmount ?? 0) * 1.25));
|
|
expect(useCombatStore.getState().effects).not.toContainEqual(expect.objectContaining({
|
|
kind: "hot",
|
|
sourceAbilityId: sameTargetRiptide.ability.id,
|
|
targetId: sameTarget.id,
|
|
}));
|
|
|
|
await setupShaman();
|
|
const riptideTarget = usePartyStore.getState().members[0];
|
|
const differentChainHealTarget = usePartyStore.getState().members[1];
|
|
const mismatchedRiptide = cast("Riptide", 1_000, riptideTarget.id);
|
|
const { activeCast: mismatchedCast } = cast(
|
|
"Chain Heal",
|
|
impactAt - 2_500,
|
|
differentChainHealTarget.id,
|
|
);
|
|
expect(mismatchedCast?.completesAt).toBe(impactAt);
|
|
finishCast(impactAt);
|
|
const mismatchedEvent = [...useCombatStore.getState().combatEvents].reverse().find((event) => (
|
|
event.kind === "healing"
|
|
&& event.abilityId === chainHeal.id
|
|
&& event.targetActorId === differentChainHealTarget.id
|
|
));
|
|
expect(mismatchedEvent?.rawAmount).toBe(baselineEvent?.rawAmount);
|
|
expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({
|
|
kind: "hot",
|
|
sourceAbilityId: mismatchedRiptide.ability.id,
|
|
targetId: riptideTarget.id,
|
|
}));
|
|
});
|
|
|
|
it("adds the rank's bonus-healing coefficient without passively granting cast or crit effects", async () => {
|
|
const spellPower = 1_000;
|
|
useCombatStore.setState((state) => ({
|
|
gearStats: { ...state.gearStats, spellPower },
|
|
}));
|
|
const lesserHealingWave = shamanAbility("Lesser Healing Wave");
|
|
const baselineState = useCombatStore.getState();
|
|
const baselineCrit = deriveCharacterStats(
|
|
baselineState.classId,
|
|
baselineState.level,
|
|
baselineState.gearStats,
|
|
baselineState.raceId,
|
|
{},
|
|
baselineState.secondaryClassId,
|
|
).spellCritPercent;
|
|
const impactAt = findHealImpactAt(
|
|
lesserHealingWave.id,
|
|
baselineCrit,
|
|
100,
|
|
10_000,
|
|
15_000,
|
|
);
|
|
const castStart = impactAt - 1_500;
|
|
const { activeCast: baselineCast } = cast("Lesser Healing Wave", castStart);
|
|
expect(baselineCast?.originalDurationMs).toBe(1_500);
|
|
finishCast(impactAt);
|
|
const baselineEvent = lastHealingEvent(lesserHealingWave.id);
|
|
expect(baselineEvent?.critical).toBe(false);
|
|
const baselineRawAmount = baselineEvent?.rawAmount ?? 0;
|
|
|
|
await setupShaman();
|
|
useCombatStore.setState((state) => ({
|
|
gearStats: { ...state.gearStats, spellPower },
|
|
}));
|
|
setTidalWavesRank(3);
|
|
const rankThreeHealingWave = shamanAbility("Healing Wave");
|
|
const { activeCast: unchargedHealingWave } = cast("Healing Wave", 10_000);
|
|
expect(unchargedHealingWave?.originalDurationMs).toBe(3_000);
|
|
finishCast(13_000);
|
|
const rankThreeHeal = lastHealingEvent(rankThreeHealingWave.id);
|
|
expect(rankThreeHeal?.rawAmount).toBeGreaterThan(0);
|
|
|
|
await setupShaman();
|
|
useCombatStore.setState((state) => ({
|
|
gearStats: { ...state.gearStats, spellPower },
|
|
}));
|
|
setTidalWavesRank(3);
|
|
const rankedLesserHealingWave = shamanAbility("Lesser Healing Wave");
|
|
const { activeCast: rankThreeCast } = cast("Lesser Healing Wave", castStart);
|
|
expect(rankThreeCast?.originalDurationMs).toBe(1_500);
|
|
finishCast(impactAt);
|
|
const rankThreeEvent = lastHealingEvent(rankedLesserHealingWave.id);
|
|
expect(rankThreeEvent?.critical).toBe(false);
|
|
expect(rankThreeEvent?.rawAmount).toBe(baselineRawAmount + 120);
|
|
});
|
|
});
|