Update Healer Man server
This commit is contained in:
1 parent
4c768fbec6
commit
b16821e92e
330 files changed
+35406
-1760
No files matched your search
@@ -19,12 +19,13 @@ import {
|
||||
useCombatStore,
|
||||
} from "./combatStore";
|
||||
import { TALENT_NODES, talentNodesForTree } from "./talentCatalog";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { armorReductionPercent, EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { abilitiesForClass, abilityAtLevel, abilityTalentNodeId, defaultActionBarForClass } from "./abilityCatalog";
|
||||
import { defaultActionBindings } from "./actionBindings";
|
||||
import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
|
||||
import { applyAura as applyCombatAura } from "./combatAuras";
|
||||
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
|
||||
import { wow335HunterPetArmorForLevel } from "./wow335HunterPetInheritedDefenseRuntime";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
@@ -89,6 +90,43 @@ function setPartyMemberHealth(id: string, health: number): void {
|
||||
}));
|
||||
}
|
||||
|
||||
function registerHunterPetDefenseProbe(id: string, school: "physical" | "fire"): string {
|
||||
const attackId = `${id}-attack`;
|
||||
useCombatStore.getState().registerMob(id, {
|
||||
name: id,
|
||||
level: 80,
|
||||
maxHealth: 100_000,
|
||||
xpReward: 0,
|
||||
attacks: [{
|
||||
...DEFAULT_ENEMY_ATTACK,
|
||||
id: attackId,
|
||||
name: "Defense probe",
|
||||
school,
|
||||
range: 5,
|
||||
}],
|
||||
});
|
||||
setMobPosition(id, [2, 0, 0]);
|
||||
useCombatStore.setState((state) => ({
|
||||
mobs: { ...state.mobs, [id]: { ...state.mobs[id]!, attackDamage: 1_000 } },
|
||||
}));
|
||||
return attackId;
|
||||
}
|
||||
|
||||
function incomingDamageEventForSummon(mobId: string, attackId: string, summonId: string) {
|
||||
useCombatStore.getState().damageMob(mobId, 1, 1_000, {
|
||||
actorId: summonId,
|
||||
role: "damage",
|
||||
abilityId: "hunter-pet-defense-probe",
|
||||
});
|
||||
useCombatStore.getState().advanceMobCombat(1_450);
|
||||
return [...useCombatStore.getState().combatEvents].reverse().find((event) => (
|
||||
event.kind === "damage"
|
||||
&& event.sourceActorId === mobId
|
||||
&& event.targetActorId === summonId
|
||||
&& event.abilityId === attackId
|
||||
));
|
||||
}
|
||||
|
||||
function preparePartyMember(
|
||||
index: number,
|
||||
position: PartyWorldPosition,
|
||||
@@ -949,6 +987,56 @@ describe("combat store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("mirrors a player Death Knight's landed spell during Dancing Rune Weapon's 12-second window", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "death-knight",
|
||||
level: 80,
|
||||
xp: 0,
|
||||
});
|
||||
useCombatStore.setState({
|
||||
talentRanks: { "talent-1961": 1 },
|
||||
resource: 100,
|
||||
resourcePools: { "runic-power": 100 },
|
||||
});
|
||||
registerTarget("dancing-rune-weapon-player-target", [2, 0, 0], 1_000_000, 0);
|
||||
|
||||
expect(useCombatStore.getState().castAbility(
|
||||
"wow335-death-knight-dancing-rune-weapon", undefined, 1_000,
|
||||
).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
definition: expect.objectContaining({ name: "Dancing Rune Weapon", durationMs: 12_000 }),
|
||||
appliedAt: 1_000,
|
||||
expiresAt: 13_000,
|
||||
}));
|
||||
expect(useCombatStore.getState().summons.some((summon) => (
|
||||
summon.sourceAbilityId === "wow335-death-knight-dancing-rune-weapon"
|
||||
))).toBe(false);
|
||||
|
||||
const deathCoil = useCombatStore.getState().abilities.find((ability) => ability.name === "Death Coil");
|
||||
expect(deathCoil).toBeDefined();
|
||||
expect(useCombatStore.getState().castAbility(deathCoil!.id, undefined, 3_000).ok).toBe(true);
|
||||
const activeHits = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage"
|
||||
&& event.sourceActorId === PLAYER_AGGRO_ID
|
||||
&& event.targetActorId === "dancing-rune-weapon-player-target"
|
||||
&& event.abilityId === deathCoil!.id
|
||||
&& event.occurredAt === 3_000
|
||||
));
|
||||
expect(activeHits).toHaveLength(2);
|
||||
expect(activeHits[1].rawAmount).toBe(Math.floor(activeHits[0].rawAmount * 0.5));
|
||||
|
||||
useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } });
|
||||
expect(useCombatStore.getState().castAbility(deathCoil!.id, undefined, 14_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage"
|
||||
&& event.sourceActorId === PLAYER_AGGRO_ID
|
||||
&& event.targetActorId === "dancing-rune-weapon-player-target"
|
||||
&& event.abilityId === deathCoil!.id
|
||||
&& event.occurredAt === 14_000
|
||||
))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("swings at the resolved companion without redirecting player damage", () => {
|
||||
const target = usePartyStore.getState().members.find((member) => member.role === "damage")!;
|
||||
usePartyStore.setState((state) => ({
|
||||
@@ -1242,6 +1330,12 @@ describe("combat store", () => {
|
||||
|
||||
expect(useCombatStore.getState().settings.showThreatMeter).toBe(false);
|
||||
expect(useCombatStore.getState().settings.showMobAggroRanges).toBe(false);
|
||||
expect(useCombatStore.getState().settings).toMatchObject({
|
||||
showOwnDamageText: true,
|
||||
showAllyDamageText: false,
|
||||
showIncomingDamageText: false,
|
||||
showFloatingCombatText: true,
|
||||
});
|
||||
useCombatStore.getState().updateSettings({
|
||||
masterVolume: 9,
|
||||
uiScale: 0.1,
|
||||
@@ -1273,6 +1367,18 @@ describe("combat store", () => {
|
||||
settings: { masterVolume: 0.6 },
|
||||
});
|
||||
expect(useCombatStore.getState().settings.masterVolume).toBe(0.6);
|
||||
expect(useCombatStore.getState().settings).toMatchObject({
|
||||
showOwnDamageText: true,
|
||||
showAllyDamageText: false,
|
||||
showIncomingDamageText: false,
|
||||
});
|
||||
useCombatStore.getState().updateSettings({ showOwnDamageText: false, showAllyDamageText: true, showIncomingDamageText: true });
|
||||
expect(useCombatStore.getState().settings).toMatchObject({
|
||||
showOwnDamageText: false,
|
||||
showAllyDamageText: true,
|
||||
showIncomingDamageText: true,
|
||||
showFloatingCombatText: true,
|
||||
});
|
||||
useCombatStore.getState().resetSettings();
|
||||
expect(useCombatStore.getState().settings).toEqual(DEFAULT_GAMEPLAY_SETTINGS);
|
||||
});
|
||||
@@ -2141,6 +2247,204 @@ describe("combat store", () => {
|
||||
&& event.abilityId === "wow335-hunter-kill-shot")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses player Hunter pet commands and owner attack events while Growl adds flat pet threat", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80, xp: 0 });
|
||||
const callPet = useCombatStore.getState().abilities.find((ability) => ability.name === "Call Pet")!;
|
||||
expect(useCombatStore.getState().castAbility(callPet.id, [0, 0, 0], 1_000).ok).toBe(true);
|
||||
const pet = useCombatStore.getState().summons.find((summon) => summon.hunterPet)!;
|
||||
expect(pet).toMatchObject({
|
||||
hunterPetCommand: "follow",
|
||||
hunterPetReactMode: "defensive",
|
||||
hunterPetGrowlAutocastEnabled: false,
|
||||
});
|
||||
useCombatStore.getState().registerMob("hunter-pet-first-target", {
|
||||
name: "Hunter pet target", maxHealth: 100_000, xpReward: 0,
|
||||
});
|
||||
setMobPosition("hunter-pet-first-target", [6, 0, 0]);
|
||||
useCombatStore.getState().registerMob("hunter-pet-owner-action-target", {
|
||||
name: "Owner action target", maxHealth: 100_000, xpReward: 0,
|
||||
});
|
||||
setMobPosition("hunter-pet-owner-action-target", [8, 0, 0]);
|
||||
useCombatStore.getState().selectMob("hunter-pet-first-target");
|
||||
const arcaneShot = useCombatStore.getState().abilities.find((ability) => ability.name === "Arcane Shot")!;
|
||||
const firstShot = useCombatStore.getState().castAbility(arcaneShot.id, [0, 0, 0], 2_600);
|
||||
expect(firstShot.ok, firstShot.reason).toBe(true);
|
||||
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)
|
||||
?.hunterPetPendingOwnerAttackTargetId).toBe("hunter-pet-first-target");
|
||||
useCombatStore.getState().tick(0.1, 2_700);
|
||||
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)?.targetId)
|
||||
.toBe("hunter-pet-first-target");
|
||||
expect(useCombatStore.getState().mobs["hunter-pet-first-target"]?.threatByActor[pet.id] ?? 0).toBe(0);
|
||||
expect(useCombatStore.getState().setHunterPetGrowlAutocast(pet.id, true)).toBe(true);
|
||||
useCombatStore.getState().tick(0.1, 2_800);
|
||||
useCombatStore.getState().tick(0.1, 2_900);
|
||||
const afterGrowl = useCombatStore.getState();
|
||||
expect(afterGrowl.mobs["hunter-pet-first-target"]?.threatByActor[pet.id]).toBeGreaterThanOrEqual(1_234);
|
||||
expect(afterGrowl.mobs["hunter-pet-first-target"]?.forcedTarget).toBeNull();
|
||||
expect(afterGrowl.summons.find((summon) => summon.id === pet.id)?.hunterPetGrowlReadyAt).toBe(7_900);
|
||||
|
||||
useCombatStore.getState().selectMob("hunter-pet-owner-action-target");
|
||||
expect(useCombatStore.getState().castAbility(arcaneShot.id, [0, 0, 0], 10_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)
|
||||
?.hunterPetPendingOwnerAttackTargetId).toBe("hunter-pet-owner-action-target");
|
||||
useCombatStore.getState().tick(0.1, 10_100);
|
||||
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)?.targetId)
|
||||
.toBe("hunter-pet-first-target");
|
||||
|
||||
useCombatStore.getState().selectMob("hunter-pet-first-target");
|
||||
useCombatStore.getState().tick(0.1, 10_200);
|
||||
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)?.targetId)
|
||||
.toBe("hunter-pet-first-target");
|
||||
expect(useCombatStore.getState().commandHunterPet(pet.id, "follow")).toBe(true);
|
||||
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)).toMatchObject({
|
||||
targetId: null,
|
||||
hunterPetCommand: "follow",
|
||||
});
|
||||
expect(useCombatStore.getState().commandHunterPet(pet.id, "stay")).toBe(true);
|
||||
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)?.hunterPetCommand)
|
||||
.toBe("stay");
|
||||
useCombatStore.setState((state) => ({ summons: state.summons.map((summon) => summon.id === pet.id
|
||||
? { ...summon, position: [10, 0, 0] as const }
|
||||
: summon) }));
|
||||
expect(useCombatStore.getState().commandHunterPet(pet.id, "follow")).toBe(true);
|
||||
useCombatStore.getState().tick(0.1, 10_300);
|
||||
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)!.position[0]).toBeLessThan(10);
|
||||
expect(useCombatStore.getState().commandHunterPet(pet.id, "stay")).toBe(true);
|
||||
useCombatStore.setState((state) => ({ summons: state.summons.map((summon) => summon.id === pet.id
|
||||
? { ...summon, position: [0, 0, 0] as const }
|
||||
: summon) }));
|
||||
useCombatStore.getState().tick(0.1, 10_400);
|
||||
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)!.position[0]).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each(["physical", "fire"] as const)(
|
||||
"applies inherited WoW 3.3.5 defenses to a player Hunter pet against %s damage",
|
||||
(school) => {
|
||||
const gearStats = { ...EMPTY_ITEM_STATS, armor: 3_000, fireResistance: 500 };
|
||||
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80, xp: 0 });
|
||||
useCombatStore.setState({ gearStats });
|
||||
const callPet = useCombatStore.getState().abilities.find((ability) => ability.name === "Call Pet")!;
|
||||
expect(useCombatStore.getState().castAbility(callPet.id, [0, 0, 0], 1_000).ok).toBe(true);
|
||||
const pet = useCombatStore.getState().summons.find((summon) => summon.hunterPet)!;
|
||||
const attackId = registerHunterPetDefenseProbe(`player-pet-defense-${school}`, school);
|
||||
|
||||
const event = incomingDamageEventForSummon(`player-pet-defense-${school}`, attackId, pet.id);
|
||||
expect(event).toBeDefined();
|
||||
expect(event?.rawAmount).toBe(1_000);
|
||||
if (school === "physical") {
|
||||
const ownerStats = deriveCharacterStats("hunter", 80, gearStats, "human");
|
||||
const petArmor = wow335HunterPetArmorForLevel(80, ownerStats.armor);
|
||||
const expected = 1_000 - Math.round(1_000 * armorReductionPercent(petArmor, 80) / 100);
|
||||
expect(event?.effectiveAmount).toBe(expected);
|
||||
expect(event?.effectiveAmount).toBeLessThan(1_000);
|
||||
} else {
|
||||
// 40% of the owner's 500 Fire Resistance yields a 200-resistance
|
||||
// pet profile: the level-80 partial-resist table selects 20% or 30%.
|
||||
expect(event?.effectiveAmount).toBeGreaterThanOrEqual(700);
|
||||
expect(event?.effectiveAmount).toBeLessThanOrEqual(800);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["physical", "fire"] as const)(
|
||||
"applies inherited WoW 3.3.5 defenses to a generated companion Hunter pet against %s damage",
|
||||
(school) => {
|
||||
const gearStats = { ...EMPTY_ITEM_STATS, armor: 3_000, fireResistance: 500 };
|
||||
usePartyStore.getState().initializeParty(
|
||||
`hunter-pet-defense-companion-${school}`,
|
||||
80,
|
||||
useCombatStore.getState().health,
|
||||
);
|
||||
const memberId = usePartyStore.getState().members[0]!.id;
|
||||
usePartyStore.setState((state) => ({
|
||||
members: state.members.map((member) => member.id === memberId
|
||||
? {
|
||||
...member,
|
||||
classId: "hunter",
|
||||
secondaryClassId: null,
|
||||
raceId: "human",
|
||||
level: 80,
|
||||
gearStats,
|
||||
talentRanks: {},
|
||||
health: member.maxHealth,
|
||||
}
|
||||
: member),
|
||||
}));
|
||||
registerPartyRuntimePosition(memberId, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const callPet = abilitiesForClass("hunter").find((ability) => ability.name === "Call Pet")!;
|
||||
const petId = useCombatStore.getState().summonActor(memberId, callPet, {
|
||||
kind: "summon",
|
||||
creatureId: 1,
|
||||
coefficient: 1,
|
||||
durationMs: 60_000,
|
||||
}, [0, 0, 0], null, 1_000);
|
||||
expect(petId).not.toBeNull();
|
||||
const attackId = registerHunterPetDefenseProbe(`companion-pet-defense-${school}`, school);
|
||||
|
||||
const event = incomingDamageEventForSummon(`companion-pet-defense-${school}`, attackId, petId!);
|
||||
expect(event).toBeDefined();
|
||||
expect(event?.rawAmount).toBe(1_000);
|
||||
if (school === "physical") {
|
||||
const ownerStats = deriveCharacterStats("hunter", 80, gearStats, "human");
|
||||
const petArmor = wow335HunterPetArmorForLevel(80, ownerStats.armor);
|
||||
const expected = 1_000 - Math.round(1_000 * armorReductionPercent(petArmor, 80) / 100);
|
||||
expect(event?.effectiveAmount).toBe(expected);
|
||||
expect(event?.effectiveAmount).toBeLessThan(1_000);
|
||||
} else {
|
||||
expect(event?.effectiveAmount).toBeGreaterThanOrEqual(700);
|
||||
expect(event?.effectiveAmount).toBeLessThanOrEqual(800);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("includes the player's active armor aura in inherited Hunter-pet physical mitigation", () => {
|
||||
const gearStats = { ...EMPTY_ITEM_STATS, armor: 3_000 };
|
||||
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80, xp: 0 });
|
||||
useCombatStore.setState({ gearStats });
|
||||
const callPet = useCombatStore.getState().abilities.find((ability) => ability.name === "Call Pet")!;
|
||||
expect(useCombatStore.getState().castAbility(callPet.id, [0, 0, 0], 1_000).ok).toBe(true);
|
||||
const pet = useCombatStore.getState().summons.find((summon) => summon.hunterPet)!;
|
||||
const ownerArmorWithoutAura = deriveCharacterStats("hunter", 80, gearStats, "human").armor;
|
||||
const aura = applyCombatAura(useCombatStore.getState().auras, {
|
||||
definition: {
|
||||
id: "test:hunter-owner-armor",
|
||||
name: "Test Armor Bonus",
|
||||
disposition: "buff",
|
||||
durationMs: 30_000,
|
||||
modifiers: [{ kind: "stat", stat: "armor", operation: "flat", value: 1_000 }],
|
||||
},
|
||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
now: 1_000,
|
||||
});
|
||||
useCombatStore.setState({ auras: aura.auras });
|
||||
const attackId = registerHunterPetDefenseProbe("player-pet-owner-armor-aura", "physical");
|
||||
|
||||
const event = incomingDamageEventForSummon("player-pet-owner-armor-aura", attackId, pet.id);
|
||||
const petArmorWithAura = wow335HunterPetArmorForLevel(80, ownerArmorWithoutAura + 1_000);
|
||||
const expectedWithAura = 1_000
|
||||
- Math.round(1_000 * armorReductionPercent(petArmorWithAura, 80) / 100);
|
||||
const petArmorWithoutAura = wow335HunterPetArmorForLevel(80, ownerArmorWithoutAura);
|
||||
const expectedWithoutAura = 1_000
|
||||
- Math.round(1_000 * armorReductionPercent(petArmorWithoutAura, 80) / 100);
|
||||
expect(event?.effectiveAmount).toBe(expectedWithAura);
|
||||
expect(event?.effectiveAmount).toBeLessThan(expectedWithoutAura);
|
||||
});
|
||||
|
||||
it("leaves a non-Hunter summon on its existing incoming-damage path", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80, xp: 0 });
|
||||
const mirrorImage = useCombatStore.getState().abilities.find((ability) => ability.name === "Mirror Image")!;
|
||||
expect(useCombatStore.getState().castAbility(mirrorImage.id, [0, 0, 0], 1_000).ok).toBe(true);
|
||||
const summon = useCombatStore.getState().summons.find((candidate) => !candidate.hunterPet)!;
|
||||
const attackId = registerHunterPetDefenseProbe("non-hunter-summon-defense", "physical");
|
||||
|
||||
const event = incomingDamageEventForSummon("non-hunter-summon-defense", attackId, summon.id);
|
||||
expect(event).toBeDefined();
|
||||
expect(event?.rawAmount).toBe(1_000);
|
||||
expect(event?.effectiveAmount).toBe(1_000);
|
||||
});
|
||||
|
||||
it("summons three Mirror Images and gives Combustion Fire hit stacks", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
||||
unlockReadySpell("wow335-mage-combustion");
|
||||
|
||||
Reference in new issue
Block a user