forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
35dfdaed61
commit
4fb7a0b9c8
161 files changed
+24618
-529
No files matched your search
@@ -0,0 +1,250 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AURA_ENTITY_ID, resolvedAbilityCosts, useCombatStore } from "./combatStore";
|
||||
import { abilityAtLevel, abilityById } from "./abilityCatalog";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { WOW335_SHAMAN_MAELSTROM_WEAPON_AURA_TAG } from "./wow335ShamanMaelstromWeaponRuntime";
|
||||
|
||||
const TARGET_ID = "maelstrom-target";
|
||||
const OFF_HAND_WEAPON_ID = "maelstrom-test-off-hand";
|
||||
|
||||
function runtimeManaCost(abilityId: string, state: ReturnType<typeof useCombatStore.getState>, now: number): number {
|
||||
const catalogAbility = abilityById(abilityId);
|
||||
expect(catalogAbility).not.toBeNull();
|
||||
const rankedAbility = abilityAtLevel(catalogAbility!, state.level);
|
||||
return resolvedAbilityCosts(rankedAbility, state.talentModifiers, { ...state, now })
|
||||
.find((cost) => cost.resource === "mana")?.amount ?? 0;
|
||||
}
|
||||
|
||||
function shamanAbility(name: string) {
|
||||
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
|
||||
expect(ability, `Shaman ${name}`).toBeDefined();
|
||||
return ability!;
|
||||
}
|
||||
|
||||
function setMaelstromRank(rank: number): void {
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-2057": rank },
|
||||
}));
|
||||
}
|
||||
|
||||
function maelstromAura() {
|
||||
return useCombatStore.getState().auras.find((aura) => (
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes(WOW335_SHAMAN_MAELSTROM_WEAPON_AURA_TAG)
|
||||
));
|
||||
}
|
||||
|
||||
function stableCombatUnit(seed: string): number {
|
||||
let hash = 2_166_136_261;
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
hash ^= seed.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16_777_619);
|
||||
}
|
||||
return (hash >>> 0) / 0x1_0000_0000;
|
||||
}
|
||||
|
||||
function setupShaman(): void {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
const offHandStats = {
|
||||
...EMPTY_ITEM_STATS,
|
||||
meleeWeapon: { damageMin: 20, damageMax: 30, damageSchool: "physical" as const, speedMs: 5_000 },
|
||||
};
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "shaman",
|
||||
level: 80,
|
||||
xp: 0,
|
||||
inventory: [{
|
||||
instanceId: OFF_HAND_WEAPON_ID,
|
||||
itemId: 99001,
|
||||
itemKey: OFF_HAND_WEAPON_ID,
|
||||
name: "Slow off-hand weapon",
|
||||
quality: "uncommon",
|
||||
inventorySlot: "off-hand",
|
||||
armorType: null,
|
||||
weaponType: "mace",
|
||||
itemLevel: 80,
|
||||
sourceItemLevel: 80,
|
||||
stats: offHandStats,
|
||||
quantity: 1,
|
||||
sourceBossKey: "test",
|
||||
sourceBossName: "Test",
|
||||
acquiredAt: 1,
|
||||
}],
|
||||
equipment: { player: { "off-hand": OFF_HAND_WEAPON_ID } },
|
||||
});
|
||||
useCombatStore.getState().resetSettings();
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
// Keep a distinct one-second main-hand value so the Lava Lash assertion
|
||||
// proves that the proc uses its equipped five-second off-hand weapon.
|
||||
useCombatStore.setState((state) => ({
|
||||
gearStats: {
|
||||
...state.gearStats,
|
||||
meleeWeapon: { damageMin: 20, damageMax: 30, damageSchool: "physical", speedMs: 1_000 },
|
||||
},
|
||||
}));
|
||||
usePartyStore.getState().initializeParty("wow-shaman-maelstrom-tests", 80, useCombatStore.getState().health);
|
||||
useCombatStore.getState().registerMob(TARGET_ID, {
|
||||
name: "Maelstrom target",
|
||||
level: 80,
|
||||
maxHealth: 1_000_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition(TARGET_ID, [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob(TARGET_ID);
|
||||
}
|
||||
|
||||
function cast(abilityId: string, now: number, targetActorId: string | null = TARGET_ID): void {
|
||||
const result = useCombatStore.getState().castAbility(abilityId, { targetActorId, now });
|
||||
expect(result.ok, JSON.stringify(result)).toBe(true);
|
||||
}
|
||||
|
||||
describe("WoW Shaman Maelstrom Weapon combat runtime", () => {
|
||||
beforeEach(setupShaman);
|
||||
|
||||
it("procs on landed melee hits using Lava Lash's equipped off-hand speed", () => {
|
||||
setMaelstromRank(3);
|
||||
const lavaLash = shamanAbility("Lava Lash");
|
||||
const seedUnitAtMainHandRate = (now: number) => stableCombatUnit(
|
||||
`${PLAYER_AURA_ENTITY_ID}:${lavaLash.id}:${TARGET_ID}:${now}:direct:wow335-shaman-maelstrom-weapon`,
|
||||
);
|
||||
const now = Array.from({ length: 1_000 }, (_, index) => 1_000 + index)
|
||||
.find((candidate) => seedUnitAtMainHandRate(candidate) >= 0.2)!;
|
||||
expect(now).toBeDefined();
|
||||
|
||||
cast(lavaLash.id, now);
|
||||
|
||||
expect(seedUnitAtMainHandRate(now)).toBeGreaterThanOrEqual(0.2);
|
||||
expect(maelstromAura()).toMatchObject({ stacks: 1, expiresAt: now + 30_000 });
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => (
|
||||
event.kind === "damage" && event.abilityId === lavaLash.id && event.effectiveAmount > 0
|
||||
))).toBe(true);
|
||||
});
|
||||
|
||||
it("adds one stack per proc, refreshes the 30-second duration, and caps at five", () => {
|
||||
setMaelstromRank(3);
|
||||
const lavaLash = shamanAbility("Lava Lash");
|
||||
const interval = Math.max(lavaLash.cooldownMs, lavaLash.gcdMs, 1_500) + 10;
|
||||
let now = 1_000;
|
||||
|
||||
for (let hit = 1; hit <= 6; hit += 1) {
|
||||
cast(lavaLash.id, now);
|
||||
expect(maelstromAura()?.stacks).toBe(Math.min(hit, 5));
|
||||
expect(maelstromAura()?.expiresAt).toBe(now + 30_000);
|
||||
now += interval;
|
||||
}
|
||||
});
|
||||
|
||||
it("reduces Lightning Bolt cast time per stack, consumes the whole bundle at cast start, and still charges Mana", () => {
|
||||
setMaelstromRank(3);
|
||||
const lavaLash = shamanAbility("Lava Lash");
|
||||
const lightningBolt = shamanAbility("Lightning Bolt");
|
||||
const interval = Math.max(lavaLash.cooldownMs, lavaLash.gcdMs, 1_500) + 10;
|
||||
let now = 1_000;
|
||||
for (let hit = 0; hit < 5; hit += 1) {
|
||||
cast(lavaLash.id, now);
|
||||
now += interval;
|
||||
}
|
||||
expect(maelstromAura()?.stacks).toBe(5);
|
||||
|
||||
// Earth Shock is not listed by the generated Maelstrom tooltip and must
|
||||
// leave the active next-spell bundle alone.
|
||||
const earthShock = shamanAbility("Earth Shock");
|
||||
cast(earthShock.id, now);
|
||||
expect(maelstromAura()?.stacks).toBe(5);
|
||||
|
||||
now += Math.max(earthShock.cooldownMs, earthShock.gcdMs, 1_500) + 10;
|
||||
const before = useCombatStore.getState();
|
||||
const manaBefore = before.resource;
|
||||
const expectedManaCost = runtimeManaCost(lightningBolt.id, before, now);
|
||||
cast(lightningBolt.id, now);
|
||||
|
||||
expect(useCombatStore.getState().activeCast).toBeNull();
|
||||
expect(maelstromAura()).toBeUndefined();
|
||||
expect(useCombatStore.getState().resource).toBe(manaBefore - expectedManaCost);
|
||||
});
|
||||
|
||||
it("applies a partial cast-time reduction, consumes the bundle on start, and does not make the spell free", () => {
|
||||
const lightningBolt = shamanAbility("Lightning Bolt");
|
||||
cast(lightningBolt.id, 500);
|
||||
const baselineCast = useCombatStore.getState().activeCast;
|
||||
expect(baselineCast).not.toBeNull();
|
||||
const baselineDurationMs = baselineCast!.completesAt - baselineCast!.startedAt;
|
||||
|
||||
setupShaman();
|
||||
setMaelstromRank(3);
|
||||
const lavaLash = shamanAbility("Lava Lash");
|
||||
const rankedLightningBolt = shamanAbility("Lightning Bolt");
|
||||
cast(lavaLash.id, 1_000);
|
||||
expect(maelstromAura()?.stacks).toBe(1);
|
||||
|
||||
const now = 3_000;
|
||||
const before = useCombatStore.getState();
|
||||
const manaBefore = before.resource;
|
||||
const expectedManaCost = runtimeManaCost(rankedLightningBolt.id, before, now);
|
||||
cast(rankedLightningBolt.id, now);
|
||||
const activeCast = useCombatStore.getState().activeCast;
|
||||
expect(activeCast!.completesAt! - activeCast!.startedAt!)
|
||||
.toBe(Math.round(baselineDurationMs * 0.8));
|
||||
expect(maelstromAura()).toBeUndefined();
|
||||
expect(useCombatStore.getState().resource).toBe(manaBefore);
|
||||
|
||||
useCombatStore.getState().tick(0.1, activeCast!.completesAt);
|
||||
expect(useCombatStore.getState().resource).toBeLessThan(manaBefore);
|
||||
expect(useCombatStore.getState().resource).toBeCloseTo(manaBefore - expectedManaCost, 0);
|
||||
});
|
||||
|
||||
it("does not proc from a spell hit or a melee hit without a trained rank", () => {
|
||||
const lavaLash = shamanAbility("Lava Lash");
|
||||
const lightningBolt = shamanAbility("Lightning Bolt");
|
||||
cast(lavaLash.id, 1_000);
|
||||
expect(maelstromAura()).toBeUndefined();
|
||||
|
||||
setMaelstromRank(3);
|
||||
cast(lightningBolt.id, 3_000);
|
||||
expect(maelstromAura()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not proc from a missed melee attack", () => {
|
||||
setMaelstromRank(3);
|
||||
const lavaLash = shamanAbility("Lava Lash");
|
||||
useCombatStore.getState().registerMob(TARGET_ID, {
|
||||
name: "High level Maelstrom target",
|
||||
level: 200,
|
||||
maxHealth: 1_000_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
const now = 1_000;
|
||||
|
||||
cast(lavaLash.id, now);
|
||||
|
||||
const damageEvent = useCombatStore.getState().combatEvents.find((event) => (
|
||||
event.kind === "damage" && event.abilityId === lavaLash.id
|
||||
));
|
||||
expect(damageEvent?.effectiveAmount).toBe(0);
|
||||
expect(maelstromAura()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores an expired Maelstrom aura when calculating cast time", () => {
|
||||
setMaelstromRank(3);
|
||||
const lavaLash = shamanAbility("Lava Lash");
|
||||
const lightningBolt = shamanAbility("Lightning Bolt");
|
||||
cast(lavaLash.id, 1_000);
|
||||
const generatedAura = maelstromAura();
|
||||
expect(generatedAura).toBeDefined();
|
||||
useCombatStore.setState((state) => ({
|
||||
auras: state.auras.map((aura) => aura.instanceId === generatedAura!.instanceId
|
||||
? { ...aura, expiresAt: 3_000 }
|
||||
: aura),
|
||||
}));
|
||||
|
||||
cast(lightningBolt.id, 3_000);
|
||||
|
||||
const activeCast = useCombatStore.getState().activeCast;
|
||||
expect(activeCast!.completesAt! - activeCast!.startedAt!).toBe(2_500);
|
||||
expect(maelstromAura()?.expiresAt).toBe(3_000);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user