forked from phenom/healer-man
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,376 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
||||
import { cooldownResetCandidates } from "./cooldownReset";
|
||||
import { talentModifiersForRanks } from "./talentModifiers";
|
||||
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
|
||||
describe("WoW 3.3.5 Hunter Readiness", () => {
|
||||
it("preserves Readiness's Bestial Wrath cooldown exception", () => {
|
||||
const hunterAbilities = WOW335_ABILITIES_BY_CLASS.hunter;
|
||||
const readiness = hunterAbilities.find((ability) => ability.name === "Readiness");
|
||||
expect(readiness).toBeDefined();
|
||||
|
||||
const reset = readiness!.effects.find((effect) => effect.kind === "cooldown-reset");
|
||||
expect(reset).toMatchObject({
|
||||
kind: "cooldown-reset",
|
||||
mode: "reset",
|
||||
excludeAbilityNames: ["Bestial Wrath"],
|
||||
});
|
||||
if (!reset || reset.kind !== "cooldown-reset") return;
|
||||
|
||||
const candidates = cooldownResetCandidates(
|
||||
hunterAbilities,
|
||||
readiness!.id,
|
||||
reset.abilityName,
|
||||
reset.excludeAbilityNames,
|
||||
);
|
||||
expect(candidates.some((ability) => ability.name === "Bestial Wrath")).toBe(false);
|
||||
expect(candidates.some((ability) => ability.name === "Readiness")).toBe(false);
|
||||
expect(candidates.some((ability) => ability.name === "Rapid Fire")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not turn The Beast Within's conditional tooltip into a permanent modifier", () => {
|
||||
expect(talentModifiersForRanks({ "talent-1803": 1 }, "hunter")).toEqual([]);
|
||||
expect(talentModifiersForRanks({ "talent-1803": 2 }, "hunter")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function hunterAbilityId(name: string): string {
|
||||
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
|
||||
expect(ability, name).toBeDefined();
|
||||
return ability!.id;
|
||||
}
|
||||
|
||||
function unlockHunterTalent(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 hunterProcRoll(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 hunterProcTime(abilityId: string, petId: string, shouldProc: boolean, after: number): number {
|
||||
for (let now = after; now < after + 60_000; now += 1) {
|
||||
const rolls = hunterProcRoll(`${PLAYER_AGGRO_ID}:${abilityId}:${petId}:${now}:the-beast-within`);
|
||||
if ((rolls < 0.5) === shouldProc) return now;
|
||||
}
|
||||
throw new Error("Could not find a deterministic rank-one proc test time.");
|
||||
}
|
||||
|
||||
function registerHunterMob(id: string, position: readonly [number, number, number], maxHealth = 100_000): void {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
|
||||
setMobPosition(id, position);
|
||||
useCombatStore.getState().selectMob(id);
|
||||
}
|
||||
|
||||
describe("WoW 3.3.5 Hunter pet mechanics", () => {
|
||||
beforeEach(async () => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
await preloadAbilityCatalogsForClasses(["hunter"]);
|
||||
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80 });
|
||||
useCombatStore.getState().resetSettings();
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
usePartyStore.getState().initializeParty(
|
||||
"wow335-hunter-mechanics-tests",
|
||||
1,
|
||||
useCombatStore.getState().health,
|
||||
);
|
||||
});
|
||||
|
||||
it("resets Hunter cooldowns while leaving Bestial Wrath's cooldown untouched", () => {
|
||||
const readinessId = unlockHunterTalent("Readiness");
|
||||
const bestialWrathId = unlockHunterTalent("Bestial Wrath");
|
||||
const rapidFireId = hunterAbilityId("Rapid Fire");
|
||||
useCombatStore.setState((state) => ({
|
||||
cooldowns: {
|
||||
...state.cooldowns,
|
||||
[readinessId]: 0,
|
||||
[bestialWrathId]: 90_000,
|
||||
[rapidFireId]: 30_000,
|
||||
},
|
||||
}));
|
||||
|
||||
const result = useCombatStore.getState().castAbility(readinessId, undefined, 1_000);
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().cooldowns[bestialWrathId]).toBe(90_000);
|
||||
expect(useCombatStore.getState().cooldowns[rapidFireId]).toBe(0);
|
||||
expect(useCombatStore.getState().cooldowns[readinessId]).toBeGreaterThan(1_000);
|
||||
});
|
||||
|
||||
it("rejects Bestial Wrath without an active pet and does not proc The Beast Within", () => {
|
||||
const bestialWrathId = unlockHunterTalent("Bestial Wrath");
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-1803": 2 },
|
||||
}));
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id.includes(":34471:"))).toBe(false);
|
||||
|
||||
const startingMana = useCombatStore.getState().resourcePools.mana;
|
||||
const result = useCombatStore.getState().castAbility(bestialWrathId, undefined, 1_000);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
expect(useCombatStore.getState().resourcePools.mana).toBe(startingMana);
|
||||
expect(useCombatStore.getState().cooldowns[bestialWrathId] ?? 0).toBe(0);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id.includes(":34471:"))).toBe(false);
|
||||
});
|
||||
|
||||
it("requires a live pet before accepting pet-command and Mend Pet casts", () => {
|
||||
registerHunterMob("petless-target", [0, 0, 3]);
|
||||
const petDependentCasts = [
|
||||
{ id: unlockHunterTalent("Bestial Wrath"), targetActorId: undefined },
|
||||
{ id: unlockHunterTalent("Intimidation"), targetActorId: "petless-target" },
|
||||
{ id: hunterAbilityId("Kill Command"), targetActorId: "petless-target" },
|
||||
{ id: hunterAbilityId("Mend Pet"), targetActorId: undefined },
|
||||
];
|
||||
const startingMana = useCombatStore.getState().resourcePools.mana;
|
||||
|
||||
for (const [index, cast] of petDependentCasts.entries()) {
|
||||
const result = useCombatStore.getState().castAbility(cast.id, {
|
||||
...(cast.targetActorId ? { targetActorId: cast.targetActorId } : {}),
|
||||
now: 1_000 + index * 2_000,
|
||||
});
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
expect(useCombatStore.getState().resourcePools.mana).toBe(startingMana);
|
||||
expect(useCombatStore.getState().cooldowns[cast.id] ?? 0).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("places Bestial Wrath on the pet and applies the rank-two Beast Within payload to the hunter", () => {
|
||||
const callPetId = hunterAbilityId("Call Pet");
|
||||
const bestialWrathId = unlockHunterTalent("Bestial Wrath");
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-1803": 2 },
|
||||
}));
|
||||
|
||||
const callResult = useCombatStore.getState().castAbility(callPetId, undefined, 1_000);
|
||||
expect(callResult, JSON.stringify(callResult)).toMatchObject({ ok: true });
|
||||
const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet");
|
||||
expect(pet).toBeDefined();
|
||||
|
||||
const result = useCombatStore.getState().castAbility(bestialWrathId, undefined, 3_000);
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
|
||||
|
||||
const auras = useCombatStore.getState().auras;
|
||||
const petAuras = auras.filter((aura) => aura.targetId === pet!.id && aura.definition.id.includes(":19574:"));
|
||||
expect(petAuras).toHaveLength(3);
|
||||
expect(auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.id.includes(":19574:"))).toBe(false);
|
||||
|
||||
const beastWithinAuras = auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.id.includes(":34471:"));
|
||||
expect(beastWithinAuras).toHaveLength(3);
|
||||
expect(beastWithinAuras.every((aura) => aura.expiresAt === 21_000)).toBe(true);
|
||||
expect(beastWithinAuras.flatMap((aura) => aura.definition.modifiers ?? [])).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "resource", resource: "power", property: "cost", value: -0.2 }),
|
||||
expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.1 }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("uses The Beast Within's 50% rank-one proc chance", () => {
|
||||
const callPetId = hunterAbilityId("Call Pet");
|
||||
const bestialWrathId = unlockHunterTalent("Bestial Wrath");
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-1803": 1 },
|
||||
}));
|
||||
expect(useCombatStore.getState().castAbility(callPetId, undefined, 1_000).ok).toBe(true);
|
||||
const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet");
|
||||
expect(pet).toBeDefined();
|
||||
|
||||
const noProcAt = hunterProcTime(bestialWrathId, pet!.id, false, 3_000);
|
||||
expect(useCombatStore.getState().castAbility(bestialWrathId, undefined, noProcAt).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.id.includes(":34471:"))).toBe(false);
|
||||
|
||||
useCombatStore.setState((state) => ({
|
||||
cooldowns: { ...state.cooldowns, [bestialWrathId]: 0 },
|
||||
globalCooldownEndsAt: 0,
|
||||
}));
|
||||
const procAt = hunterProcTime(bestialWrathId, pet!.id, true, noProcAt + 5_000);
|
||||
expect(useCombatStore.getState().castAbility(bestialWrathId, undefined, procAt).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.id.includes(":34471:"))).toBe(true);
|
||||
});
|
||||
|
||||
it("stores Intimidation on the pet and spends it on the pet's next melee hit", () => {
|
||||
registerHunterMob("intimidation-target", [0, 0, 3]);
|
||||
const callPetId = hunterAbilityId("Call Pet");
|
||||
const intimidationId = unlockHunterTalent("Intimidation");
|
||||
expect(useCombatStore.getState().castAbility(callPetId, undefined, 1_000).ok).toBe(true);
|
||||
const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet");
|
||||
expect(pet).toBeDefined();
|
||||
|
||||
const cast = useCombatStore.getState().castAbility(intimidationId, { targetActorId: "intimidation-target", now: 3_000 });
|
||||
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
|
||||
const intimidationAura = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.definition.id.includes(":19577:")
|
||||
));
|
||||
expect(intimidationAura?.targetId).toBe(pet!.id);
|
||||
expect(useCombatStore.getState().mobs["intimidation-target"]?.statuses).toHaveLength(0);
|
||||
|
||||
useCombatStore.getState().tick(1, 4_000);
|
||||
|
||||
const target = useCombatStore.getState().mobs["intimidation-target"];
|
||||
expect(target?.statuses).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "stun", sourceActorId: pet!.id, endsAt: 7_000 }),
|
||||
]));
|
||||
expect(target?.threatByActor[pet!.id]).toBe(580);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === intimidationAura?.instanceId)).toBe(false);
|
||||
});
|
||||
|
||||
it("only enables Counterattack after a parry and consumes that readiness on use", () => {
|
||||
const counterattackId = unlockHunterTalent("Counterattack");
|
||||
useCombatStore.setState((state) => ({
|
||||
gearStats: { ...state.gearStats, parryRating: 100_000 },
|
||||
}));
|
||||
useCombatStore.getState().registerMob("counterattack-target", {
|
||||
name: "counterattack-target",
|
||||
maxHealth: 100_000,
|
||||
xpReward: 0,
|
||||
attacks: [DEFAULT_ENEMY_ATTACK],
|
||||
});
|
||||
setMobPosition("counterattack-target", [2, 0, 0]);
|
||||
|
||||
const unavailable = useCombatStore.getState().castAbility(counterattackId, {
|
||||
targetActorId: "counterattack-target", now: 1_000,
|
||||
});
|
||||
expect(unavailable).toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
|
||||
useCombatStore.getState().damageMob("counterattack-target", 1, 1_100);
|
||||
let parryAt: number | null = null;
|
||||
for (let now = 1_450; now < 20_000; now += 100) {
|
||||
useCombatStore.getState().advanceMobCombat(now);
|
||||
if (useCombatStore.getState().lastCombatResult?.parried) {
|
||||
parryAt = now;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(parryAt).not.toBeNull();
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("wow335-hunter-counterattack-ready")
|
||||
))).toBe(true);
|
||||
|
||||
const available = useCombatStore.getState().castAbility(counterattackId, {
|
||||
targetActorId: "counterattack-target", now: parryAt! + 100,
|
||||
});
|
||||
expect(available, JSON.stringify(available)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("wow335-hunter-counterattack-ready")
|
||||
))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not consume Kill Command's special-attack charges on a generic pet melee swing", () => {
|
||||
registerHunterMob("kill-command-target", [0, 0, 3]);
|
||||
const callPetId = hunterAbilityId("Call Pet");
|
||||
const killCommandId = hunterAbilityId("Kill Command");
|
||||
expect(useCombatStore.getState().castAbility(callPetId, undefined, 1_000).ok).toBe(true);
|
||||
const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet");
|
||||
expect(pet).toBeDefined();
|
||||
|
||||
const cast = useCombatStore.getState().castAbility(killCommandId, { targetActorId: "kill-command-target", now: 3_000 });
|
||||
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
|
||||
const aura = useCombatStore.getState().auras.find((entry) => (
|
||||
entry.definition.utilityTags?.includes("wow335-hunter-kill-command")
|
||||
));
|
||||
expect(aura?.targetId).toBe(pet!.id);
|
||||
expect(aura?.stacks).toBe(60);
|
||||
expect(aura?.definition.modifiers?.some((modifier) => modifier.kind === "damage") ?? false).toBe(false);
|
||||
|
||||
useCombatStore.getState().tick(1, 4_000);
|
||||
|
||||
expect(useCombatStore.getState().auras.find((entry) => entry.instanceId === aura?.instanceId)?.stacks).toBe(60);
|
||||
});
|
||||
|
||||
it("arms Freezing Arrow at its ground point and freezes only an approaching target", () => {
|
||||
registerHunterMob("freezing-target", [0, 0, 10]);
|
||||
useCombatStore.getState().registerMob("freezing-later", { name: "freezing-later", maxHealth: 100_000, xpReward: 0 });
|
||||
setMobPosition("freezing-later", [0, 0, 12]);
|
||||
const freezingArrowId = hunterAbilityId("Freezing Arrow");
|
||||
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.id === freezingArrowId)!;
|
||||
expect(ability.targeting).toMatchObject({ shape: "ground", groundRange: 40, radius: 2 });
|
||||
|
||||
const cast = useCombatStore.getState().castAbility(freezingArrowId, {
|
||||
origin: [0, 0, 0], groundPosition: [0, 0, 0], now: 1_000,
|
||||
});
|
||||
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().mobs["freezing-target"]?.statuses).toHaveLength(0);
|
||||
expect(useCombatStore.getState().areaEffects[0]?.hunterTrap?.kind).toBe("freezing");
|
||||
|
||||
setMobPosition("freezing-target", [0, 0, 1]);
|
||||
useCombatStore.getState().tick(1, 2_000);
|
||||
|
||||
expect(useCombatStore.getState().mobs["freezing-target"]?.statuses).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "freeze", endsAt: 22_000, breakOnDamage: true }),
|
||||
]));
|
||||
expect(useCombatStore.getState().mobs["freezing-later"]?.statuses).toHaveLength(0);
|
||||
expect(useCombatStore.getState().areaEffects.some((area) => area.hunterTrap?.kind === "freezing")).toBe(false);
|
||||
|
||||
useCombatStore.getState().damageMob("freezing-target", 1, 2_001);
|
||||
expect(useCombatStore.getState().mobs["freezing-target"]?.statuses).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("arms Explosive Trap, then deals its rank fire impact and periodic AoE ticks", () => {
|
||||
registerHunterMob("explosive-center", [0, 0, 0]);
|
||||
useCombatStore.getState().registerMob("explosive-edge", { name: "explosive-edge", maxHealth: 100_000, xpReward: 0 });
|
||||
setMobPosition("explosive-edge", [0, 0, 8]);
|
||||
useCombatStore.getState().registerMob("explosive-outside", { name: "explosive-outside", maxHealth: 100_000, xpReward: 0 });
|
||||
setMobPosition("explosive-outside", [0, 0, 11]);
|
||||
const abilityId = hunterAbilityId("Explosive Trap");
|
||||
const cast = useCombatStore.getState().castAbility(abilityId, {
|
||||
origin: [0, 0, 0], groundPosition: [0, 0, 0], now: 1_000,
|
||||
});
|
||||
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
|
||||
const beforeCenter = useCombatStore.getState().mobs["explosive-center"]!.health;
|
||||
const beforeEdge = useCombatStore.getState().mobs["explosive-edge"]!.health;
|
||||
expect(useCombatStore.getState().areaEffects[0]?.hunterTrap?.kind).toBe("explosive");
|
||||
|
||||
useCombatStore.getState().tick(1, 2_000);
|
||||
|
||||
expect(useCombatStore.getState().mobs["explosive-center"]!.health).toBeLessThan(beforeCenter);
|
||||
expect(useCombatStore.getState().mobs["explosive-edge"]!.health).toBeLessThan(beforeEdge);
|
||||
expect(useCombatStore.getState().mobs["explosive-outside"]!.health).toBe(100_000);
|
||||
expect(useCombatStore.getState().combatEvents).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
occurredAt: 2_000,
|
||||
kind: "damage",
|
||||
abilityId,
|
||||
targetActorId: "explosive-center",
|
||||
school: "fire",
|
||||
}),
|
||||
]));
|
||||
expect(useCombatStore.getState().areaEffects[0]).toMatchObject({ radius: 10, tickIntervalMs: 2_000 });
|
||||
const afterImpact = useCombatStore.getState().mobs["explosive-center"]!.health;
|
||||
|
||||
useCombatStore.getState().tick(1, 4_000);
|
||||
|
||||
expect(useCombatStore.getState().mobs["explosive-center"]!.health).toBeLessThan(afterImpact);
|
||||
expect(useCombatStore.getState().combatEvents).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
occurredAt: 4_000,
|
||||
kind: "damage",
|
||||
abilityId,
|
||||
targetActorId: "explosive-center",
|
||||
school: "fire",
|
||||
}),
|
||||
]));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user