forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
35dfdaed61
commit
4fb7a0b9c8
161 files changed
+24618
-529
No files matched your search
@@ -0,0 +1,331 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user