Update Healer Man server
This commit is contained in:
1 parent
9d7e0e2f9e
commit
35dfdaed61
94 files changed
+17034
-418
No files matched your search
@@ -0,0 +1,365 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilityById, resourceProfileForClass } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { applyAura, resolveResourceValue } from "./combatAuras";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
ROM_MAGE_WARLOCK_PASSIVE_TAGS,
|
||||
romMageWarlockCastEffects,
|
||||
} from "./romMageWarlockOverrides";
|
||||
import { applyTalentModifierValue } from "./talentModifiers";
|
||||
|
||||
function initializeMage(secondaryClassId?: "rom-druid" | "rom-warden" | "rom-champion", level = 80): void {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-mage",
|
||||
secondaryClassId,
|
||||
raceId: "rom-human",
|
||||
level,
|
||||
});
|
||||
}
|
||||
|
||||
function addTarget(id: string, x: number, z = 0): void {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000 });
|
||||
setMobPosition(id, [x, 0, z]);
|
||||
}
|
||||
|
||||
describe("RoM Mage mechanics", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
||||
});
|
||||
|
||||
it("maps Elemental Catalysis mana percentage regeneration against maximum mana", () => {
|
||||
const catalysis = abilityById("rom-mage-primary-0")!;
|
||||
expect(catalysis.effects).toContainEqual(expect.objectContaining({
|
||||
kind: "apply-aura",
|
||||
aura: expect.objectContaining({
|
||||
durationMs: 20_000,
|
||||
modifiers: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "stat",
|
||||
stat: "mana-regeneration-max-percent-per-second",
|
||||
operation: "flat",
|
||||
value: 0.01,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}));
|
||||
|
||||
initializeMage(undefined, 60);
|
||||
addTarget("catalysis-combat", 3);
|
||||
useCombatStore.setState((state) => ({
|
||||
...state,
|
||||
resource: state.maxResource / 2,
|
||||
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana! / 2 },
|
||||
lastResourceSpentAt: { mana: 1_000 },
|
||||
mobs: { ...state.mobs, "catalysis-combat": { ...state.mobs["catalysis-combat"]!, engaged: true } },
|
||||
}));
|
||||
const before = useCombatStore.getState().resourcePools.mana!;
|
||||
expect(useCombatStore.getState().castAbility(catalysis.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
|
||||
&& modifier.stat === "mana-regeneration-max-percent-per-second" && modifier.value === 0.01))).toBe(true);
|
||||
useCombatStore.getState().tick(1, 2_000);
|
||||
useCombatStore.getState().tick(1, 3_000);
|
||||
const restored = useCombatStore.getState().resourcePools.mana! - before;
|
||||
expect(restored).toBeGreaterThanOrEqual(useCombatStore.getState().maxResourcePools.mana! * 0.02);
|
||||
});
|
||||
|
||||
it("uses rank-two Catalysis's explicit 20-second damage and spell-speed payload without adding a mana tick", () => {
|
||||
initializeMage(undefined, 65);
|
||||
const catalysis = abilityById("rom-mage-primary-0")!;
|
||||
const rankTwo = catalysis.ranks?.find((rank) => rank.spellId === 497971);
|
||||
expect(rankTwo).toBeDefined();
|
||||
expect(rankTwo?.effects).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "apply-aura",
|
||||
aura: expect.objectContaining({
|
||||
durationMs: 20_000,
|
||||
modifiers: [expect.objectContaining({ kind: "damage", direction: "dealt", attackKind: "spell", value: 0.2 })],
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "apply-aura",
|
||||
aura: expect.objectContaining({
|
||||
durationMs: 20_000,
|
||||
modifiers: [expect.objectContaining({ kind: "stat", stat: "spell-haste", value: 50 })],
|
||||
}),
|
||||
}),
|
||||
]));
|
||||
expect(rankTwo?.effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
|
||||
&& modifier.stat === "mana-regeneration-max-percent-per-second"))).toBe(false);
|
||||
|
||||
addTarget("catalysis-rank-two-combat", 3);
|
||||
useCombatStore.setState((state) => ({
|
||||
...state,
|
||||
lastResourceSpentAt: { mana: 1_000 },
|
||||
mobs: { ...state.mobs, "catalysis-rank-two-combat": { ...state.mobs["catalysis-rank-two-combat"]!, engaged: true } },
|
||||
}));
|
||||
const result = useCombatStore.getState().castAbility(catalysis.id, undefined, 1_000);
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.id.includes("497971")
|
||||
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "damage"
|
||||
&& modifier.attackKind === "spell" && modifier.value === 0.2))).toBe(true);
|
||||
});
|
||||
|
||||
it("applies rank-two Catalysis spell haste to Mage cast time while rank one stays unchanged", () => {
|
||||
const flame = abilityById("rom-mage-primary-1")!;
|
||||
expect(flame).toMatchObject({ name: "Flame", castTimeMs: 3_000 });
|
||||
|
||||
initializeMage(undefined, 65);
|
||||
addTarget("catalysis-haste-rank-two-baseline", 3);
|
||||
useCombatStore.getState().selectMob("catalysis-haste-rank-two-baseline");
|
||||
expect(useCombatStore.getState().castAbility(flame.id, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
const rankTwoBaselineMs = useCombatStore.getState().activeCast!.originalDurationMs;
|
||||
|
||||
initializeMage(undefined, 65);
|
||||
clearMobRuntimeRegistry();
|
||||
addTarget("catalysis-haste-rank-two-target", 3);
|
||||
useCombatStore.getState().selectMob("catalysis-haste-rank-two-target");
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-0", undefined, 3_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.id.includes("497971")
|
||||
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
|
||||
&& modifier.stat === "spell-haste" && modifier.value === 50))).toBe(true);
|
||||
expect(useCombatStore.getState().castAbility(flame.id, undefined, 5_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().activeCast).toMatchObject({
|
||||
abilityId: flame.id,
|
||||
startedAt: 5_000,
|
||||
originalDurationMs: Math.round(rankTwoBaselineMs / 1.5),
|
||||
completesAt: 5_000 + Math.round(rankTwoBaselineMs / 1.5),
|
||||
});
|
||||
|
||||
initializeMage(undefined, 60);
|
||||
clearMobRuntimeRegistry();
|
||||
addTarget("catalysis-haste-rank-one-baseline", 3);
|
||||
useCombatStore.getState().selectMob("catalysis-haste-rank-one-baseline");
|
||||
expect(useCombatStore.getState().castAbility(flame.id, undefined, 7_000)).toMatchObject({ ok: true });
|
||||
const rankOneBaselineMs = useCombatStore.getState().activeCast!.originalDurationMs;
|
||||
|
||||
initializeMage(undefined, 60);
|
||||
clearMobRuntimeRegistry();
|
||||
addTarget("catalysis-haste-rank-one-target", 3);
|
||||
useCombatStore.getState().selectMob("catalysis-haste-rank-one-target");
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-0", undefined, 9_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "stat"
|
||||
&& modifier.stat === "spell-haste"))).toBe(false);
|
||||
expect(useCombatStore.getState().castAbility(flame.id, undefined, 11_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().activeCast).toMatchObject({
|
||||
abilityId: flame.id,
|
||||
startedAt: 11_000,
|
||||
originalDurationMs: rankOneBaselineMs,
|
||||
completesAt: 11_000 + rankOneBaselineMs,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not apply a RoM spell-haste aura to another class's cast time", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", raceId: "human", level: 80 });
|
||||
addTarget("non-mage-haste-baseline", 3);
|
||||
useCombatStore.getState().selectMob("non-mage-haste-baseline");
|
||||
expect(useCombatStore.getState().castAbility("priest-smite", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
const priestBaselineMs = useCombatStore.getState().activeCast!.originalDurationMs;
|
||||
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", raceId: "human", level: 80 });
|
||||
clearMobRuntimeRegistry();
|
||||
addTarget("non-mage-haste-target", 3);
|
||||
useCombatStore.getState().selectMob("non-mage-haste-target");
|
||||
useCombatStore.setState((state) => ({
|
||||
auras: applyAura(state.auras, {
|
||||
definition: {
|
||||
id: "rom-catalysis-non-mage-test",
|
||||
name: "Test Catalysis haste",
|
||||
disposition: "buff",
|
||||
durationMs: 20_000,
|
||||
modifiers: [{ kind: "stat", stat: "spell-haste", operation: "flat", value: 50 }],
|
||||
},
|
||||
sourceId: PLAYER_AGGRO_ID,
|
||||
targetId: PLAYER_AGGRO_ID,
|
||||
now: 3_000,
|
||||
}).auras,
|
||||
}));
|
||||
expect(useCombatStore.getState().castAbility("priest-smite", undefined, 3_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().activeCast).toMatchObject({
|
||||
abilityId: "priest-smite",
|
||||
originalDurationMs: priestBaselineMs,
|
||||
completesAt: 3_000 + priestBaselineMs,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves ordinary mana regeneration unchanged without the Catalysis aura", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", raceId: "human", level: 80 });
|
||||
addTarget("ordinary-mana-combat", 3);
|
||||
useCombatStore.setState((state) => ({
|
||||
...state,
|
||||
resource: state.maxResource / 2,
|
||||
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana! / 2 },
|
||||
lastResourceSpentAt: { mana: 1_000 },
|
||||
mobs: { ...state.mobs, "ordinary-mana-combat": { ...state.mobs["ordinary-mana-combat"]!, engaged: true } },
|
||||
}));
|
||||
const beforeState = useCombatStore.getState();
|
||||
const before = beforeState.resourcePools.mana!;
|
||||
const playerAuras = beforeState.auras.filter((aura) => aura.targetId === PLAYER_AGGRO_ID);
|
||||
const baseRegeneration = applyTalentModifierValue(
|
||||
resourceProfileForClass("priest").regenerationPerSecond
|
||||
+ beforeState.gearStats.mp5 / 5
|
||||
+ beforeState.gearStats.spirit * 0.02,
|
||||
beforeState.talentModifiers,
|
||||
"resource-regeneration-percent",
|
||||
);
|
||||
const expectedPerSecond = resolveResourceValue(
|
||||
resolveResourceValue(baseRegeneration, playerAuras, "mana", "regeneration"),
|
||||
playerAuras,
|
||||
"power",
|
||||
"regeneration",
|
||||
);
|
||||
expect(beforeState.auras.some((aura) => aura.definition.modifiers?.some((modifier) => (
|
||||
modifier.kind === "stat" && modifier.stat === "mana-regeneration-max-percent-per-second"
|
||||
)))).toBe(false);
|
||||
useCombatStore.getState().tick(1, 2_000);
|
||||
useCombatStore.getState().tick(1, 3_000);
|
||||
expect(useCombatStore.getState().resourcePools.mana! - before).toBeCloseTo(expectedPerSecond * 2);
|
||||
});
|
||||
|
||||
it("casts Phoenix down a capped line and leaves other line spells' extension intact", () => {
|
||||
initializeMage();
|
||||
for (const [id, x, z] of [
|
||||
["phoenix-target", 4, 0],
|
||||
["phoenix-path", 8, 0],
|
||||
["phoenix-off-line", 7, 2],
|
||||
["phoenix-beyond", 12.1, 0],
|
||||
] as const) addTarget(id, x, z);
|
||||
useCombatStore.getState().selectMob("phoenix-target");
|
||||
expect(abilityById("rom-mage-primary-6")).toMatchObject({
|
||||
target: "hostile",
|
||||
range: { max: 12 },
|
||||
targeting: { shape: "line", radius: 12 },
|
||||
});
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-6", undefined, 1_000))
|
||||
.toMatchObject({ ok: true, affectedMobIds: ["phoenix-target", "phoenix-path"] });
|
||||
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-scout", raceId: "rom-elf", level: 60 });
|
||||
clearMobRuntimeRegistry();
|
||||
for (const [id, x] of [["arrow-target", 3], ["arrow-path", 7], ["arrow-beyond", 7.1]] as const) {
|
||||
addTarget(id, x);
|
||||
}
|
||||
useCombatStore.getState().selectMob("arrow-target");
|
||||
expect(useCombatStore.getState().castAbility("rom-scout-primary-5", undefined, 4_000))
|
||||
.toMatchObject({ ok: true, affectedMobIds: ["arrow-target", "arrow-path"] });
|
||||
});
|
||||
|
||||
it("doubles only Fireball and Flame on the 10% Eruption proc and runs Control Flame once", () => {
|
||||
const fireball = abilityById("rom-mage-general-0")!;
|
||||
const tags = new Set([
|
||||
ROM_MAGE_WARLOCK_PASSIVE_TAGS.eruption,
|
||||
ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlame,
|
||||
]);
|
||||
const procced = romMageWarlockCastEffects(fireball, fireball.effects, tags, 0.099);
|
||||
const ordinary = romMageWarlockCastEffects(fireball, fireball.effects, tags, 0.1);
|
||||
expect(procced.filter((effect) => effect.kind === "damage")).toEqual(
|
||||
fireball.effects.filter((effect) => effect.kind === "damage").map((effect) => effect.kind === "damage"
|
||||
? expect.objectContaining({
|
||||
coefficient: effect.coefficient * 2,
|
||||
basePoints: (effect.basePoints ?? 0) * 2,
|
||||
pointsPerLevel: (effect.pointsPerLevel ?? 0) * 2,
|
||||
})
|
||||
: effect),
|
||||
);
|
||||
expect(ordinary.filter((effect) => effect.kind === "damage")).toEqual(
|
||||
fireball.effects.filter((effect) => effect.kind === "damage"),
|
||||
);
|
||||
expect(procced).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "apply-aura",
|
||||
aura: expect.objectContaining({
|
||||
id: "rom-mage-druid-control-flame-buff",
|
||||
durationMs: 12_000,
|
||||
modifiers: [expect.objectContaining({ stat: "magical-attack", value: 0.08 })],
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "apply-aura",
|
||||
aura: expect.objectContaining({
|
||||
id: "rom-mage-druid-control-flame-cooldown",
|
||||
durationMs: 20_000,
|
||||
}),
|
||||
}),
|
||||
]));
|
||||
expect(romMageWarlockCastEffects(fireball, fireball.effects, new Set([
|
||||
...tags,
|
||||
ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlameCooldown,
|
||||
]), 0.099).some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.id === "rom-mage-druid-control-flame-buff")).toBe(false);
|
||||
});
|
||||
|
||||
it("applies Eruption and Control Flame when a Fireball proc succeeds", () => {
|
||||
initializeMage("rom-druid");
|
||||
addTarget("eruption-target", 3);
|
||||
useCombatStore.getState().selectMob("eruption-target");
|
||||
const before = useCombatStore.getState().mobs["eruption-target"]!.health;
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.utilityTags?.includes(ROM_MAGE_WARLOCK_PASSIVE_TAGS.eruption))).toBe(true);
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-general-0", undefined, 1_002)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().mobs["eruption-target"]!.health).toBeLessThan(before);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "rom-mage-druid-control-flame-buff"))
|
||||
.toBe(true);
|
||||
const flameBuff = useCombatStore.getState().auras.find((aura) => aura.definition.id === "rom-mage-druid-control-flame-buff");
|
||||
expect(flameBuff?.definition.modifiers).toEqual([
|
||||
expect.objectContaining({ kind: "stat", stat: "magical-attack", operation: "percent", value: 0.08 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reduces Fireball cooldown by one second with Flame Follower", () => {
|
||||
initializeMage("rom-warden");
|
||||
addTarget("follower-target", 3);
|
||||
useCombatStore.getState().selectMob("follower-target");
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.utilityTags?.includes(ROM_MAGE_WARLOCK_PASSIVE_TAGS.flameFollower))).toBe(true);
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-general-0", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().cooldowns["rom-mage-general-0"]).toBe(6_000);
|
||||
});
|
||||
|
||||
it("resets only Lightning and Discharge with Brain Shock", () => {
|
||||
initializeMage("rom-champion");
|
||||
useCombatStore.setState((state) => ({
|
||||
cooldowns: {
|
||||
...state.cooldowns,
|
||||
"rom-mage-general-1": 8_000,
|
||||
"rom-mage-primary-4": 10_000,
|
||||
"rom-mage-general-0": 9_000,
|
||||
},
|
||||
resourcePools: { ...state.resourcePools, rage: 100 },
|
||||
}));
|
||||
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-rom-champion-elite-20", undefined, 1_000))
|
||||
.toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().cooldowns).toMatchObject({
|
||||
"rom-mage-general-1": 0,
|
||||
"rom-mage-primary-4": 0,
|
||||
"rom-mage-general-0": 9_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("reduces Electrostatic Charge cooldown by 30 seconds with Mage/Champion expertise", () => {
|
||||
initializeMage(undefined, 80);
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-2", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(121_000);
|
||||
|
||||
initializeMage("rom-champion", 40);
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-2", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(121_000);
|
||||
|
||||
initializeMage("rom-champion", 80);
|
||||
expect(useCombatStore.getState().castAbility("rom-mage-primary-2", undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(91_000);
|
||||
});
|
||||
|
||||
it("keeps Mage Fire Ward party-scoped", () => {
|
||||
const fireWard = abilityById("rom-mage-general-5")!;
|
||||
expect(fireWard.effects.some((effect) => effect.kind === "apply-aura" && effect.scope === "party"))
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user