Files
healer-man/src/game/wow335ShamanTidalWavesRuntime.test.ts
T
2026-09-30 09:57:13 -04:00

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