forked from phenom/healer-man
Validate RoM primary five hybrid stats and pair mechanics
This commit is contained in:
1 parent
8e95480d4f
commit
c8b3a3d95a
18 files changed
+2061
-48
No files matched your search
@@ -0,0 +1,277 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilitiesForCharacter, abilityById, learnedAbilityForBuild } from "./abilityCatalog";
|
||||
import { abilityDamageSchool, useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
|
||||
import {
|
||||
ROM_MAGE_WARLOCK_PASSIVE_TAGS,
|
||||
romMageWarlockCastEffects,
|
||||
romMageWarlockCriticalEffects,
|
||||
} from "./romMageWarlockOverrides";
|
||||
|
||||
const ELITES = [
|
||||
[15, 498744, "Static Resonance"],
|
||||
[20, 498745, "Industry Fire"],
|
||||
[25, 498748, "Lightning Display"],
|
||||
[30, 498750, "Breath Erase"],
|
||||
[35, 498751, "Elements of Pride"],
|
||||
[40, 498757, "Soul Stepping"],
|
||||
[45, 498758, "Deep Inspiration"],
|
||||
[50, 498759, "Fire Lightning Burst"],
|
||||
[60, 499813, "Soul Speed"],
|
||||
[70, 499814, "Elemental Extraction"],
|
||||
] as const;
|
||||
|
||||
function elite(level: number) {
|
||||
const result = abilityById(`rom-mage-rom-warlock-elite-${level}`);
|
||||
expect(result, `Mage/Warlock elite ${level}`).toBeDefined();
|
||||
return result!;
|
||||
}
|
||||
|
||||
function initialize(level: number): void {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-mage", secondaryClassId: "rom-warlock", raceId: "rom-dwarf", level,
|
||||
});
|
||||
}
|
||||
|
||||
function registerTarget(id = "mage-warlock-dummy", health = 100_000): void {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: health });
|
||||
setMobPosition(id, [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob(id);
|
||||
}
|
||||
|
||||
describe("accepted RoM Mage/Warlock dwarf pair", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
||||
});
|
||||
|
||||
it.each([10, 30, 60, 79])("learns the correct primary, secondary, and elite kit at level %i", (level) => {
|
||||
const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === "rom-mage")!;
|
||||
expect(identity).toMatchObject({ raceId: "rom-dwarf", secondaryClassId: "rom-warlock", role: "damage" });
|
||||
const learned = abilitiesForCharacter(identity.classId, identity.secondaryClassId)
|
||||
.filter((ability) => learnedAbilityForBuild(ability, level, {}) !== null);
|
||||
expect(learned.some((ability) => ability.id === "rom-mage-primary-1")).toBe(true);
|
||||
expect(learned.some((ability) => ability.id === "rom-warlock-general-0")).toBe(true);
|
||||
expect(learned.filter((ability) => ability.secondaryClassId === "rom-warlock")
|
||||
.map((ability) => [ability.unlockLevel, ability.dbcSpellId, ability.name]))
|
||||
.toEqual(ELITES.filter(([unlock]) => unlock <= level));
|
||||
});
|
||||
|
||||
it("preserves source multi-hit and area damage, defensive absorb, control, and threat tools", () => {
|
||||
expect(abilityById("rom-mage-primary-5")!.effects.filter((effect) => effect.kind === "damage")).toHaveLength(5);
|
||||
expect(abilityById("rom-mage-primary-12")!.effects.filter((effect) => effect.kind === "damage")).toHaveLength(3);
|
||||
expect(abilityById("rom-mage-primary-8")!.radius).toBeGreaterThan(0);
|
||||
expect(abilityById("rom-mage-primary-2")!.effects.some((effect) => (
|
||||
effect.kind === "apply-aura" && (effect.aura.absorbs?.length ?? 0) > 0
|
||||
))).toBe(true);
|
||||
expect(abilityById("rom-mage-primary-56")!.effects).toEqual([
|
||||
expect.objectContaining({ kind: "status", maxTargets: 1, breakOnDamage: true }),
|
||||
]);
|
||||
expect(elite(30).effects).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "apply-aura",
|
||||
aura: expect.objectContaining({
|
||||
modifiers: expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "stat", stat: "threat", operation: "percent", value: -0.1 }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("uses magic schools for Mage and Warlock magic projectiles and dark spells", () => {
|
||||
for (const [id, school] of [
|
||||
["rom-mage-primary-1", "fire"],
|
||||
["rom-mage-primary-52", "nature"],
|
||||
["rom-warlock-general-0", "shadow"],
|
||||
["rom-warlock-general-4", "shadow"],
|
||||
["rom-warlock-general-5", "shadow"],
|
||||
] as const) {
|
||||
expect(abilityDamageSchool("rom-mage", abilityById(id)!), id).toBe(school);
|
||||
}
|
||||
});
|
||||
|
||||
it("damages the selected target using both class resources", () => {
|
||||
initialize(30);
|
||||
registerTarget();
|
||||
const manaBefore = useCombatStore.getState().resourcePools.mana!;
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 4_100);
|
||||
expect(useCombatStore.getState().mobs["mage-warlock-dummy"]!.health).toBeLessThan(100_000);
|
||||
expect(useCombatStore.getState().resourcePools.mana).toBeLessThan(manaBefore);
|
||||
|
||||
initialize(30);
|
||||
registerTarget("warlock-dummy");
|
||||
const focusBefore = useCombatStore.getState().resourcePools.focus!;
|
||||
expect(useCombatStore.getState().castAbility("rom-warlock-general-0", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 3_100);
|
||||
expect(useCombatStore.getState().mobs["warlock-dummy"]!.health).toBeLessThan(100_000);
|
||||
expect(useCombatStore.getState().resourcePools.focus).toBeLessThan(focusBefore);
|
||||
});
|
||||
|
||||
it("Electrostatic Charge creates its source percentage of maximum HP absorb", () => {
|
||||
initialize(60);
|
||||
const before = useCombatStore.getState();
|
||||
expect(before.castAbility("rom-mage-primary-2", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 1_100);
|
||||
const charge = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.definition.name.includes("Electrostatic Charge") && aura.absorbs.length > 0
|
||||
));
|
||||
expect(charge).toBeDefined();
|
||||
expect(charge!.absorbs[0]?.remaining).toBe(Math.round(before.maxHealth * 0.3));
|
||||
});
|
||||
|
||||
it("hits nearby enemies with Mage and Warlock area spells, respecting the source target caps", () => {
|
||||
initialize(30);
|
||||
for (const [id, x] of [["area-a", 2], ["area-b", 3], ["area-c", 4], ["area-far", 20]] as const) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 });
|
||||
setMobPosition(id, [x, 0, 0]);
|
||||
}
|
||||
useCombatStore.getState().selectMob("area-a");
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-8", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
for (const id of ["area-a", "area-b", "area-c"]) {
|
||||
expect(useCombatStore.getState().mobs[id]!.health, `Purgatory Fire ${id}`).toBeLessThan(100_000);
|
||||
}
|
||||
expect(useCombatStore.getState().mobs["area-far"]!.health).toBe(100_000);
|
||||
|
||||
initialize(30);
|
||||
for (const [id, x] of [["weave-a", 2], ["weave-b", 3], ["weave-c", 4], ["weave-far", 20]] as const) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 });
|
||||
setMobPosition(id, [x, 0, 0]);
|
||||
}
|
||||
useCombatStore.getState().selectMob("weave-a");
|
||||
expect(useCombatStore.getState().castAbility("rom-warlock-general-1", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 2_100);
|
||||
for (const id of ["weave-a", "weave-b", "weave-c"]) {
|
||||
expect(useCombatStore.getState().mobs[id]!.health, `Weakening Weave ${id}`).toBeLessThan(100_000);
|
||||
}
|
||||
expect(useCombatStore.getState().mobs["weave-far"]!.health).toBe(100_000);
|
||||
});
|
||||
|
||||
it("Static Resonance resets only Electrostatic Charge", () => {
|
||||
initialize(60);
|
||||
useCombatStore.setState({ cooldowns: {
|
||||
"rom-mage-primary-2": 90_000,
|
||||
"rom-mage-primary-9": 90_000,
|
||||
"rom-mage-general-0": 90_000,
|
||||
} });
|
||||
expect(useCombatStore.getState().castAbility(elite(15).id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(0);
|
||||
expect(useCombatStore.getState().cooldowns["rom-mage-primary-9"]).toBe(90_000);
|
||||
expect(useCombatStore.getState().cooldowns["rom-mage-general-0"]).toBe(90_000);
|
||||
});
|
||||
|
||||
it("Deep Inspiration resets Intensification and Energy Well only", () => {
|
||||
initialize(60);
|
||||
useCombatStore.setState({ cooldowns: {
|
||||
"rom-mage-general-3": 90_000,
|
||||
"rom-mage-primary-10": 90_000,
|
||||
"rom-mage-primary-2": 90_000,
|
||||
} });
|
||||
expect(useCombatStore.getState().castAbility(elite(45).id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().cooldowns["rom-mage-general-3"]).toBe(0);
|
||||
expect(useCombatStore.getState().cooldowns["rom-mage-primary-10"]).toBe(0);
|
||||
expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(90_000);
|
||||
});
|
||||
|
||||
it("Soul Stepping permits a cast to finish after the Mage moves", () => {
|
||||
initialize(60);
|
||||
registerTarget("ordinary-moving-cast-dummy");
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().setPlayerPosition([1, 0, 0]);
|
||||
useCombatStore.getState().tick(0.1, 4_100);
|
||||
expect(useCombatStore.getState().mobs["ordinary-moving-cast-dummy"]!.health).toBe(100_000);
|
||||
|
||||
initialize(60);
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
registerTarget("moving-cast-dummy");
|
||||
expect(useCombatStore.getState().castAbility(elite(40).id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-1", undefined, 2_600))
|
||||
.toMatchObject({ ok: true });
|
||||
useCombatStore.getState().setPlayerPosition([1, 0, 0]);
|
||||
useCombatStore.getState().tick(0.1, 5_700);
|
||||
expect(useCombatStore.getState().mobs["moving-cast-dummy"]!.health).toBeLessThan(100_000);
|
||||
});
|
||||
|
||||
it("Fire Lightning Burst hits twice and resets its own cooldown only if the target survives", () => {
|
||||
initialize(60);
|
||||
registerTarget();
|
||||
useCombatStore.setState({ cooldowns: { "rom-mage-primary-2": 90_000 } });
|
||||
expect(elite(50).effects.filter((effect) => effect.kind === "damage")).toHaveLength(2);
|
||||
expect(useCombatStore.getState().castAbility(elite(50).id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().tick(0.1, 3_100);
|
||||
expect(useCombatStore.getState().mobs["mage-warlock-dummy"]!.health).toBeLessThan(100_000);
|
||||
expect(useCombatStore.getState().cooldowns[elite(50).id] ?? 0).toBeLessThanOrEqual(3_100);
|
||||
expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(90_000);
|
||||
});
|
||||
|
||||
it("does not apply conditional passive buffs or enemy defense penalties to the caster on login", () => {
|
||||
initialize(79);
|
||||
const auraNames = useCombatStore.getState().auras.map((aura) => aura.definition.name);
|
||||
for (const level of [25, 35, 60, 70]) {
|
||||
expect(auraNames.some((name) => name.startsWith(elite(level).name)), elite(level).name).toBe(false);
|
||||
}
|
||||
expect(elite(20).effects.some((effect) => effect.kind === "scripted")).toBe(false);
|
||||
});
|
||||
|
||||
it("converts Soul Pain to fire with an extra hit only while Industry Fire is learned", () => {
|
||||
const soulPain = abilityById("rom-warlock-general-4")!;
|
||||
const ordinary = romMageWarlockCastEffects(soulPain, soulPain.effects, new Set());
|
||||
const upgraded = romMageWarlockCastEffects(soulPain, soulPain.effects,
|
||||
new Set([ROM_MAGE_WARLOCK_PASSIVE_TAGS.industryFire]));
|
||||
expect(ordinary.some((effect) => effect.kind === "damage")).toBe(false);
|
||||
expect(upgraded).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "dot", school: "fire" }),
|
||||
expect.objectContaining({ kind: "damage", school: "fire" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("extends Charged, creates conditional Elements stacks, and enhances Soul Stepping", () => {
|
||||
const tags = new Set([
|
||||
ROM_MAGE_WARLOCK_PASSIVE_TAGS.lightningDisplay,
|
||||
ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementsOfPride,
|
||||
ROM_MAGE_WARLOCK_PASSIVE_TAGS.soulSpeed,
|
||||
]);
|
||||
const plasma = abilityById("rom-mage-primary-52")!;
|
||||
const charged = romMageWarlockCastEffects(plasma, plasma.effects, tags).find((effect) => (
|
||||
effect.kind === "apply-aura" && effect.aura.utilityTags?.includes("rom-charged")
|
||||
));
|
||||
expect(charged).toMatchObject({ aura: { durationMs: 18_000 } });
|
||||
|
||||
const flame = abilityById("rom-mage-primary-1")!;
|
||||
expect(romMageWarlockCastEffects(flame, flame.effects, tags)).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "apply-aura", recipient: "caster",
|
||||
aura: expect.objectContaining({ id: "rom-mage-warlock-elements-nature", maxStacks: 2 }),
|
||||
}),
|
||||
]));
|
||||
const stepping = elite(40);
|
||||
expect(romMageWarlockCastEffects(stepping, stepping.effects, new Set()).some((effect) => (
|
||||
effect.kind === "apply-aura" && [24, 51].includes(effect.sourceAuraType)
|
||||
))).toBe(false);
|
||||
expect(romMageWarlockCastEffects(stepping, stepping.effects, tags).some((effect) => (
|
||||
effect.kind === "apply-aura" && [24, 51].includes(effect.sourceAuraType)
|
||||
))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps Elemental Extraction's defense debuff on a critical-hit target", () => {
|
||||
const explosion = abilityById("rom-mage-primary-54")!;
|
||||
expect(romMageWarlockCriticalEffects(explosion, new Set())).toEqual([]);
|
||||
expect(romMageWarlockCriticalEffects(explosion,
|
||||
new Set([ROM_MAGE_WARLOCK_PASSIVE_TAGS.elementalExtraction]))).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "apply-aura", recipient: "ability-target",
|
||||
aura: expect.objectContaining({
|
||||
disposition: "debuff", durationMs: 10_000,
|
||||
modifiers: expect.arrayContaining([
|
||||
expect.objectContaining({ stat: "armor", value: -0.3 }),
|
||||
expect.objectContaining({ stat: "magical-defense", value: -0.3 }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user