forked from phenom/healer-man
327 lines
14 KiB
TypeScript
327 lines
14 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { PLAYER_AURA_ENTITY_ID, useCombatStore, resolvedAbilityCosts } from "./combatStore";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
|
import { usePartyStore } from "./partyStore";
|
|
import { abilitiesForClass, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
|
import { applyTalentModifierValue, talentModifiersForRanks } from "./talentModifiers";
|
|
import { deriveCharacterStats } from "./wotlkStats";
|
|
|
|
function shamanAbility(name: string) {
|
|
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
|
|
expect(ability, `Shaman ${name}`).toBeDefined();
|
|
return ability!;
|
|
}
|
|
|
|
function setTalentRank(talentId: string, rank: number): void {
|
|
useCombatStore.setState((state) => {
|
|
const talentRanks = { ...state.talentRanks, [talentId]: rank };
|
|
const passiveModifiers = state.talentModifiers.filter((modifier) => (
|
|
modifier.sourceTalentId?.startsWith("class-passive:")
|
|
));
|
|
return {
|
|
talentRanks,
|
|
talentModifiers: [...passiveModifiers, ...talentModifiersForRanks(talentRanks, "shaman")],
|
|
};
|
|
});
|
|
}
|
|
|
|
function castAndFinish(abilityId: string, targetActorId: string | null | undefined, now: number): void {
|
|
const result = useCombatStore.getState().castAbility(
|
|
abilityId,
|
|
targetActorId !== undefined ? { targetActorId, now } : { now },
|
|
);
|
|
expect(result.ok, JSON.stringify(result)).toBe(true);
|
|
const activeCast = useCombatStore.getState().activeCast;
|
|
if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt);
|
|
}
|
|
|
|
async function setupShaman(): Promise<void> {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
await preloadAbilityCatalogsForClasses(["shaman", "mage"]);
|
|
useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 });
|
|
useCombatStore.getState().resetSettings();
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
usePartyStore.getState().initializeParty("wow-shaman-conditional-talent-tests", 80, useCombatStore.getState().health);
|
|
useCombatStore.getState().registerMob("shaman-conditional-target", {
|
|
name: "Shaman conditional talent target",
|
|
level: 80,
|
|
maxHealth: 1_000_000,
|
|
xpReward: 0,
|
|
});
|
|
setMobPosition("shaman-conditional-target", [8, 0, 0]);
|
|
useCombatStore.getState().selectMob("shaman-conditional-target");
|
|
}
|
|
|
|
function setMagePartyCaster(): string {
|
|
const member = usePartyStore.getState().members[0]!;
|
|
usePartyStore.setState((state) => ({
|
|
members: state.members.map((candidate) => candidate.id === member.id ? {
|
|
...candidate,
|
|
classId: "mage",
|
|
talentRanks: { "talent-2049": 3 },
|
|
resourceType: "mana",
|
|
resourceName: "Mana",
|
|
} : candidate),
|
|
}));
|
|
useCombatStore.setState((state) => {
|
|
const actors = { ...state.actors };
|
|
delete actors[member.id];
|
|
return { actors };
|
|
});
|
|
return member.id;
|
|
}
|
|
|
|
function damageWithShamanSpellCrit(sourceActorId?: string, attackKind: "spell" | "melee" | "ranged" = "spell") {
|
|
const isParty = Boolean(sourceActorId);
|
|
const sourceMember = sourceActorId
|
|
? usePartyStore.getState().members.find((member) => member.id === sourceActorId)
|
|
: undefined;
|
|
const ability = isParty
|
|
? abilitiesForClass("mage").find((candidate) => candidate.name === "Fireball")!
|
|
: shamanAbility("Lightning Bolt");
|
|
return useCombatStore.getState().damageMob(
|
|
"shaman-conditional-target",
|
|
100,
|
|
10_000 + useCombatStore.getState().combatEvents.length,
|
|
sourceActorId && sourceMember
|
|
? { actorId: sourceActorId, role: sourceMember.role, abilityId: ability.id }
|
|
: undefined,
|
|
attackKind === "spell" ? "nature" : "physical",
|
|
{
|
|
attackKind,
|
|
seed: `shaman-elemental-oath-${sourceActorId ?? "player"}-${attackKind}-${useCombatStore.getState().combatEvents.length}`,
|
|
abilityId: ability.id,
|
|
rules: { canMiss: false, canDodge: false, canParry: false, canBlock: false },
|
|
},
|
|
);
|
|
}
|
|
|
|
function shamanHealRoll(abilityId: string, targetId: string, impactAt: number): number {
|
|
let hash = 2166136261;
|
|
for (const character of `${PLAYER_AURA_ENTITY_ID}:${abilityId}:${targetId}:${impactAt}:heal`) {
|
|
hash ^= character.charCodeAt(0);
|
|
hash = Math.imul(hash, 16777619);
|
|
}
|
|
return (hash >>> 0) / 0x1_0000_0000;
|
|
}
|
|
|
|
function findShamanHealImpactAt(
|
|
abilityId: string,
|
|
targetId: string,
|
|
minimumChancePercent: number,
|
|
maximumChancePercent: number,
|
|
startAt: number,
|
|
): number {
|
|
for (let impactAt = startAt; impactAt < startAt + 100_000; impactAt += 1) {
|
|
const roll = shamanHealRoll(abilityId, targetId, impactAt);
|
|
if (roll >= minimumChancePercent / 100 && roll < maximumChancePercent / 100) return impactAt;
|
|
}
|
|
throw new Error("Could not find a seeded Shaman heal crit roll in the requested interval.");
|
|
}
|
|
|
|
describe("WoW Shaman conditional talent mechanics", () => {
|
|
beforeEach(setupShaman);
|
|
|
|
it("applies each Convection rank to its complete generated spell list", () => {
|
|
const eligibleNames = [
|
|
"Earth Shock",
|
|
"Flame Shock",
|
|
"Frost Shock",
|
|
"Lightning Bolt",
|
|
"Chain Lightning",
|
|
"Lava Burst",
|
|
"Wind Shear",
|
|
];
|
|
|
|
for (let rank = 1; rank <= 5; rank += 1) {
|
|
setTalentRank("talent-564", rank);
|
|
const state = useCombatStore.getState();
|
|
for (const name of eligibleNames) {
|
|
const ability = shamanAbility(name);
|
|
const baseCost = resolvedAbilityCosts(ability, [], { ...state, auras: [] })
|
|
.find((cost) => cost.resource === "mana")?.amount;
|
|
const reducedCost = resolvedAbilityCosts(ability, state.talentModifiers, { ...state, auras: [] })
|
|
.find((cost) => cost.resource === "mana")?.amount;
|
|
expect(baseCost, `${name} base mana cost`).toBeGreaterThan(0);
|
|
expect(reducedCost, `${name} at Convection rank ${rank}`)
|
|
.toBe(Math.round(baseCost! * (1 - rank * 0.03)));
|
|
}
|
|
|
|
const healingWave = shamanAbility("Healing Wave");
|
|
const baseHealingWaveCost = resolvedAbilityCosts(healingWave, [], { ...state, auras: [] })
|
|
.find((cost) => cost.resource === "mana")?.amount;
|
|
const unchangedHealingWaveCost = resolvedAbilityCosts(healingWave, state.talentModifiers, { ...state, auras: [] })
|
|
.find((cost) => cost.resource === "mana")?.amount;
|
|
expect(unchangedHealingWaveCost).toBe(baseHealingWaveCost);
|
|
}
|
|
});
|
|
|
|
it("adds Elemental Oath once to Shaman and party spell crit chance only", () => {
|
|
const partyCasterId = setMagePartyCaster();
|
|
const generatedCritModifiers = (rank: number) => talentModifiersForRanks({ "talent-2049": rank }, "shaman")
|
|
.filter((modifier) => modifier.kind === "critical-chance-percent");
|
|
const playerSpellChance: number[] = [];
|
|
const partySpellChance: number[] = [];
|
|
const playerMeleeChance: number[] = [];
|
|
const playerRangedChance: number[] = [];
|
|
let playerSpellBase = 0;
|
|
let partySpellBase = 0;
|
|
let playerMeleeBase = 0;
|
|
let playerRangedBase = 0;
|
|
|
|
for (let rank = 0; rank <= 3; rank += 1) {
|
|
setTalentRank("talent-2049", rank);
|
|
expect(generatedCritModifiers(rank)).toEqual([]);
|
|
const playerSpell = damageWithShamanSpellCrit();
|
|
const partySpell = damageWithShamanSpellCrit(partyCasterId);
|
|
const playerMelee = damageWithShamanSpellCrit(undefined, "melee");
|
|
const playerRanged = damageWithShamanSpellCrit(undefined, "ranged");
|
|
const playerSpellCrit = playerSpell.combatResult!.attackTable.criticalPercent;
|
|
const partySpellCrit = partySpell.combatResult!.attackTable.criticalPercent;
|
|
const playerMeleeCrit = playerMelee.combatResult!.attackTable.criticalPercent;
|
|
const playerRangedCrit = playerRanged.combatResult!.attackTable.criticalPercent;
|
|
if (rank === 0) {
|
|
playerSpellBase = playerSpellCrit;
|
|
partySpellBase = partySpellCrit;
|
|
playerMeleeBase = playerMeleeCrit;
|
|
playerRangedBase = playerRangedCrit;
|
|
}
|
|
playerSpellChance.push(playerSpellCrit);
|
|
partySpellChance.push(partySpellCrit);
|
|
playerMeleeChance.push(playerMeleeCrit);
|
|
playerRangedChance.push(playerRangedCrit);
|
|
}
|
|
|
|
expect(playerSpellChance).toEqual([0, 1, 2, 3].map((rank) => playerSpellBase + rank));
|
|
expect(partySpellChance).toEqual([0, 1, 2, 3].map((rank) => partySpellBase + rank));
|
|
expect(playerMeleeChance).toEqual([playerMeleeBase, playerMeleeBase, playerMeleeBase, playerMeleeBase]);
|
|
expect(playerRangedChance).toEqual([playerRangedBase, playerRangedBase, playerRangedBase, playerRangedBase]);
|
|
});
|
|
|
|
it("applies the Elemental Oath rank once to Shaman healing spell crits", async () => {
|
|
const probeHealingWave = shamanAbility("Healing Wave");
|
|
const probe = useCombatStore.getState().castAbility(probeHealingWave.id, { targetActorId: null, now: 1_000 });
|
|
expect(probe.ok, JSON.stringify(probe)).toBe(true);
|
|
const castDurationMs = useCombatStore.getState().activeCast!.completesAt - 1_000;
|
|
await setupShaman();
|
|
const healingWave = shamanAbility("Healing Wave");
|
|
const state = useCombatStore.getState();
|
|
useCombatStore.setState({ selectedFriendlyActorId: null });
|
|
usePartyStore.setState({ selectedMemberId: null });
|
|
const baseSpellCrit = deriveCharacterStats(state.classId, state.level, state.gearStats, state.raceId).spellCritPercent
|
|
+ applyTalentModifierValue(0, state.talentModifiers, "critical-chance-percent", {
|
|
abilityName: healingWave.name,
|
|
tags: ["cast-time", "healing", "nature"],
|
|
});
|
|
const targetId = PLAYER_AURA_ENTITY_ID;
|
|
const firstImpactAt = findShamanHealImpactAt(
|
|
healingWave.id,
|
|
targetId,
|
|
baseSpellCrit,
|
|
baseSpellCrit + 1,
|
|
10_000,
|
|
);
|
|
const secondImpactAt = findShamanHealImpactAt(
|
|
healingWave.id,
|
|
targetId,
|
|
baseSpellCrit + 1,
|
|
baseSpellCrit + 2,
|
|
firstImpactAt + 20_000,
|
|
);
|
|
setTalentRank("talent-2049", 1);
|
|
|
|
const castForImpact = (impactAt: number) => {
|
|
useCombatStore.setState((current) => ({ health: Math.max(1, current.maxHealth - 1_000) }));
|
|
castAndFinish(healingWave.id, null, impactAt - castDurationMs);
|
|
const events = useCombatStore.getState().combatEvents;
|
|
const event = [...events].reverse().find((candidate) => (
|
|
candidate.kind === "healing" && candidate.abilityId === healingWave.id
|
|
));
|
|
expect(event, JSON.stringify(events)).toBeDefined();
|
|
expect(event?.occurredAt).toBe(impactAt);
|
|
return event;
|
|
};
|
|
|
|
expect(castForImpact(firstImpactAt)?.critical).toBe(true);
|
|
expect(castForImpact(secondImpactAt)?.critical).toBe(false);
|
|
});
|
|
|
|
it("grants two 30% mana-cost reductions after a critical elemental spell", () => {
|
|
setTalentRank("talent-574", 2);
|
|
useCombatStore.setState((state) => ({
|
|
gearStats: { ...state.gearStats, criticalStrikeRating: 1_000_000 },
|
|
}));
|
|
const lightningBolt = shamanAbility("Lightning Bolt");
|
|
castAndFinish(lightningBolt.id, undefined, 1_000);
|
|
|
|
const criticalBolt = useCombatStore.getState().combatEvents.find((event) => (
|
|
event.kind === "damage" && event.abilityId === lightningBolt.id && event.critical
|
|
));
|
|
expect(criticalBolt).toBeDefined();
|
|
const clearcasting = () => useCombatStore.getState().auras.find((aura) => (
|
|
aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.definition.utilityTags?.includes("wow335-shaman-elemental-focus-clearcasting")
|
|
));
|
|
expect(clearcasting()?.stacks).toBe(2);
|
|
expect(clearcasting()?.expiresAt).toBe(criticalBolt!.occurredAt + 15_000);
|
|
|
|
const healingWave = shamanAbility("Healing Wave");
|
|
const before = useCombatStore.getState();
|
|
const fullCost = resolvedAbilityCosts(healingWave, before.talentModifiers, { ...before, auras: [] })
|
|
.find((cost) => cost.resource === "mana")?.amount;
|
|
expect(fullCost).toBeGreaterThan(0);
|
|
const discountedCost = Math.round(fullCost! * 0.7);
|
|
expect(resolvedAbilityCosts(healingWave, before.talentModifiers, before)
|
|
.find((cost) => cost.resource === "mana")?.amount).toBe(discountedCost);
|
|
|
|
castAndFinish(healingWave.id, null, 10_000);
|
|
expect(clearcasting()?.stacks).toBe(1);
|
|
const oneChargeState = useCombatStore.getState();
|
|
expect(resolvedAbilityCosts(healingWave, oneChargeState.talentModifiers, oneChargeState)
|
|
.find((cost) => cost.resource === "mana")?.amount).toBe(discountedCost);
|
|
castAndFinish(healingWave.id, null, 20_000);
|
|
expect(clearcasting()).toBeUndefined();
|
|
const expiredState = useCombatStore.getState();
|
|
expect(resolvedAbilityCosts(healingWave, expiredState.talentModifiers, expiredState)
|
|
.find((cost) => cost.resource === "mana")?.amount).toBe(fullCost);
|
|
});
|
|
|
|
it("uses Reverberation's rank chance and 20% bonus on the next different Shock", async () => {
|
|
setTalentRank("talent-575", 3);
|
|
const earthShock = shamanAbility("Earth Shock");
|
|
const frostShock = shamanAbility("Frost Shock");
|
|
|
|
castAndFinish(earthShock.id, undefined, 1_000);
|
|
const initialReverberation = useCombatStore.getState().auras.find((aura) => (
|
|
aura.definition.utilityTags?.includes("wow335-shaman-reverberation")
|
|
));
|
|
expect(initialReverberation?.expiresAt).toBe(9_000);
|
|
expect(initialReverberation?.definition.utilityTags).toContain(`wow335-shaman-reverberation:from:${earthShock.id}`);
|
|
|
|
castAndFinish(frostShock.id, undefined, 6_000);
|
|
const frostShockHit = useCombatStore.getState().combatEvents.find((event) => (
|
|
event.kind === "damage" && event.abilityId === frostShock.id
|
|
));
|
|
expect(frostShockHit?.effectiveAmount).toBeGreaterThan(0);
|
|
const refreshedReverberation = useCombatStore.getState().auras.find((aura) => (
|
|
aura.definition.utilityTags?.includes("wow335-shaman-reverberation")
|
|
));
|
|
expect(refreshedReverberation?.definition.utilityTags)
|
|
.toContain(`wow335-shaman-reverberation:from:${frostShock.id}`);
|
|
|
|
const buffedRawDamage = frostShockHit?.rawAmount;
|
|
await setupShaman();
|
|
const baselineBolt = shamanAbility("Lightning Bolt");
|
|
const baselineFrostShock = shamanAbility("Frost Shock");
|
|
castAndFinish(baselineBolt.id, undefined, 1_000);
|
|
castAndFinish(baselineFrostShock.id, undefined, 6_000);
|
|
const baselineHit = useCombatStore.getState().combatEvents.find((event) => (
|
|
event.kind === "damage" && event.abilityId === baselineFrostShock.id
|
|
));
|
|
expect(baselineHit?.rawAmount).toBeGreaterThan(0);
|
|
expect(buffedRawDamage).toBe(Math.round((baselineHit?.rawAmount ?? 0) * 1.2));
|
|
});
|
|
});
|