forked from phenom/healer-man
257 lines
12 KiB
TypeScript
257 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
|
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
|
import { applyAura } from "./combatAuras";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
import { usePartyStore } from "./partyStore";
|
|
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
|
|
import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
|
|
|
|
function abilityId(name: string): string {
|
|
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
|
|
expect(ability, name).toBeDefined();
|
|
return ability!.id;
|
|
}
|
|
|
|
function unlockTalentAbility(name: string): string {
|
|
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
|
|
expect(ability, name).toBeDefined();
|
|
const nodeId = abilityTalentNodeId(ability!);
|
|
expect(nodeId, name).toBeTruthy();
|
|
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId!]: 1 } }));
|
|
return ability!.id;
|
|
}
|
|
|
|
function setTalentRank(name: string, rank: number): void {
|
|
const talent = WOW335_TALENT_NODES.find((candidate) => candidate.name === name);
|
|
expect(talent, name).toBeDefined();
|
|
useCombatStore.setState((state) => ({
|
|
talentRanks: { ...state.talentRanks, [talent!.id]: rank },
|
|
}));
|
|
}
|
|
|
|
function castAndFinish(name: string, now: number): number {
|
|
const id = abilityId(name);
|
|
const result = useCombatStore.getState().castAbility(id, undefined, now);
|
|
expect(result.ok, `${name}: ${JSON.stringify(result)}`).toBe(true);
|
|
const activeCast = useCombatStore.getState().activeCast;
|
|
if (activeCast?.abilityId === id) {
|
|
useCombatStore.getState().tick(0.1, activeCast.completesAt);
|
|
return activeCast.completesAt;
|
|
}
|
|
return now;
|
|
}
|
|
|
|
function addNearbyMob(id: string, x: number, maxHealth = 100_000): void {
|
|
useCombatStore.getState().registerMob(id, { name: id, maxHealth });
|
|
setMobPosition(id, [x, 0, 0]);
|
|
}
|
|
|
|
function deterministicUnit(seed: string): number {
|
|
let hash = 2166136261;
|
|
for (let index = 0; index < seed.length; index += 1) {
|
|
hash ^= seed.charCodeAt(index);
|
|
hash = Math.imul(hash, 16777619);
|
|
}
|
|
return (hash >>> 0) / 0x1_0000_0000;
|
|
}
|
|
|
|
describe("WoW 3.3.5 Warlock mechanics", () => {
|
|
beforeEach(async () => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
await preloadAbilityCatalogsForClasses(["warlock"]);
|
|
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80 });
|
|
useCombatStore.getState().resetSettings();
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
usePartyStore.getState().initializeParty("wow335-warlock-mechanics-tests", 1, useCombatStore.getState().health);
|
|
useCombatStore.setState((state) => ({
|
|
resource: state.maxResource,
|
|
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana },
|
|
}));
|
|
addNearbyMob("warlock-target", 4);
|
|
useCombatStore.getState().selectMob("warlock-target");
|
|
});
|
|
|
|
it("requires and consumes the caster's Immolate for Conflagrate", () => {
|
|
const target = useCombatStore.getState().mobs["warlock-target"]!;
|
|
const conflagrateId = unlockTalentAbility("Conflagrate");
|
|
const unavailable = useCombatStore.getState().castAbility(conflagrateId, undefined, 1_000);
|
|
expect(unavailable).toMatchObject({ ok: false, reason: "condition-not-met" });
|
|
|
|
const immolateCompleteAt = castAndFinish("Immolate", 3_000);
|
|
const immolateId = abilityId("Immolate");
|
|
expect(useCombatStore.getState().effects.some((effect) => (
|
|
effect.kind === "dot" && effect.sourceAbilityId === immolateId && effect.targetId === target.id
|
|
))).toBe(true);
|
|
|
|
const conflagrate = useCombatStore.getState().castAbility(conflagrateId, undefined, immolateCompleteAt + 100);
|
|
expect(conflagrate.ok, JSON.stringify(conflagrate)).toBe(true);
|
|
expect(conflagrate.damage).toBeGreaterThan(0);
|
|
expect(useCombatStore.getState().effects.some((effect) => (
|
|
effect.kind === "dot" && effect.sourceAbilityId === immolateId && effect.targetId === target.id
|
|
))).toBe(false);
|
|
});
|
|
|
|
it("applies Aftermath's generated rank-2 slow after a landed Conflagrate", () => {
|
|
setTalentRank("Aftermath", 2);
|
|
const conflagrateId = unlockTalentAbility("Conflagrate");
|
|
castAndFinish("Immolate", 3_000);
|
|
const conflagrateAt = castAndFinish("Conflagrate", 8_000);
|
|
|
|
const aftermathSlow = useCombatStore.getState().mobs["warlock-target"]?.statuses.find((status) => (
|
|
status.kind === "slow" && status.sourceAbilityId === conflagrateId
|
|
));
|
|
expect(aftermathSlow).toMatchObject({
|
|
kind: "slow",
|
|
sourceAbilityId: conflagrateId,
|
|
sourceActorId: PLAYER_AGGRO_ID,
|
|
magnitude: 0.5,
|
|
endsAt: conflagrateAt + 4_000,
|
|
});
|
|
});
|
|
|
|
it("models Shadowflame's missing periodic child so Conflagrate can consume it", () => {
|
|
const shadowflameCompleteAt = castAndFinish("Shadowflame", 3_000);
|
|
const shadowflameId = abilityId("Shadowflame");
|
|
const activeShadowflame = useCombatStore.getState().effects.find((effect) => (
|
|
effect.kind === "dot" && effect.sourceAbilityId === shadowflameId && effect.targetId === "warlock-target"
|
|
));
|
|
expect(activeShadowflame).toMatchObject({ kind: "dot", intervalMs: 2_000, remainingTicks: 4, damageSchool: "fire" });
|
|
|
|
const conflagrate = useCombatStore.getState().castAbility(
|
|
unlockTalentAbility("Conflagrate"), undefined, shadowflameCompleteAt + 2_000,
|
|
);
|
|
expect(conflagrate.ok, JSON.stringify(conflagrate)).toBe(true);
|
|
expect(conflagrate.damage).toBeGreaterThan(0);
|
|
expect(useCombatStore.getState().effects.some((effect) => (
|
|
effect.kind === "dot" && effect.sourceAbilityId === shadowflameId
|
|
))).toBe(false);
|
|
});
|
|
|
|
it("ramps Curse of Agony ticks through half, full, then one-and-a-half damage", () => {
|
|
const curseId = abilityId("Curse of Agony");
|
|
const completeAt = castAndFinish("Curse of Agony", 1_000);
|
|
for (let tick = 1; tick <= 12; tick += 1) {
|
|
useCombatStore.getState().tick(0.1, completeAt + tick * 2_000);
|
|
}
|
|
const amounts = useCombatStore.getState().combatEvents
|
|
.filter((event) => event.kind === "damage" && event.abilityId === curseId && event.targetActorId === "warlock-target")
|
|
.map((event) => event.effectiveAmount);
|
|
expect(amounts).toHaveLength(12);
|
|
expect(new Set(amounts.slice(0, 4)).size).toBe(1);
|
|
expect(new Set(amounts.slice(4, 8)).size).toBe(1);
|
|
expect(new Set(amounts.slice(8, 12)).size).toBe(1);
|
|
expect(Math.abs(amounts[4]! - 2 * amounts[0]!)).toBeLessThanOrEqual(2);
|
|
expect(Math.abs(amounts[8]! - 3 * amounts[0]!)).toBeLessThanOrEqual(3);
|
|
});
|
|
|
|
it("uses Seed's DBC threshold and rank-matched area explosion on damage", () => {
|
|
const source = WOW335_ABILITIES_BY_CLASS.warlock.find((ability) => ability.name === "Seed of Corruption");
|
|
expect(source?.ranks?.map((rank) => rank.spellId)).toEqual([27_243, 47_835, 47_836]);
|
|
|
|
addNearbyMob("seed-neighbor", 8);
|
|
const completeAt = castAndFinish("Seed of Corruption", 1_000);
|
|
expect(useCombatStore.getState().effects.find((effect) => effect.kind === "dot" && effect.targetId === "warlock-target"))
|
|
.toMatchObject({ warlockSeed: { threshold: 1_518, explosionSpellId: 47_834, accumulatedDamage: 0 } });
|
|
|
|
const neighborHealth = useCombatStore.getState().mobs["seed-neighbor"]!.health;
|
|
useCombatStore.getState().damageMob("warlock-target", 1_517, completeAt + 100, undefined, "shadow");
|
|
expect(useCombatStore.getState().mobs["seed-neighbor"]!.health).toBe(neighborHealth);
|
|
|
|
useCombatStore.getState().damageMob("warlock-target", 1, completeAt + 200, undefined, "shadow");
|
|
expect(useCombatStore.getState().mobs["seed-neighbor"]!.health).toBeLessThan(neighborHealth);
|
|
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "dot" && effect.targetId === "warlock-target"))
|
|
.toBe(false);
|
|
});
|
|
|
|
it("detonates Seed when its carrier dies before the damage threshold", () => {
|
|
addNearbyMob("seed-neighbor", 8);
|
|
addNearbyMob("seed-carrier", 6, 1_000);
|
|
useCombatStore.getState().selectMob("seed-carrier");
|
|
const completeAt = castAndFinish("Seed of Corruption", 1_000);
|
|
const neighborHealth = useCombatStore.getState().mobs["seed-neighbor"]!.health;
|
|
|
|
useCombatStore.getState().damageMob("seed-carrier", 1_000, completeAt + 100, undefined, "shadow");
|
|
|
|
expect(useCombatStore.getState().mobs["seed-carrier"]?.dead).toBe(true);
|
|
expect(useCombatStore.getState().mobs["seed-neighbor"]!.health).toBeLessThan(neighborHealth);
|
|
});
|
|
|
|
it("procs Backlash only from landed physical attacks, with its source duration and internal cooldown", () => {
|
|
setTalentRank("Backlash", 3);
|
|
const maxHealth = useCombatStore.getState().maxHealth;
|
|
const readyTag = "wow335-warlock-backlash-ready";
|
|
const cooldownTag = "wow335-warlock-backlash-cooldown";
|
|
|
|
useCombatStore.getState().damagePlayer(1, "shadow", 80, 20_000);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes(readyTag))).toBe(false);
|
|
|
|
const procAt = Array.from({ length: 500 }, (_, offset) => 30_000 + offset)
|
|
.find((now) => deterministicUnit(
|
|
`${PLAYER_AGGRO_ID}:external:physical:${now}:${maxHealth}:wow335-warlock-backlash`,
|
|
) < 0.25);
|
|
expect(procAt, "a deterministic source-rank Backlash proc seed").toBeDefined();
|
|
useCombatStore.setState({ health: maxHealth });
|
|
useCombatStore.getState().damagePlayer(1, "physical", 80, procAt!);
|
|
|
|
const ready = useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes(readyTag));
|
|
expect(ready?.expiresAt).toBe(procAt! + 12_000);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes(cooldownTag)
|
|
&& aura.expiresAt === procAt! + 8_000)).toBe(true);
|
|
|
|
useCombatStore.setState({ health: maxHealth });
|
|
useCombatStore.getState().damagePlayer(1, "physical", 80, procAt! + 1);
|
|
expect(useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes(readyTag))?.expiresAt)
|
|
.toBe(procAt! + 12_000);
|
|
});
|
|
|
|
it("makes Backlash's next Shadow Bolt instant, half-cost at rank 3, and consumes its proc", () => {
|
|
setTalentRank("Backlash", 3);
|
|
const playerId = PLAYER_AURA_ENTITY_ID;
|
|
const shadowBolt = useCombatStore.getState().abilities.find((candidate) => candidate.name === "Shadow Bolt")!;
|
|
const manaBefore = useCombatStore.getState().resourcePools.mana!;
|
|
const normalCast = useCombatStore.getState().castAbility(shadowBolt.id, undefined, 1_000);
|
|
expect(normalCast.ok, JSON.stringify(normalCast)).toBe(true);
|
|
const normalActiveCast = useCombatStore.getState().activeCast;
|
|
expect(normalActiveCast?.abilityId).toBe(shadowBolt.id);
|
|
useCombatStore.getState().tick(0.1, normalActiveCast!.completesAt);
|
|
const normalManaSpent = manaBefore - useCombatStore.getState().resourcePools.mana!;
|
|
expect(normalManaSpent).toBeGreaterThan(0);
|
|
|
|
const backlashCastAt = normalActiveCast!.completesAt + 1_000;
|
|
const readyAura = applyAura([], {
|
|
definition: {
|
|
id: "wow335:warlock:backlash-ready",
|
|
name: "Backlash",
|
|
disposition: "buff",
|
|
durationMs: 12_000,
|
|
utilityTags: ["wow335-warlock-backlash-ready"],
|
|
},
|
|
sourceId: playerId,
|
|
targetId: playerId,
|
|
now: backlashCastAt,
|
|
}).auras;
|
|
const afterNormal = useCombatStore.getState();
|
|
useCombatStore.setState({
|
|
auras: readyAura,
|
|
activeCast: null,
|
|
globalCooldownEndsAt: 0,
|
|
resource: afterNormal.maxResource,
|
|
resourcePools: { ...afterNormal.resourcePools, mana: afterNormal.maxResourcePools.mana! },
|
|
});
|
|
const procManaBefore = useCombatStore.getState().resourcePools.mana!;
|
|
|
|
const result = useCombatStore.getState().castAbility(shadowBolt.id, undefined, backlashCastAt);
|
|
expect(result.ok, JSON.stringify(result)).toBe(true);
|
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
|
expect(procManaBefore - useCombatStore.getState().resourcePools.mana!).toBe(Math.round(normalManaSpent * 0.5));
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes(
|
|
"wow335-warlock-backlash-ready",
|
|
))).toBe(false);
|
|
expect(result.damage).toBeGreaterThan(0);
|
|
});
|
|
});
|