forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
4c768fbec6
commit
b16821e92e
330 files changed
+35406
-1760
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useCombatStore, type EnemyActiveCast } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
@@ -16,18 +16,31 @@ import {
|
||||
partyAttackDamage,
|
||||
partyCombatRangeBand,
|
||||
partyCommandTargetId,
|
||||
partyHunterPetRecipientIds,
|
||||
partyUsesRangedAttacks,
|
||||
partyAbilityTargetIsValid,
|
||||
resetPartyRuntimeClock,
|
||||
} from "./partyRuntime";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { partyAbilityAmount } from "./partyAbilityAi";
|
||||
import { createDeathKnightRunes } from "./combatActors";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
|
||||
import { abilitiesForClass, abilityAtLevel, abilityTalentNodeId } from "./abilityCatalog";
|
||||
import { abilitiesForCharacter, abilitiesForClass, abilityAtLevel, abilityTalentNodeId, learnedAbilityForBuild, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
import { channelPulseAt, channelPulseCount } from "./combatChannelAmounts";
|
||||
import { baseClassFor } from "../app/characterCatalog";
|
||||
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
|
||||
import { ROM_ROGUE_OFF_HAND_MASTERY_TAG } from "./romRogueMechanics";
|
||||
import {
|
||||
WOW335_HUNTER_PET_RANGED_AP_TO_SPELL_DAMAGE,
|
||||
wow335HunterPetBaseStatsForLevel,
|
||||
} from "./wow335HunterPetRuntime";
|
||||
import {
|
||||
WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID,
|
||||
} from "./wow335DeathKnightDancingRuneWeaponRuntime";
|
||||
|
||||
function registerEngagedMob(id = "pack:a", maxHealth = 200): void {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
|
||||
@@ -93,6 +106,10 @@ function registerFrostbiteTarget(id: string): void {
|
||||
}
|
||||
|
||||
describe("party combat runtime", () => {
|
||||
beforeAll(async () => {
|
||||
await preloadAbilityCatalogsForClasses(["rom-druid"]);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
@@ -109,6 +126,398 @@ describe("party combat runtime", () => {
|
||||
expect(activePartyTargetId(mobs, null)).toBe("pack:a");
|
||||
});
|
||||
|
||||
it("extends a companion Druid's cast-time damage range with Nature's Reach", () => {
|
||||
const seed = "druid-natures-reach-party";
|
||||
const druid = createConfiguredPartyMember({
|
||||
name: "Balance Druid",
|
||||
classId: "druid",
|
||||
specialization: "Balance",
|
||||
specializationId: "druid:balance",
|
||||
talentTreeId: "druid-balance",
|
||||
talentRanks: { "talent-764": 3 },
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
const wrath = abilitiesForCharacter("druid", null).find((ability) => ability.name === "Wrath");
|
||||
if (!wrath) throw new Error("Druid Wrath is missing from the learned ability catalog.");
|
||||
const learnedWrath = abilityAtLevel(wrath, 80);
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
|
||||
usePartyStore.setState({ members: [druid] });
|
||||
registerPartyRuntimePosition(druid.id, [0, 0, 0]);
|
||||
useCombatStore.getState().registerMob("druid-natures-reach-party-target", {
|
||||
name: "Nature's Reach party target",
|
||||
maxHealth: 100_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition("druid-natures-reach-party-target", [35, 0, 0]);
|
||||
updatePartyRuntimeTargetVisibility(druid.id, "druid-natures-reach-party-target", true);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
expect(partyAbilityTargetIsValid(druid, learnedWrath, "druid-natures-reach-party-target")).toBe(true);
|
||||
expect(partyAbilityTargetIsValid({ ...druid, talentRanks: {} }, learnedWrath,
|
||||
"druid-natures-reach-party-target")).toBe(false);
|
||||
});
|
||||
|
||||
it("spreads a companion Druid/Rogue's current Corrosive Poison with Necrotic Wound", () => {
|
||||
const seed = "druid-rogue-necrotic-wound-party";
|
||||
const druid = createConfiguredPartyMember({
|
||||
name: "Druid/Rogue",
|
||||
classId: "rom-druid",
|
||||
secondaryClassId: "rom-rogue",
|
||||
raceId: "rom-elf",
|
||||
specialization: "Druid/Rogue",
|
||||
specializationId: "rom-druid:rom-rogue",
|
||||
talentTreeId: "rom-druid-rom-rogue",
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
const findPairAbility = (spellId: number) => {
|
||||
const found = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === spellId
|
||||
&& candidate.classId === "rom-druid" && candidate.secondaryClassId === "rom-rogue");
|
||||
if (!found) throw new Error(`Missing Druid/Rogue spell ${spellId}.`);
|
||||
return found;
|
||||
};
|
||||
const wound = learnedAbilityForBuild(findPairAbility(494321), 80, druid.talentRanks)!;
|
||||
const poison = findPairAbility(494024);
|
||||
const poisonDot = poison.effects.find((effect) => effect.kind === "dot");
|
||||
const sinisterRequest = findPairAbility(494023);
|
||||
if (!poisonDot || poisonDot.kind !== "dot") throw new Error("Corrosive Poison has no current DoT.");
|
||||
if (!wound) throw new Error("Companion Druid/Rogue did not learn Necrotic Wound.");
|
||||
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
|
||||
usePartyStore.setState({ members: [{
|
||||
...druid,
|
||||
resourcePools: { ...druid.resourcePools, mana: 960, energy: 75, "nature-power": 0 },
|
||||
cooldowns: { ...druid.cooldowns, [sinisterRequest.id]: 20_000 },
|
||||
activeCast: {
|
||||
abilityId: wound.id,
|
||||
school: "shadow",
|
||||
mode: "channel",
|
||||
startedAt: 1_000,
|
||||
completesAt: 2_500,
|
||||
originalDurationMs: 1_500,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId: "druid-rogue-wound-primary",
|
||||
channelTicksCompleted: 0,
|
||||
},
|
||||
activeCastTargetId: "druid-rogue-wound-primary",
|
||||
status: "attacking",
|
||||
}] });
|
||||
registerPartyRuntimePosition(druid.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const primary = "druid-rogue-wound-primary";
|
||||
const nearby = "druid-rogue-wound-nearby";
|
||||
const outside = "druid-rogue-wound-outside";
|
||||
for (const [id, position] of [
|
||||
[primary, [4, 0, 0]],
|
||||
[nearby, [11, 0, 0]],
|
||||
[outside, [13, 0, 0]],
|
||||
] as const) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000, xpReward: 0 });
|
||||
setMobPosition(id, position);
|
||||
useCombatStore.getState().damageMob(id, 1, 100);
|
||||
}
|
||||
updatePartyRuntimeTargetVisibility(druid.id, primary, true);
|
||||
expect(partyAbilityTargetIsValid(druid, wound, primary)).toBe(false);
|
||||
expect(useCombatStore.getState().applyTimedActorEffect(druid.id, primary, poison, poisonDot, 1_000)).toBe(true);
|
||||
expect(partyAbilityTargetIsValid(druid, wound, primary)).toBe(true);
|
||||
usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === druid.id
|
||||
? { ...member, cooldowns: { ...member.cooldowns, [sinisterRequest.id]: 20_000 } }
|
||||
: member) }));
|
||||
|
||||
advanceDungeonPartyCombat(2_500);
|
||||
|
||||
const poisonTargets = useCombatStore.getState().effects.filter((effect) => (
|
||||
effect.kind === "dot" && effect.tag === "rom-druid-corrosive-poison" && effect.remainingTicks === 12
|
||||
)).map((effect) => effect.targetId).sort();
|
||||
expect(poisonTargets).toEqual([nearby, primary].sort());
|
||||
expect(useCombatStore.getState().mobs[primary]!.health).toBeLessThan(999_999);
|
||||
expect(useCombatStore.getState().mobs[nearby]!.health).toBeLessThan(999_999);
|
||||
expect(useCombatStore.getState().mobs[outside]!.health).toBe(999_999);
|
||||
expect(usePartyStore.getState().members[0]!.cooldowns[sinisterRequest.id]).toBe(19_000);
|
||||
expect(useCombatStore.getState().mobs[primary]!.statuses.some((status) => status.kind === "root")).toBe(false);
|
||||
});
|
||||
|
||||
it("extends a companion Death Knight's diseases with Epidemic", () => {
|
||||
const seed = "death-knight-epidemic-party";
|
||||
const deathKnight = createConfiguredPartyMember({
|
||||
name: "Epidemic DK",
|
||||
classId: "death-knight",
|
||||
specialization: "Frost",
|
||||
specializationId: "death-knight:frost",
|
||||
talentTreeId: "death-knight-frost",
|
||||
talentRanks: { "talent-1963": 2 },
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
const abilities = abilitiesForCharacter("death-knight", null);
|
||||
const icyTouch = abilities.find((candidate) => candidate.name === "Icy Touch");
|
||||
if (!icyTouch) throw new Error("Missing Death Knight Icy Touch.");
|
||||
const learnedIcyTouch = learnedAbilityForBuild(icyTouch, 80, deathKnight.talentRanks);
|
||||
if (!learnedIcyTouch) throw new Error("Companion Death Knight did not learn Icy Touch.");
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
|
||||
usePartyStore.setState({
|
||||
members: [{
|
||||
...deathKnight,
|
||||
gearStats: { ...EMPTY_ITEM_STATS, hitRating: 10_000, expertiseRating: 10_000 },
|
||||
resource: 100,
|
||||
maxResource: 100,
|
||||
resourcePools: { ...deathKnight.resourcePools, "runic-power": 100 },
|
||||
runes: createDeathKnightRunes(1_000),
|
||||
globalCooldownEndsAt: 0,
|
||||
nextActionAt: 0,
|
||||
status: "ready",
|
||||
}],
|
||||
command: "attack",
|
||||
activeMobId: "party-dk-epidemic-target",
|
||||
});
|
||||
registerPartyRuntimePosition(deathKnight.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerEngagedMob("party-dk-epidemic-target", 1_000_000);
|
||||
|
||||
for (const now of [1_000, 2_500, 4_000, 5_500]) advanceDungeonPartyCombat(now);
|
||||
|
||||
expect(useCombatStore.getState().effects.find((effect) => (
|
||||
effect.kind === "dot" && effect.sourceActorId === deathKnight.id
|
||||
&& effect.tag === "frost-fever" && effect.targetId === "party-dk-epidemic-target"
|
||||
))).toMatchObject({ remainingTicks: 7, intervalMs: 3_000 });
|
||||
});
|
||||
|
||||
it("activates Dancing Rune Weapon for a companion and mirrors its direct spell and melee hits", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const seed = "death-knight-dancing-rune-weapon-party";
|
||||
const deathKnight = createConfiguredPartyMember({
|
||||
name: "Rune Weapon DK",
|
||||
classId: "death-knight",
|
||||
specialization: "Blood",
|
||||
specializationId: "death-knight:blood",
|
||||
talentTreeId: "death-knight-blood",
|
||||
talentRanks: { "talent-1961": 1 },
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
const abilities = abilitiesForCharacter("death-knight", null);
|
||||
const dancingRuneWeapon = abilities.find((ability) => ability.id === WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID);
|
||||
const learnedDancingRuneWeapon = dancingRuneWeapon
|
||||
? learnedAbilityForBuild(dancingRuneWeapon, 80, deathKnight.talentRanks)
|
||||
: null;
|
||||
const deathCoil = abilities.find((ability) => ability.name === "Death Coil");
|
||||
if (!learnedDancingRuneWeapon || !deathCoil) {
|
||||
throw new Error("Companion Death Knight is missing Dancing Rune Weapon or Death Coil.");
|
||||
}
|
||||
const learnedDeathCoil = learnedAbilityForBuild(deathCoil, 80, deathKnight.talentRanks);
|
||||
if (!learnedDeathCoil) throw new Error("Companion Death Knight did not learn Death Coil.");
|
||||
const targetId = "party-dk-dancing-rune-weapon-target";
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
|
||||
usePartyStore.setState({ members: [{
|
||||
...deathKnight,
|
||||
talentRanks: { ...deathKnight.talentRanks, "talent-1961": 1 },
|
||||
gearStats: EMPTY_ITEM_STATS,
|
||||
resource: 100,
|
||||
maxResource: 100,
|
||||
resourcePools: { ...deathKnight.resourcePools, "runic-power": 100 },
|
||||
runes: createDeathKnightRunes(1_000),
|
||||
activeCast: {
|
||||
abilityId: learnedDancingRuneWeapon.id,
|
||||
school: "shadow",
|
||||
mode: "cast",
|
||||
startedAt: 1_000,
|
||||
completesAt: 1_000,
|
||||
originalDurationMs: 0,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId: deathKnight.id,
|
||||
},
|
||||
activeCastTargetId: deathKnight.id,
|
||||
status: "attacking",
|
||||
}] });
|
||||
registerPartyRuntimePosition(deathKnight.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerEngagedMob(targetId, 1_000_000);
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
|
||||
sourceId: deathKnight.id,
|
||||
targetId: deathKnight.id,
|
||||
definition: expect.objectContaining({ name: "Dancing Rune Weapon", durationMs: 12_000 }),
|
||||
appliedAt: 1_000,
|
||||
expiresAt: 13_000,
|
||||
}));
|
||||
expect(useCombatStore.getState().summons.some((summon) => (
|
||||
summon.ownerActorId === deathKnight.id
|
||||
&& summon.sourceAbilityId === WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID
|
||||
))).toBe(false);
|
||||
|
||||
usePartyStore.setState((state) => ({
|
||||
members: state.members.map((member) => member.id === deathKnight.id ? {
|
||||
...member,
|
||||
activeCast: {
|
||||
abilityId: learnedDeathCoil.id,
|
||||
school: "shadow",
|
||||
mode: "cast",
|
||||
startedAt: 3_000,
|
||||
completesAt: 3_000,
|
||||
originalDurationMs: 0,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId,
|
||||
},
|
||||
activeCastTargetId: targetId,
|
||||
status: "attacking" as const,
|
||||
} : member),
|
||||
}));
|
||||
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
|
||||
advanceDungeonPartyCombat(3_000);
|
||||
const spellHits = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage"
|
||||
&& event.sourceActorId === deathKnight.id
|
||||
&& event.targetActorId === targetId
|
||||
&& event.abilityId === learnedDeathCoil.id
|
||||
&& event.occurredAt === 3_000
|
||||
));
|
||||
expect(spellHits).toHaveLength(2);
|
||||
expect(spellHits[1].rawAmount).toBe(Math.floor(spellHits[0].rawAmount * 0.5));
|
||||
|
||||
usePartyStore.setState((state) => ({
|
||||
members: state.members.map((member) => member.id === deathKnight.id ? {
|
||||
...member,
|
||||
activeCast: null,
|
||||
activeCastTargetId: null,
|
||||
nextActionAt: 100_000,
|
||||
status: "attacking" as const,
|
||||
} : member),
|
||||
}));
|
||||
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
|
||||
advanceDungeonPartyCombat(5_000);
|
||||
const meleeHits = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage"
|
||||
&& event.sourceActorId === deathKnight.id
|
||||
&& event.targetActorId === targetId
|
||||
&& event.abilityId === "party-auto-attack"
|
||||
&& event.occurredAt === 5_000
|
||||
));
|
||||
expect(meleeHits).toHaveLength(2);
|
||||
expect(meleeHits[1].rawAmount).toBe(Math.floor(meleeHits[0].rawAmount * 0.5));
|
||||
});
|
||||
|
||||
it("gives a RoM Rogue a separate off-hand swing and activates learned Off Hand Mastery", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const rogue = createConfiguredPartyMember({
|
||||
name: "RoM Rogue",
|
||||
classId: "rom-rogue",
|
||||
secondaryClassId: "rom-warrior",
|
||||
raceId: "rom-human",
|
||||
specialization: "Rogue/Warrior",
|
||||
role: "damage",
|
||||
}, 80, 0, "rom-rogue-offhand-runtime");
|
||||
const targetId = "rom-rogue-offhand-target";
|
||||
usePartyStore.getState().initializeParty("rom-rogue-offhand-runtime", 80,
|
||||
useCombatStore.getState().health, "damage", 2);
|
||||
usePartyStore.setState({
|
||||
members: [{ ...rogue, gearStats: EMPTY_ITEM_STATS, nextActionAt: 100_000 }],
|
||||
command: "attack",
|
||||
activeMobId: targetId,
|
||||
});
|
||||
registerPartyRuntimePosition(rogue.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerEngagedMob(targetId, 1_000_000);
|
||||
const presentations: CombatPresentationEvent[] = [];
|
||||
const unsubscribe = subscribeCombatPresentation((message) => {
|
||||
if (message.kind === "event") presentations.push(message.event);
|
||||
});
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
unsubscribe();
|
||||
expect(presentations).toContainEqual(expect.objectContaining({
|
||||
sourceActorId: rogue.id,
|
||||
abilityId: "party-auto-attack-offhand",
|
||||
name: "Off-hand Auto Attack",
|
||||
phase: "impact",
|
||||
}));
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.targetId === rogue.id
|
||||
&& aura.definition.utilityTags?.includes(ROM_ROGUE_OFF_HAND_MASTERY_TAG)
|
||||
))).toBe(true);
|
||||
});
|
||||
|
||||
it("adds hand-specific CoA Chaos Dagger Fire packets to generated Rogue/Mage autos", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const rogue = createConfiguredPartyMember({
|
||||
name: "RoM Rogue/Mage",
|
||||
classId: "rom-rogue",
|
||||
secondaryClassId: "rom-mage",
|
||||
raceId: "rom-human",
|
||||
specialization: "Rogue/Mage",
|
||||
role: "damage",
|
||||
}, 80, 0, "rom-rogue-mage-chaos-dagger-party");
|
||||
const mainHandWeapon = { damageMin: 100, damageMax: 100, damageSchool: "physical" as const, speedMs: 2_000 };
|
||||
const offHandWeapon = { damageMin: 40, damageMax: 40, damageSchool: "physical" as const, speedMs: 2_000 };
|
||||
const offHandId = "rom-chaos-dagger-party-offhand";
|
||||
const offHandItem = {
|
||||
instanceId: offHandId,
|
||||
itemId: 1,
|
||||
itemKey: offHandId,
|
||||
name: "Chaos Dagger Test Off-hand",
|
||||
quality: "uncommon" as const,
|
||||
inventorySlot: "off-hand",
|
||||
armorType: null,
|
||||
weaponType: "dagger",
|
||||
itemLevel: 1,
|
||||
sourceItemLevel: 1,
|
||||
stats: { ...EMPTY_ITEM_STATS, meleeWeapon: offHandWeapon },
|
||||
quantity: 1,
|
||||
sourceBossKey: "test",
|
||||
sourceBossName: "Test",
|
||||
acquiredAt: 1,
|
||||
};
|
||||
usePartyStore.getState().initializeParty("rom-rogue-mage-chaos-dagger-party", 80,
|
||||
useCombatStore.getState().health, "damage", 2, "rom");
|
||||
usePartyStore.setState({ members: [{
|
||||
...rogue,
|
||||
gearStats: { ...EMPTY_ITEM_STATS, hitRating: 1_000_000, meleeWeapon: mainHandWeapon },
|
||||
nextActionAt: 100_000,
|
||||
}] });
|
||||
useCombatStore.setState((state) => ({
|
||||
inventory: [...state.inventory, offHandItem],
|
||||
equipment: { ...state.equipment, [rogue.id]: { "off-hand": offHandId } },
|
||||
}));
|
||||
registerPartyRuntimePosition(rogue.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const targetId = "rom-rogue-mage-chaos-dagger-target";
|
||||
useCombatStore.getState().registerMob(targetId, {
|
||||
name: "Chaos Dagger party target",
|
||||
maxHealth: 1_000_000,
|
||||
level: 80,
|
||||
schoolImmunities: ["fire"],
|
||||
});
|
||||
setMobPosition(targetId, [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob(targetId, 1, 100);
|
||||
updatePartyRuntimeTargetVisibility(rogue.id, targetId, true);
|
||||
usePartyStore.setState({ command: "attack", activeMobId: targetId });
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
const damage = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage" && event.targetActorId === targetId
|
||||
));
|
||||
const firePackets = damage.filter((event) => event.school === "fire");
|
||||
expect(firePackets.map((event) => event.rawAmount).sort((a, b) => a - b)).toEqual([34, 85]);
|
||||
expect(firePackets.every((event) => event.effectiveAmount === 0)).toBe(true);
|
||||
expect(damage.some((event) => event.school === "physical" && event.effectiveAmount > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("routes companion pet-targeted auras to that Hunter's live summoned pet", () => {
|
||||
const callPet = abilitiesForClass("hunter").find((ability) => ability.name === "Call Pet")!;
|
||||
const summons = [
|
||||
{ id: "hunter-one-pet", ownerActorId: "hunter-one", health: 1, expiresAt: 10_000, kind: "summon" as const, sourceAbilityId: callPet.id },
|
||||
{ id: "hunter-two-pet", ownerActorId: "hunter-two", health: 1, expiresAt: 10_000, kind: "summon" as const, sourceAbilityId: callPet.id },
|
||||
{ id: "hunter-one-dead-pet", ownerActorId: "hunter-one", health: 0, expiresAt: 10_000, kind: "summon" as const, sourceAbilityId: callPet.id },
|
||||
{ id: "hunter-one-expired-pet", ownerActorId: "hunter-one", health: 1, expiresAt: 1_000, kind: "summon" as const, sourceAbilityId: callPet.id },
|
||||
{ id: "hunter-one-totem", ownerActorId: "hunter-one", health: 1, expiresAt: 10_000, kind: "totem" as const, sourceAbilityId: callPet.id },
|
||||
];
|
||||
|
||||
expect(partyHunterPetRecipientIds(summons, "hunter-one", 2_000)).toEqual(["hunter-one-pet"]);
|
||||
});
|
||||
|
||||
it("maintains HoTs on separate injured allies without resetting pending ticks", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 8 });
|
||||
usePartyStore.getState().initializeParty("party-hot-coverage", 8, useCombatStore.getState().health, "damage");
|
||||
@@ -117,7 +526,7 @@ describe("party combat runtime", () => {
|
||||
const hot = abilities.find(ability => ability.name === "Rejuvenation")!;
|
||||
usePartyStore.setState(state => ({ members: state.members.map(member => member.id === healer.id
|
||||
? { ...member, classId: "druid", resourceType: "mana", resource: 10_000, maxResource: 10_000,
|
||||
resourcePools: { mana: 10_000 }, talentRanks: {}, cooldowns: Object.fromEntries(abilities.filter(a => a.id !== hot.id).map(a => [a.id, 60_000])) }
|
||||
resourcePools: { mana: 10_000 }, talentRanks: { "talent-830": 3 }, cooldowns: Object.fromEntries(abilities.filter(a => a.id !== hot.id).map(a => [a.id, 60_000])) }
|
||||
: { ...member, health: member.maxHealth * .5 }) }));
|
||||
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
@@ -125,6 +534,9 @@ describe("party combat runtime", () => {
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
const first = useCombatStore.getState().effects.find(effect => effect.kind === "hot" && effect.sourceActorId === healer.id)!;
|
||||
expect(first).toBeDefined();
|
||||
const rejuvenationHot = hot.effects.find((effect) => effect.kind === "hot")!;
|
||||
const baselineAmount = partyAbilityAmount({ ...healer, classId: "druid", talentRanks: {} }, hot, rejuvenationHot);
|
||||
expect(first.fixedAmount).toBe(Math.round(baselineAmount * 1.2));
|
||||
advanceDungeonPartyCombat(3_000);
|
||||
const effects = useCombatStore.getState().effects.filter(effect => effect.kind === "hot" && effect.sourceActorId === healer.id);
|
||||
expect(new Set(effects.map(effect => effect.targetId)).size).toBe(2);
|
||||
@@ -294,6 +706,121 @@ describe("party combat runtime", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ rank: 0, shouldSlow: false },
|
||||
{ rank: 2, shouldSlow: true },
|
||||
])("applies generated Warlock Aftermath to landed companion Conflagrate at rank $rank", ({ rank, shouldSlow }) => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const seed = `party-warlock-aftermath-rank-${rank}`;
|
||||
const warlock = createConfiguredPartyMember({
|
||||
name: "Destruction Warlock",
|
||||
classId: "warlock",
|
||||
specialization: "Destruction",
|
||||
specializationId: "warlock:destruction",
|
||||
talentTreeId: "warlock-destruction",
|
||||
talentRanks: { "talent-968": 1, "talent-982": rank },
|
||||
role: "damage",
|
||||
}, 80, 0, seed);
|
||||
const conflagrate = abilityAtLevel(
|
||||
abilitiesForClass("warlock").find((ability) => ability.name === "Conflagrate")!,
|
||||
80,
|
||||
);
|
||||
expect(learnedAbilityForBuild(
|
||||
abilitiesForClass("warlock").find((ability) => ability.name === "Conflagrate")!,
|
||||
80,
|
||||
warlock.talentRanks,
|
||||
)).toMatchObject({ id: conflagrate.id });
|
||||
const targetId = `party-warlock-aftermath-target-${rank}`;
|
||||
const completionAt = 10_000;
|
||||
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
|
||||
usePartyStore.setState({ members: [{
|
||||
...warlock,
|
||||
gearStats: EMPTY_ITEM_STATS,
|
||||
resource: 10_000,
|
||||
maxResource: 10_000,
|
||||
resourcePools: { ...warlock.resourcePools, mana: 10_000 },
|
||||
activeCast: {
|
||||
abilityId: conflagrate.id,
|
||||
school: "fire",
|
||||
mode: "cast",
|
||||
startedAt: completionAt - conflagrate.castTimeMs,
|
||||
completesAt: completionAt,
|
||||
originalDurationMs: conflagrate.castTimeMs,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId,
|
||||
},
|
||||
activeCastTargetId: targetId,
|
||||
status: "attacking",
|
||||
}] });
|
||||
registerPartyRuntimePosition(warlock.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
expect(useCombatStore.getState().actors[warlock.id]?.cast).toMatchObject({
|
||||
abilityId: conflagrate.id,
|
||||
targetId,
|
||||
});
|
||||
registerFrostbiteTarget(targetId);
|
||||
const immolate = abilityAtLevel(
|
||||
abilitiesForClass("warlock").find((ability) => ability.name === "Immolate")!,
|
||||
80,
|
||||
);
|
||||
const immolateDot = immolate.effects.find((effect) => effect.kind === "dot");
|
||||
if (!immolateDot || immolateDot.kind !== "dot") throw new Error("Missing generated Immolate DoT.");
|
||||
expect(useCombatStore.getState().applyTimedActorEffect(
|
||||
warlock.id, targetId, immolate, immolateDot, 1_000,
|
||||
)).toBe(true);
|
||||
expect(partyAbilityTargetIsValid(warlock, conflagrate, targetId, [0, 0, 0], completionAt)).toBe(true);
|
||||
const immolateEffectId = useCombatStore.getState().effects.find((effect) => (
|
||||
effect.kind === "dot" && effect.sourceActorId === warlock.id && effect.targetId === targetId
|
||||
))!.id;
|
||||
expect(useCombatStore.getState().actors[warlock.id]?.cast).toMatchObject({
|
||||
abilityId: conflagrate.id,
|
||||
targetId,
|
||||
});
|
||||
const beforeHealth = useCombatStore.getState().mobs[targetId]!.health;
|
||||
const aftermathEvents: StructuredCombatEvent[] = [];
|
||||
const stopAftermathEvents = subscribeCombatEvents((event) => aftermathEvents.push(event));
|
||||
const aftermathPresentation: CombatPresentationEvent[] = [];
|
||||
const stopAftermathPresentation = subscribeCombatPresentation((message) => {
|
||||
if (message.kind === "event") aftermathPresentation.push(message.event);
|
||||
});
|
||||
|
||||
advanceDungeonPartyCombat(completionAt);
|
||||
stopAftermathEvents();
|
||||
stopAftermathPresentation();
|
||||
|
||||
const mob = useCombatStore.getState().mobs[targetId]!;
|
||||
expect(aftermathPresentation).toContainEqual(expect.objectContaining({
|
||||
phase: "impact",
|
||||
sourceActorId: warlock.id,
|
||||
abilityId: conflagrate.id,
|
||||
}));
|
||||
const conflagrateDamage = aftermathEvents.find((event) => (
|
||||
event.kind === "damage" && event.sourceActorId === warlock.id && event.targetActorId === targetId
|
||||
&& event.abilityId === conflagrate.id
|
||||
));
|
||||
expect(conflagrateDamage).toMatchObject({
|
||||
rawAmount: 628,
|
||||
effectiveAmount: expect.any(Number),
|
||||
});
|
||||
expect(mob.health).toBeLessThan(beforeHealth);
|
||||
expect(useCombatStore.getState().effects.some((effect) => effect.id === immolateEffectId)).toBe(false);
|
||||
const aftermath = mob.statuses.find((status) => (
|
||||
status.kind === "slow" && status.sourceAbilityId === conflagrate.id && status.sourceActorId === warlock.id
|
||||
));
|
||||
if (shouldSlow) {
|
||||
expect(aftermath).toMatchObject({
|
||||
kind: "slow",
|
||||
sourceAbilityId: conflagrate.id,
|
||||
sourceActorId: warlock.id,
|
||||
endsAt: completionAt + 4_000,
|
||||
magnitude: 0.5,
|
||||
});
|
||||
} else {
|
||||
expect(aftermath).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("lets a party Mage Frostbite proc on a landed Blizzard channel pulse", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const seed = "party-mage-frostbite-blizzard";
|
||||
@@ -708,9 +1235,21 @@ describe("party combat runtime", () => {
|
||||
registerEngagedMob("pet-runtime-boss", 100_000);
|
||||
const events: StructuredCombatEvent[] = [];
|
||||
const stop = subscribeCombatEvents((event) => events.push(event));
|
||||
let growlConfigured = false;
|
||||
for (let now = 1_000; now <= 30_000; now += 100) {
|
||||
useCombatStore.getState().tick(0.1, now);
|
||||
advanceDungeonPartyCombat(now);
|
||||
const hunterPet = useCombatStore.getState().summons.find((summon) => summon.ownerActorId === hunter.id
|
||||
&& summon.hunterPet);
|
||||
if (hunterPet && !growlConfigured) {
|
||||
expect(hunterPet).toMatchObject({
|
||||
hunterPetCommand: "follow",
|
||||
hunterPetReactMode: "defensive",
|
||||
hunterPetGrowlAutocastEnabled: false,
|
||||
});
|
||||
expect(useCombatStore.getState().setHunterPetGrowlAutocast(hunterPet.id, true)).toBe(true);
|
||||
growlConfigured = true;
|
||||
}
|
||||
}
|
||||
stop();
|
||||
|
||||
@@ -718,6 +1257,31 @@ describe("party combat runtime", () => {
|
||||
summon.ownerActorId === hunter.id || summon.ownerActorId === warlock.id
|
||||
));
|
||||
expect(new Set(pets.map((summon) => summon.ownerActorId))).toEqual(new Set([hunter.id, warlock.id]));
|
||||
const baseHunterPetStats = wow335HunterPetBaseStatsForLevel(hunter.level);
|
||||
expect(pets.find((summon) => summon.ownerActorId === hunter.id)).toMatchObject({
|
||||
hunterPet: {
|
||||
baseStats: { strength: 192, attackPower: 364 },
|
||||
effectiveStats: {
|
||||
inheritedStamina: expect.any(Number),
|
||||
inheritedRangedAttackPower: expect.any(Number),
|
||||
inheritedSpellDamage: expect.any(Number),
|
||||
},
|
||||
},
|
||||
});
|
||||
const hunterPet = pets.find((summon) => summon.ownerActorId === hunter.id)!;
|
||||
expect(growlConfigured).toBe(true);
|
||||
expect(hunterPet.hunterPetGrowlReadyAt).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().mobs["pet-runtime-boss"]?.threatByActor[hunterPet.id]).toBeGreaterThanOrEqual(1_234);
|
||||
expect(useCombatStore.getState().mobs["pet-runtime-boss"]?.forcedTarget).toBeNull();
|
||||
expect(hunterPet.hunterPet!.effectiveStats.inheritedSpellDamage).toBeGreaterThan(0);
|
||||
expect(hunterPet.hunterPet!.effectiveStats.inheritedSpellDamage).toBeCloseTo(
|
||||
hunterPet.hunterPet!.effectiveStats.inheritedRangedAttackPower / 0.22
|
||||
* WOW335_HUNTER_PET_RANGED_AP_TO_SPELL_DAMAGE,
|
||||
);
|
||||
expect(hunterPet.maxHealth).toBe(hunterPet.hunterPet!.effectiveStats.maxHealth);
|
||||
expect(hunterPet.attackDamage).toBe(hunterPet.hunterPet!.effectiveStats.averageAutoAttackDamage);
|
||||
expect(hunterPet.maxHealth).toBeGreaterThan(baseHunterPetStats.maxHealth);
|
||||
expect(hunterPet.attackDamage).toBeGreaterThan(baseHunterPetStats.averageAutoAttackDamage);
|
||||
expect(pets.every((summon) => summon.targetId === "pet-runtime-boss")).toBe(true);
|
||||
expect(events.filter((event) => event.kind === "damage"
|
||||
&& [hunter.id, warlock.id].includes(event.sourceActorId))).toEqual(expect.arrayContaining([
|
||||
|
||||
Reference in new issue
Block a user