Files
healer-man/src/game/wow335WarlockMechanics.test.ts
T
2026-10-02 10:38:12 -04:00

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