Update Healer Man server

This commit is contained in:
phenom committed 2026-10-02 10:38:12 -04:00
1 parent 4c768fbec6
commit b16821e92e
330 files changed
+35406 -1760

No files matched your search

+305 -1
View File
@@ -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");