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

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