forked from phenom/healer-man
1881 lines
84 KiB
TypeScript
1881 lines
84 KiB
TypeScript
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
import { useCombatStore, type EnemyActiveCast } from "./combatStore";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import {
|
|
clearPartyRuntimeRegistry,
|
|
registerPartyRuntimePosition,
|
|
updatePartyRuntimeAttackCorridor,
|
|
updatePartyRuntimeTargetVisibility,
|
|
beginPartyRuntimeRejoin,
|
|
finishPartyRuntimeRejoin,
|
|
} from "./partyRuntimeRegistry";
|
|
import {
|
|
activePartyTargetId,
|
|
advanceDungeonPartyCombat,
|
|
applyHealerSupport,
|
|
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 { 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 });
|
|
setMobPosition(id, [2, 0, 0]);
|
|
useCombatStore.getState().damageMob(id, 1, 100);
|
|
for (const member of usePartyStore.getState().members) {
|
|
updatePartyRuntimeTargetVisibility(member.id, id, true);
|
|
}
|
|
}
|
|
|
|
function stableUnit(seed: string): number {
|
|
let hash = 2166136261;
|
|
for (const character of seed) {
|
|
hash ^= character.charCodeAt(0);
|
|
hash = Math.imul(hash, 16777619);
|
|
}
|
|
return (hash >>> 0) / 0x1_0000_0000;
|
|
}
|
|
|
|
function partyHealCriticalAt(sourceId: string, abilityId: string, targetId: string, chance: number): number {
|
|
for (let now = 10_000; now < 1_000_000; now += 1) {
|
|
if (stableUnit(`${sourceId}:${abilityId}:${targetId}:${now}:heal`) < chance) return now;
|
|
}
|
|
throw new Error("Could not find a seeded companion healing crit.");
|
|
}
|
|
|
|
function createFrostbitePartyMage(seed: string) {
|
|
const mage = createConfiguredPartyMember({
|
|
name: "Frostbite Mage",
|
|
classId: "mage",
|
|
specialization: "Frost",
|
|
specializationId: "mage:frost",
|
|
talentTreeId: "mage-frost",
|
|
talentRanks: { "talent-38": 3 },
|
|
role: "damage",
|
|
}, 80, 0, seed);
|
|
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
|
|
usePartyStore.setState({ members: [{
|
|
...mage,
|
|
gearStats: EMPTY_ITEM_STATS,
|
|
resource: 10_000,
|
|
maxResource: 10_000,
|
|
resourcePools: { ...mage.resourcePools, mana: 10_000 },
|
|
}] });
|
|
registerPartyRuntimePosition(mage.id, [0, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
return mage;
|
|
}
|
|
|
|
function registerFrostbiteTarget(id: string): void {
|
|
useCombatStore.getState().registerMob(id, {
|
|
name: id,
|
|
level: 1,
|
|
maxHealth: 100_000,
|
|
xpReward: 0,
|
|
});
|
|
setMobPosition(id, [2, 0, 0]);
|
|
useCombatStore.getState().damageMob(id, 1, 100);
|
|
for (const member of usePartyStore.getState().members) {
|
|
updatePartyRuntimeTargetVisibility(member.id, id, true);
|
|
}
|
|
usePartyStore.setState({ command: "attack", activeMobId: id });
|
|
}
|
|
|
|
describe("party combat runtime", () => {
|
|
beforeAll(async () => {
|
|
await preloadAbilityCatalogsForClasses(["rom-druid"]);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetPartyRuntimeClock();
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 5 });
|
|
usePartyStore.getState().initializeParty("runtime-party", 5, useCombatStore.getState().health);
|
|
});
|
|
|
|
it("prefers the selected engaged target, then a living engaged pack member", () => {
|
|
registerEngagedMob("pack:a");
|
|
registerEngagedMob("pack:b");
|
|
const mobs = useCombatStore.getState().mobs;
|
|
expect(activePartyTargetId(mobs, "pack:b")).toBe("pack:b");
|
|
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");
|
|
const healer = usePartyStore.getState().members.find(member => member.role === "healer")!;
|
|
const abilities = abilitiesForClass("druid");
|
|
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: { "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();
|
|
registerEngagedMob("hot-boss", 100_000);
|
|
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);
|
|
expect(effects.find(effect => effect.targetId === first.targetId)?.nextTickAt).toBe(first.nextTickAt);
|
|
});
|
|
|
|
it.each([false, true])("companion Swiftmend preserves the HoT only with the configured glyph (%s)", (glyphActive) => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
|
const druidAbilities = abilitiesForClass("druid");
|
|
const swiftmendBase = druidAbilities.find((ability) => ability.name === "Swiftmend")!;
|
|
const swiftmendNodeId = abilityTalentNodeId(swiftmendBase)!;
|
|
const swiftmend = abilityAtLevel(swiftmendBase, 80);
|
|
const rejuvenation = abilityAtLevel(druidAbilities.find((ability) => ability.name === "Rejuvenation")!, 80);
|
|
const rejuvenationHot = rejuvenation.effects.find((effect) => effect.kind === "hot")!;
|
|
const druid = createConfiguredPartyMember({
|
|
name: "Configured Druid",
|
|
classId: "druid",
|
|
specialization: "Restoration",
|
|
specializationId: "druid:restoration",
|
|
talentTreeId: "druid-restoration",
|
|
talentRanks: { [swiftmendNodeId]: 1 },
|
|
glyphIds: glyphActive ? [WOW335_GLYPH_OF_SWIFTMEND_ID] : [],
|
|
role: "healer",
|
|
}, 80, 0, "party-swiftmend-glyph");
|
|
const target = createConfiguredPartyMember({
|
|
name: "Injured Tank",
|
|
classId: "warrior",
|
|
specialization: "Protection",
|
|
specializationId: "warrior:protection",
|
|
talentTreeId: "warrior-protection",
|
|
role: "tank",
|
|
}, 80, 1, "party-swiftmend-glyph");
|
|
const startedAt = 1_000;
|
|
usePartyStore.getState().initializeParty("party-swiftmend-glyph", 80,
|
|
useCombatStore.getState().health, "damage");
|
|
usePartyStore.setState({ members: [
|
|
{ ...target, health: 100 },
|
|
{
|
|
...druid,
|
|
activeCast: {
|
|
abilityId: swiftmend.id,
|
|
school: "nature",
|
|
mode: "cast",
|
|
startedAt,
|
|
completesAt: startedAt,
|
|
originalDurationMs: 0,
|
|
pushbackHits: 0,
|
|
origin: [0, 0, 0],
|
|
targetId: target.id,
|
|
},
|
|
activeCastTargetId: target.id,
|
|
status: "healing",
|
|
},
|
|
] });
|
|
registerPartyRuntimePosition(druid.id, [0, 0, 0]);
|
|
registerPartyRuntimePosition(target.id, [0, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
expect(useCombatStore.getState().applyTimedActorEffect(
|
|
druid.id, target.id, rejuvenation, rejuvenationHot, startedAt,
|
|
)).toBe(true);
|
|
const originalHot = useCombatStore.getState().effects.find((effect) => (
|
|
effect.kind === "hot" && effect.sourceActorId === druid.id && effect.targetId === target.id
|
|
))!;
|
|
|
|
advanceDungeonPartyCombat(startedAt);
|
|
|
|
expect(useCombatStore.getState().actors[target.id]!.health).toBeGreaterThan(100);
|
|
expect(useCombatStore.getState().effects.some((effect) => effect.id === originalHot.id)).toBe(glyphActive);
|
|
expect(usePartyStore.getState().members.find((member) => member.id === druid.id)?.glyphIds)
|
|
.toEqual(glyphActive ? [WOW335_GLYPH_OF_SWIFTMEND_ID] : []);
|
|
});
|
|
|
|
it("refreshes a companion Priest's Shadow Word: Pain from its own completed Mind Blast", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
|
const priest = createConfiguredPartyMember({
|
|
name: "Shadow Priest",
|
|
classId: "priest",
|
|
specialization: "Shadow",
|
|
specializationId: "priest:shadow",
|
|
talentTreeId: "priest-shadow",
|
|
role: "damage",
|
|
}, 80, 0, "pain-and-suffering-companion");
|
|
const priestAbilities = abilitiesForClass("priest");
|
|
const shadowWordPainBase = priestAbilities.find((ability) => ability.name === "Shadow Word: Pain")!;
|
|
const shadowWordPain = abilityAtLevel(shadowWordPainBase, 80);
|
|
const shadowWordPainDot = shadowWordPain.effects.find((effect) => effect.kind === "dot")!;
|
|
const mindBlast = abilityAtLevel(priestAbilities.find((ability) => ability.name === "Mind Blast")!, 80);
|
|
const targetId = "pain-and-suffering-companion-target";
|
|
usePartyStore.getState().initializeParty("pain-and-suffering-companion", 80,
|
|
useCombatStore.getState().health, "damage");
|
|
usePartyStore.setState({ members: [{
|
|
...priest,
|
|
talentRanks: { "talent-1909": 3 },
|
|
gearStats: EMPTY_ITEM_STATS,
|
|
activeCast: {
|
|
abilityId: mindBlast.id,
|
|
school: "shadow",
|
|
mode: "cast",
|
|
startedAt: 10_000,
|
|
completesAt: 10_000 + mindBlast.castTimeMs,
|
|
originalDurationMs: mindBlast.castTimeMs,
|
|
pushbackHits: 0,
|
|
origin: [0, 0, 0],
|
|
targetId,
|
|
},
|
|
activeCastTargetId: targetId,
|
|
status: "attacking",
|
|
}] });
|
|
registerPartyRuntimePosition(priest.id, [0, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
registerEngagedMob(targetId, 100_000);
|
|
expect(useCombatStore.getState().applyTimedActorEffect(
|
|
priest.id, targetId, shadowWordPain, shadowWordPainDot, 1_000,
|
|
)).toBe(true);
|
|
const before = useCombatStore.getState().effects.find((effect) => (
|
|
effect.kind === "dot" && effect.sourceActorId === priest.id && effect.targetId === targetId
|
|
))!;
|
|
|
|
advanceDungeonPartyCombat(mindBlast.castTimeMs + 10_000);
|
|
const after = useCombatStore.getState().effects.find((effect) => (
|
|
effect.kind === "dot" && effect.sourceActorId === priest.id && effect.targetId === targetId
|
|
))!;
|
|
expect(after.remainingTicks).toBe(shadowWordPainDot.ticks);
|
|
expect(after.nextTickAt).toBe(10_000 + mindBlast.castTimeMs + after.intervalMs);
|
|
expect(after.fixedAmount).toBe(before.fixedAmount);
|
|
expect(after.intervalMs).toBe(before.intervalMs);
|
|
});
|
|
|
|
it("lets a party Mage Frostbite freeze after landed direct Frostbolt damage", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
|
const seed = "party-mage-frostbite-direct";
|
|
const mage = createFrostbitePartyMage(seed);
|
|
const frostbolt = abilityAtLevel(abilitiesForClass("mage").find((ability) => ability.name === "Frostbolt")!, 80);
|
|
const targetId = "party-mage-frostbite-direct-target";
|
|
const impactAt = Array.from({ length: 100_000 }, (_, index) => 10_000 + index)
|
|
.find((now) => stableUnit(`${mage.id}:${frostbolt.id}:${targetId}:${now}:wow335-mage-frostbite`) < 0.15);
|
|
if (impactAt === undefined) throw new Error("Could not find a seeded party Frostbite proc time.");
|
|
usePartyStore.setState({ members: [{
|
|
...usePartyStore.getState().members[0]!,
|
|
activeCast: {
|
|
abilityId: frostbolt.id,
|
|
school: "frost",
|
|
mode: "cast",
|
|
startedAt: impactAt - frostbolt.castTimeMs,
|
|
completesAt: impactAt,
|
|
originalDurationMs: frostbolt.castTimeMs,
|
|
pushbackHits: 0,
|
|
origin: [0, 0, 0],
|
|
targetId,
|
|
},
|
|
activeCastTargetId: targetId,
|
|
status: "attacking",
|
|
}] });
|
|
useCombatStore.getState().synchronizeActors();
|
|
registerFrostbiteTarget(targetId);
|
|
const beforeHealth = useCombatStore.getState().mobs[targetId]!.health;
|
|
|
|
advanceDungeonPartyCombat(impactAt - frostbolt.castTimeMs);
|
|
advanceDungeonPartyCombat(impactAt);
|
|
|
|
expect(useCombatStore.getState().mobs[targetId]!.health).toBeLessThan(beforeHealth);
|
|
expect(useCombatStore.getState().mobs[targetId]!.statuses).toContainEqual(expect.objectContaining({
|
|
kind: "freeze",
|
|
sourceAbilityId: frostbolt.id,
|
|
sourceActorId: mage.id,
|
|
endsAt: impactAt + 4_000,
|
|
}));
|
|
});
|
|
|
|
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";
|
|
const mage = createFrostbitePartyMage(seed);
|
|
const blizzard = abilityAtLevel(abilitiesForClass("mage").find((ability) => ability.name === "Blizzard")!, 80);
|
|
const targetId = "party-mage-frostbite-blizzard-target";
|
|
const durationMs = blizzard.castTimeMs;
|
|
expect(blizzard.castMode).toBe("channel");
|
|
expect(durationMs).toBeGreaterThan(0);
|
|
|
|
let startedAt = 0;
|
|
let procAt = 0;
|
|
for (let start = 10_000; start < 20_000 && procAt === 0; start += 1) {
|
|
const pulses = Array.from({ length: channelPulseCount(blizzard) }, (_, index) => (
|
|
channelPulseAt(blizzard, start, durationMs, index)
|
|
));
|
|
const proc = pulses.find((pulseAt) => (
|
|
stableUnit(`${mage.id}:${blizzard.id}:${targetId}:${pulseAt}:wow335-mage-frostbite`) < 0.15
|
|
));
|
|
if (proc !== undefined) {
|
|
startedAt = start;
|
|
procAt = proc;
|
|
}
|
|
}
|
|
if (procAt === 0) throw new Error("Could not find a seeded party Blizzard Frostbite pulse.");
|
|
usePartyStore.setState({ members: [{
|
|
...usePartyStore.getState().members[0]!,
|
|
activeCast: {
|
|
abilityId: blizzard.id,
|
|
school: "frost",
|
|
mode: "channel",
|
|
startedAt,
|
|
completesAt: startedAt + durationMs,
|
|
originalDurationMs: durationMs,
|
|
pushbackHits: 0,
|
|
origin: [0, 0, 0],
|
|
targetId,
|
|
channelTicksCompleted: 0,
|
|
},
|
|
activeCastTargetId: targetId,
|
|
status: "attacking",
|
|
}] });
|
|
useCombatStore.getState().synchronizeActors();
|
|
registerFrostbiteTarget(targetId);
|
|
const beforeHealth = useCombatStore.getState().mobs[targetId]!.health;
|
|
|
|
advanceDungeonPartyCombat(startedAt);
|
|
advanceDungeonPartyCombat(procAt);
|
|
|
|
expect(useCombatStore.getState().mobs[targetId]!.health).toBeLessThan(beforeHealth);
|
|
expect(useCombatStore.getState().mobs[targetId]!.statuses).toContainEqual(expect.objectContaining({
|
|
kind: "freeze",
|
|
sourceAbilityId: blizzard.id,
|
|
sourceActorId: mage.id,
|
|
endsAt: procAt + 4_000,
|
|
}));
|
|
});
|
|
|
|
it("lets a party Mage proc its learned Eruption passive on Fireball", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-mage", level: 80 });
|
|
const seed = "party-rom-mage-eruption";
|
|
const mage = createConfiguredPartyMember({
|
|
name: "Eruption Mage",
|
|
classId: "rom-mage",
|
|
secondaryClassId: "rom-druid",
|
|
specialization: "Primary",
|
|
role: "damage",
|
|
}, 80, 0, seed);
|
|
const fireball = abilitiesForClass("rom-mage").find((ability) => ability.name === "Fireball")!;
|
|
const targetId = "party-rom-mage-eruption-target";
|
|
const impactAt = Array.from({ length: 100_000 }, (_, index) => 10_000 + index)
|
|
.find((now) => stableUnit(`${mage.id}:${fireball.id}:${targetId}:${now}:rom-eruption`) < 0.1);
|
|
if (impactAt === undefined) throw new Error("Could not find a seeded party Eruption proc time.");
|
|
|
|
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom");
|
|
usePartyStore.getState().setCommand("attack");
|
|
usePartyStore.setState({ members: [{
|
|
...mage,
|
|
resource: 10_000,
|
|
maxResource: 10_000,
|
|
resourcePools: { ...mage.resourcePools, mana: 10_000 },
|
|
activeCast: {
|
|
abilityId: fireball.id,
|
|
school: "fire",
|
|
mode: "cast",
|
|
startedAt: impactAt,
|
|
completesAt: impactAt,
|
|
originalDurationMs: 0,
|
|
pushbackHits: 0,
|
|
origin: [0, 0, 0],
|
|
targetId,
|
|
},
|
|
activeCastTargetId: targetId,
|
|
status: "attacking",
|
|
}] });
|
|
registerPartyRuntimePosition(mage.id, [0, 0, 0]);
|
|
registerFrostbiteTarget(targetId);
|
|
updatePartyRuntimeTargetVisibility(mage.id, targetId, true);
|
|
useCombatStore.getState().synchronizeActors();
|
|
|
|
advanceDungeonPartyCombat(impactAt);
|
|
|
|
expect(useCombatStore.getState().mobs[targetId]!.health).toBeLessThan(99_999);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === mage.id
|
|
&& aura.definition.id === "rom-mage-druid-control-flame-buff")).toBe(true);
|
|
});
|
|
|
|
it("procs a party Paladin's source-owned Vindication from its completed Holy spell", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
|
const seed = "vindication-paladin-party";
|
|
const paladin = createConfiguredPartyMember({
|
|
name: "Vindication Paladin",
|
|
classId: "paladin",
|
|
specialization: "Retribution",
|
|
specializationId: "paladin:retribution",
|
|
talentTreeId: "paladin-retribution",
|
|
talentRanks: { "talent-1633": 3 },
|
|
role: "damage",
|
|
}, 80, 0, seed);
|
|
const exorcism = abilityAtLevel(abilitiesForClass("paladin").find((ability) => ability.name === "Exorcism")!, 80);
|
|
const targetId = "vindication-paladin-party-target";
|
|
const completionAt = Array.from({ length: 100_000 }, (_, index) => 10_000 + index)
|
|
.find((now) => stableUnit(`${paladin.id}:${exorcism.id}:${targetId}:${now}:wow335-paladin-vindication`) < 0.65);
|
|
if (completionAt === undefined) throw new Error("Could not find a seeded party Vindication proc time.");
|
|
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
|
|
usePartyStore.setState({ members: [{
|
|
...paladin,
|
|
gearStats: EMPTY_ITEM_STATS,
|
|
activeCast: {
|
|
abilityId: exorcism.id,
|
|
school: "holy",
|
|
mode: "cast",
|
|
startedAt: completionAt - exorcism.castTimeMs,
|
|
completesAt: completionAt,
|
|
originalDurationMs: exorcism.castTimeMs,
|
|
pushbackHits: 0,
|
|
origin: [0, 0, 0],
|
|
targetId,
|
|
},
|
|
activeCastTargetId: targetId,
|
|
status: "attacking",
|
|
}] });
|
|
registerPartyRuntimePosition(paladin.id, [0, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
registerEngagedMob(targetId, 100_000);
|
|
|
|
advanceDungeonPartyCombat(completionAt);
|
|
|
|
expect(useCombatStore.getState().mobs[targetId].auras).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: "wow335:paladin:vindication",
|
|
sourceActorId: paladin.id,
|
|
sourceAbilityId: exorcism.id,
|
|
endsAt: completionAt + 10_000,
|
|
magnitude: 357,
|
|
}),
|
|
]));
|
|
});
|
|
|
|
it("grants a party Rogue Improved Kick only after it interrupts an active cast", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
|
const seed = "improved-kick-party-runtime";
|
|
const rogue = createConfiguredPartyMember({
|
|
name: "Combat Rogue",
|
|
classId: "rogue",
|
|
specialization: "Combat",
|
|
specializationId: "rogue:combat",
|
|
talentTreeId: "rogue-combat",
|
|
talentRanks: { "talent-206": 2 },
|
|
role: "damage",
|
|
}, 80, 0, seed);
|
|
const kick = abilitiesForClass("rogue").find((ability) => ability.name === "Kick");
|
|
if (!kick) throw new Error("Missing generated Rogue Kick");
|
|
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
|
|
usePartyStore.setState({ members: [{
|
|
...rogue,
|
|
resource: 0,
|
|
resourcePools: { ...rogue.resourcePools, energy: 0 },
|
|
activeCast: {
|
|
abilityId: kick.id,
|
|
school: "physical",
|
|
mode: "cast",
|
|
startedAt: 900,
|
|
completesAt: 900,
|
|
originalDurationMs: 0,
|
|
pushbackHits: 0,
|
|
origin: [0, 0, 0],
|
|
targetId: "improved-kick-party-target",
|
|
},
|
|
activeCastTargetId: "improved-kick-party-target",
|
|
status: "attacking",
|
|
}] });
|
|
registerPartyRuntimePosition(rogue.id, [0, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
const targetId = "improved-kick-party-target";
|
|
useCombatStore.getState().registerMob(targetId, { name: targetId, maxHealth: 100_000, xpReward: 0 });
|
|
setMobPosition(targetId, [2, 0, 0]);
|
|
useCombatStore.getState().damageMob(targetId, 1, 100);
|
|
updatePartyRuntimeTargetVisibility(rogue.id, targetId, true);
|
|
const hostileCast: EnemyActiveCast = {
|
|
attackId: "party-test-shadow-bolt",
|
|
name: "Shadow Bolt",
|
|
spellId: 686,
|
|
school: "shadow",
|
|
interruptible: true,
|
|
targetActorId: null,
|
|
sourcePosition: [2, 0, 0],
|
|
targetPosition: [0, 0, 0],
|
|
sourceYaw: 0,
|
|
startedAt: 500,
|
|
completesAt: 2_500,
|
|
};
|
|
useCombatStore.setState((state) => ({
|
|
mobs: { ...state.mobs, [targetId]: { ...state.mobs[targetId]!, activeCast: hostileCast } },
|
|
}));
|
|
|
|
advanceDungeonPartyCombat(1_000);
|
|
|
|
expect(useCombatStore.getState().mobs[targetId]?.activeCast).toBeNull();
|
|
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
|
|
sourceId: rogue.id,
|
|
targetId: rogue.id,
|
|
appliedAt: 1_000,
|
|
expiresAt: 7_000,
|
|
definition: expect.objectContaining({
|
|
utilityTags: ["wow335-rogue-improved-kick"],
|
|
modifiers: [
|
|
{ kind: "stat", stat: "melee-haste", operation: "flat", value: 35 },
|
|
{ kind: "stat", stat: "ranged-haste", operation: "flat", value: 35 },
|
|
],
|
|
}),
|
|
}));
|
|
expect(usePartyStore.getState().members[0]?.resourcePools.energy).toBe(40);
|
|
|
|
useCombatStore.getState().selectMob(targetId);
|
|
usePartyStore.setState((state) => ({
|
|
members: state.members.map((member) => ({
|
|
...member,
|
|
nextActionAt: 10_000,
|
|
gearStats: { ...member.gearStats, hasteRating: 0 },
|
|
})),
|
|
}));
|
|
useCombatStore.getState().synchronizeActors();
|
|
const autoAttackTimes: number[] = [];
|
|
const unsubscribe = subscribeCombatPresentation((message) => {
|
|
if (message.kind === "event"
|
|
&& message.event.sourceActorId === rogue.id
|
|
&& message.event.abilityId === "party-auto-attack"
|
|
&& message.event.phase === "impact") {
|
|
autoAttackTimes.push(message.event.occurredAt);
|
|
}
|
|
});
|
|
advanceDungeonPartyCombat(2_200);
|
|
advanceDungeonPartyCombat(3_400);
|
|
unsubscribe();
|
|
expect(autoAttackTimes).toEqual([2_200, 3_400]);
|
|
});
|
|
|
|
it("precasts Earth Shield on the tank while the Restoration Shaman is at full health", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
|
const tank = createConfiguredPartyMember({
|
|
name: "Tank",
|
|
classId: "warrior",
|
|
specialization: "Protection",
|
|
specializationId: "warrior:protection",
|
|
talentTreeId: "warrior-protection",
|
|
role: "tank",
|
|
}, 80, 0, "earth-shield-runtime");
|
|
const healer = createConfiguredPartyMember({
|
|
name: "Healer",
|
|
classId: "shaman",
|
|
specialization: "Restoration",
|
|
specializationId: "shaman:restoration",
|
|
talentTreeId: "shaman-restoration",
|
|
role: "healer",
|
|
}, 80, 1, "earth-shield-runtime");
|
|
usePartyStore.getState().initializeParty("earth-shield-runtime", 80, useCombatStore.getState().health, "damage");
|
|
usePartyStore.setState({ members: [tank, healer] });
|
|
registerPartyRuntimePosition(tank.id, [0, 0, 0]);
|
|
registerPartyRuntimePosition(healer.id, [-12, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
useCombatStore.getState().registerMob("earth-shield-boss", {
|
|
name: "Earth Shield boss", boss: true, maxHealth: 100_000, xpReward: 0,
|
|
});
|
|
setMobPosition("earth-shield-boss", [2, 0, 0]);
|
|
useCombatStore.getState().damageMob("earth-shield-boss", 1, 100);
|
|
updatePartyRuntimeTargetVisibility(tank.id, "earth-shield-boss", true);
|
|
updatePartyRuntimeTargetVisibility(healer.id, "earth-shield-boss", true);
|
|
|
|
for (const now of [1_000, 2_500, 4_000]) advanceDungeonPartyCombat(now);
|
|
|
|
expect(useCombatStore.getState().auras.some((aura) => (
|
|
aura.sourceId === healer.id
|
|
&& aura.targetId === tank.id
|
|
&& aura.definition.exclusiveGroup === "shaman:earth-shield"
|
|
))).toBe(true);
|
|
});
|
|
|
|
it("turns Feral Spirit into two live Enhancement summons against a boss", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
|
const shaman = createConfiguredPartyMember({
|
|
name: "Enhancement",
|
|
classId: "shaman",
|
|
specialization: "Enhancement",
|
|
specializationId: "shaman:enhancement",
|
|
talentTreeId: "shaman-enhancement",
|
|
role: "damage",
|
|
}, 80, 0, "feral-spirit-runtime");
|
|
usePartyStore.getState().initializeParty("feral-spirit-runtime", 80, useCombatStore.getState().health, "damage");
|
|
usePartyStore.setState({ members: [shaman] });
|
|
registerPartyRuntimePosition(shaman.id, [0, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
useCombatStore.getState().registerMob("feral-spirit-boss", {
|
|
name: "Feral Spirit boss", boss: true, maxHealth: 100_000, xpReward: 0,
|
|
});
|
|
setMobPosition("feral-spirit-boss", [2, 0, 0]);
|
|
useCombatStore.getState().damageMob("feral-spirit-boss", 1, 100);
|
|
updatePartyRuntimeTargetVisibility(shaman.id, "feral-spirit-boss", true);
|
|
for (const now of [1_000, 2_500, 4_000, 5_500, 7_000]) advanceDungeonPartyCombat(now);
|
|
|
|
const feralSpirit = abilitiesForClass("shaman").find((ability) => ability.name === "Feral Spirit")!;
|
|
expect(useCombatStore.getState().summons.filter((summon) => (
|
|
summon.ownerActorId === shaman.id && summon.sourceAbilityId === feralSpirit.id
|
|
))).toHaveLength(2);
|
|
const beforeSummonAttack = useCombatStore.getState().mobs["feral-spirit-boss"].health;
|
|
useCombatStore.getState().tick(0.1, 10_000);
|
|
expect(useCombatStore.getState().mobs["feral-spirit-boss"].health).toBeLessThan(beforeSummonAttack);
|
|
});
|
|
|
|
it("lets an Assassination companion stack poisons and scale Envenom from them", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
|
const rogue = createConfiguredPartyMember({
|
|
name: "Assassination",
|
|
classId: "rogue",
|
|
specialization: "Assassination",
|
|
specializationId: "rogue:assassination",
|
|
talentTreeId: "rogue-assassination",
|
|
talentRanks: runtimePartyTalentRanksForLevel("rogue", "Assassination", 80),
|
|
role: "damage",
|
|
}, 80, 0, "assassination-poison-runtime");
|
|
usePartyStore.getState().initializeParty(
|
|
"assassination-poison-runtime",
|
|
80,
|
|
useCombatStore.getState().health,
|
|
"damage",
|
|
);
|
|
usePartyStore.setState({ members: [rogue] });
|
|
registerPartyRuntimePosition(rogue.id, [0, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
useCombatStore.getState().applyPrecombatPreparation(500);
|
|
expect(useCombatStore.getState().auras.filter((aura) => (
|
|
aura.targetId === rogue.id && aura.definition.utilityTags?.includes("weapon-enchant")
|
|
))).toHaveLength(2);
|
|
|
|
registerEngagedMob("poison-runtime-boss", 10_000_000);
|
|
const events: StructuredCombatEvent[] = [];
|
|
const stop = subscribeCombatEvents((event) => events.push(event));
|
|
for (let now = 1_000; now <= 60_000; now += 100) {
|
|
useCombatStore.getState().tick(0.1, now);
|
|
advanceDungeonPartyCombat(now);
|
|
}
|
|
stop();
|
|
|
|
expect(events.some((event) => (
|
|
event.sourceActorId === rogue.id && event.abilityId === "rogue-instant-poison"
|
|
))).toBe(true);
|
|
expect(events.some((event) => (
|
|
event.sourceActorId === rogue.id && event.abilityId === "rogue-deadly-poison"
|
|
))).toBe(true);
|
|
expect(events.some((event) => (
|
|
event.sourceActorId === rogue.id
|
|
&& event.abilityId === "wow335-rogue-envenom"
|
|
&& event.school === "nature"
|
|
&& event.effectiveAmount > 0
|
|
))).toBe(true);
|
|
expect(events.some((event) => (
|
|
event.sourceActorId === rogue.id && event.abilityId === "wow335-rogue-shiv"
|
|
))).toBe(true);
|
|
expect(events.some((event) => (
|
|
event.sourceActorId === rogue.id && event.abilityId === "wow335-rogue-rupture"
|
|
))).toBe(false);
|
|
expect(useCombatStore.getState().auras.find((aura) => (
|
|
aura.sourceId === rogue.id
|
|
&& aura.targetId === "poison-runtime-boss"
|
|
&& aura.definition.utilityTags?.includes("rogue-deadly-poison-dose")
|
|
))?.stacks).toBe(5);
|
|
});
|
|
|
|
it("gives companion Hunter and Warlock pets a hostile target when summoned from self", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
|
const hunter = createConfiguredPartyMember({
|
|
name: "Beast Mastery",
|
|
classId: "hunter",
|
|
specialization: "Beast Mastery",
|
|
specializationId: "hunter:beast-mastery",
|
|
talentTreeId: "hunter-beast-mastery",
|
|
role: "damage",
|
|
}, 80, 0, "pet-target-runtime");
|
|
const warlock = createConfiguredPartyMember({
|
|
name: "Demonology",
|
|
classId: "warlock",
|
|
specialization: "Demonology",
|
|
specializationId: "warlock:demonology",
|
|
talentTreeId: "warlock-demonology",
|
|
talentRanks: runtimePartyTalentRanksForLevel("warlock", "Demonology", 80),
|
|
role: "damage",
|
|
}, 80, 1, "pet-target-runtime");
|
|
usePartyStore.getState().initializeParty("pet-target-runtime", 80, useCombatStore.getState().health, "damage");
|
|
usePartyStore.setState({ members: [hunter, warlock] });
|
|
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [-14.5, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
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();
|
|
|
|
const pets = useCombatStore.getState().summons.filter((summon) => (
|
|
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([
|
|
expect.objectContaining({ abilityId: "wow335-hunter-call-pet" }),
|
|
expect.objectContaining({ abilityId: "wow335-warlock-summon-felguard" }),
|
|
]));
|
|
});
|
|
|
|
it("casts Power Word: Shield on an injured ally and grants absorb capacity", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
|
const tank = createConfiguredPartyMember({
|
|
name: "Tank",
|
|
classId: "warrior",
|
|
specialization: "Protection",
|
|
specializationId: "warrior:protection",
|
|
talentTreeId: "warrior-protection",
|
|
role: "tank",
|
|
}, 80, 0, "discipline-shield-runtime");
|
|
const healer = createConfiguredPartyMember({
|
|
name: "Discipline",
|
|
classId: "priest",
|
|
specialization: "Discipline",
|
|
specializationId: "priest:discipline",
|
|
talentTreeId: "priest-discipline",
|
|
role: "healer",
|
|
}, 80, 1, "discipline-shield-runtime");
|
|
const priestAbilities = abilitiesForClass("priest");
|
|
const shield = priestAbilities.find((ability) => ability.name === "Power Word: Shield")!;
|
|
usePartyStore.getState().initializeParty("discipline-shield-runtime", 80, useCombatStore.getState().health, "damage");
|
|
usePartyStore.setState({
|
|
members: [
|
|
{ ...tank, health: Math.round(tank.maxHealth * 0.5) },
|
|
{
|
|
...healer,
|
|
resource: 100_000,
|
|
maxResource: 100_000,
|
|
resourcePools: { mana: 100_000 },
|
|
cooldowns: Object.fromEntries(priestAbilities.filter((ability) => ability.id !== shield.id)
|
|
.map((ability) => [ability.id, 60_000])),
|
|
},
|
|
],
|
|
});
|
|
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
registerEngagedMob("discipline-shield-boss", 100_000);
|
|
|
|
advanceDungeonPartyCombat(1_000);
|
|
|
|
const applied = useCombatStore.getState().auras.find((aura) => (
|
|
aura.sourceId === healer.id
|
|
&& aura.targetId === tank.id
|
|
&& aura.definition.name.includes("Power Word: Shield")
|
|
));
|
|
expect(applied?.absorbs.reduce((total, absorb) => total + absorb.remaining, 0)).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("casts Inner Focus before an emergency direct heal and makes that heal free", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
|
const tank = createConfiguredPartyMember({
|
|
name: "Tank",
|
|
classId: "warrior",
|
|
specialization: "Protection",
|
|
specializationId: "warrior:protection",
|
|
talentTreeId: "warrior-protection",
|
|
role: "tank",
|
|
}, 80, 0, "inner-focus-runtime");
|
|
const healer = createConfiguredPartyMember({
|
|
name: "Discipline",
|
|
classId: "priest",
|
|
specialization: "Discipline",
|
|
specializationId: "priest:discipline",
|
|
talentTreeId: "priest-discipline",
|
|
talentRanks: runtimePartyTalentRanksForLevel("priest", "Discipline", 80),
|
|
role: "healer",
|
|
}, 80, 1, "inner-focus-runtime");
|
|
const priestAbilities = abilitiesForClass("priest");
|
|
const innerFocus = priestAbilities.find((ability) => ability.name === "Inner Focus")!;
|
|
const greaterHeal = priestAbilities.find((ability) => ability.name === "Greater Heal")!;
|
|
const startingHealth = Math.round(tank.maxHealth * 0.3);
|
|
usePartyStore.getState().initializeParty("inner-focus-runtime", 80, useCombatStore.getState().health, "damage");
|
|
usePartyStore.setState({
|
|
members: [
|
|
{ ...tank, health: startingHealth },
|
|
{
|
|
...healer,
|
|
resource: 0,
|
|
maxResource: 0,
|
|
resourcePools: { mana: 0 },
|
|
cooldowns: Object.fromEntries(priestAbilities
|
|
.filter((ability) => ![innerFocus.id, greaterHeal.id].includes(ability.id))
|
|
.map((ability) => [ability.id, 60_000])),
|
|
},
|
|
],
|
|
});
|
|
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
registerEngagedMob("inner-focus-boss", 100_000);
|
|
const events: CombatPresentationEvent[] = [];
|
|
const stop = subscribeCombatPresentation((message) => {
|
|
if (message.kind === "event" && message.event.sourceActorId === healer.id) events.push(message.event);
|
|
});
|
|
|
|
for (const now of [1_000, 2_500, 6_000]) advanceDungeonPartyCombat(now);
|
|
stop();
|
|
|
|
const releases = events.filter((event) => event.phase === "release").map((event) => event.name);
|
|
expect(releases).toEqual(expect.arrayContaining(["Inner Focus", "Greater Heal"]));
|
|
expect(releases.indexOf("Inner Focus")).toBeLessThan(releases.indexOf("Greater Heal"));
|
|
expect(usePartyStore.getState().members.find((member) => member.id === healer.id)?.resource).toBe(0);
|
|
expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.health)
|
|
.toBeGreaterThan(startingHealth);
|
|
});
|
|
|
|
it("applies a Shaman owner's Elemental Oath to party-caster healing crits", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80 });
|
|
useCombatStore.setState((state) => ({
|
|
talentRanks: { ...state.talentRanks, "talent-2049": 3 },
|
|
}));
|
|
const tank = createConfiguredPartyMember({
|
|
name: "Tank", classId: "warrior", specialization: "Protection",
|
|
specializationId: "warrior:protection", talentTreeId: "warrior-protection", role: "tank",
|
|
}, 80, 0, "elemental-oath-party-heal");
|
|
const healer = createConfiguredPartyMember({
|
|
name: "Healer", classId: "priest", specialization: "Holy",
|
|
specializationId: "priest:holy", talentTreeId: "priest-holy", role: "healer",
|
|
}, 80, 1, "elemental-oath-party-heal");
|
|
const greaterHeal = abilitiesForClass("priest").find((ability) => ability.name === "Greater Heal")!;
|
|
const startingHealth = Math.round(tank.maxHealth * 0.3);
|
|
usePartyStore.getState().initializeParty("elemental-oath-party-heal", 80,
|
|
useCombatStore.getState().health, "damage");
|
|
usePartyStore.setState({
|
|
members: [
|
|
{ ...tank, health: startingHealth, gearStats: EMPTY_ITEM_STATS },
|
|
{
|
|
...healer,
|
|
gearStats: EMPTY_ITEM_STATS,
|
|
resourceType: "mana",
|
|
resourceName: "Mana",
|
|
resource: 10_000,
|
|
maxResource: 10_000,
|
|
resourcePools: { mana: 10_000 },
|
|
cooldowns: Object.fromEntries(abilitiesForClass("priest")
|
|
.filter((ability) => ability.id !== greaterHeal.id)
|
|
.map((ability) => [ability.id, 60_000])),
|
|
},
|
|
],
|
|
});
|
|
registerPartyRuntimePosition(tank.id, [0, 0, 0]);
|
|
registerPartyRuntimePosition(healer.id, [0, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
registerEngagedMob("elemental-oath-party-heal-boss", 100_000);
|
|
|
|
const impactAt = partyHealCriticalAt(healer.id, greaterHeal.id, tank.id, 0.03);
|
|
const castStartAt = impactAt - greaterHeal.castTimeMs;
|
|
advanceDungeonPartyCombat(castStartAt);
|
|
const activeCast = usePartyStore.getState().members.find((member) => member.id === healer.id)?.activeCast;
|
|
expect(activeCast?.abilityId).toBe(greaterHeal.id);
|
|
expect(activeCast?.completesAt).toBe(impactAt);
|
|
advanceDungeonPartyCombat(impactAt);
|
|
|
|
expect(useCombatStore.getState().combatEvents).toContainEqual(expect.objectContaining({
|
|
kind: "healing",
|
|
sourceActorId: healer.id,
|
|
targetActorId: tank.id,
|
|
abilityId: greaterHeal.id,
|
|
critical: true,
|
|
}));
|
|
});
|
|
|
|
it("keeps companion Penance active for three healing pulses", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
|
const tank = createConfiguredPartyMember({
|
|
name: "Tank", classId: "warrior", specialization: "Protection",
|
|
specializationId: "warrior:protection", talentTreeId: "warrior-protection", role: "tank",
|
|
}, 80, 0, "penance-runtime");
|
|
const healer = createConfiguredPartyMember({
|
|
name: "Discipline", classId: "priest", specialization: "Discipline",
|
|
specializationId: "priest:discipline", talentTreeId: "priest-discipline",
|
|
talentRanks: runtimePartyTalentRanksForLevel("priest", "Discipline", 80), role: "healer",
|
|
}, 80, 1, "penance-runtime");
|
|
const priestAbilities = abilitiesForClass("priest");
|
|
const penance = priestAbilities.find((ability) => ability.name === "Penance")!;
|
|
const startingHealth = Math.round(tank.maxHealth * 0.2);
|
|
usePartyStore.getState().initializeParty("penance-runtime", 80, useCombatStore.getState().health, "damage");
|
|
usePartyStore.setState({ members: [
|
|
{ ...tank, health: startingHealth },
|
|
{ ...healer, cooldowns: Object.fromEntries(priestAbilities
|
|
.filter((ability) => ability.id !== penance.id)
|
|
.map((ability) => [ability.id, 60_000])) },
|
|
] });
|
|
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
registerEngagedMob("penance-boss", 100_000);
|
|
|
|
const visuals: CombatPresentationEvent[] = [];
|
|
const stopVisuals = subscribeCombatPresentation((message) => {
|
|
if (message.kind === "event" && message.event.sourceActorId === healer.id
|
|
&& message.event.abilityId === penance.id) visuals.push(message.event);
|
|
});
|
|
|
|
advanceDungeonPartyCombat(1_000);
|
|
expect(usePartyStore.getState().members.find((member) => member.id === healer.id)?.activeCast)
|
|
.toMatchObject({ mode: "channel", startedAt: 1_000, completesAt: 3_000 });
|
|
expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.health).toBe(startingHealth);
|
|
advanceDungeonPartyCombat(1_001);
|
|
const first = usePartyStore.getState().members.find((member) => member.id === tank.id)!.health;
|
|
expect(first).toBeGreaterThan(startingHealth);
|
|
advanceDungeonPartyCombat(1_999);
|
|
expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.health).toBe(first);
|
|
advanceDungeonPartyCombat(2_000);
|
|
expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.health).toBeGreaterThan(first);
|
|
advanceDungeonPartyCombat(3_000);
|
|
expect(usePartyStore.getState().members.find((member) => member.id === healer.id)?.activeCast).toBeNull();
|
|
expect(usePartyStore.getState().members.find((member) => member.id === healer.id)?.nextActionAt).toBe(3_250);
|
|
expect(useCombatStore.getState().effects.some((effect) => effect.sourceAbilityId === penance.id)).toBe(false);
|
|
stopVisuals();
|
|
expect(visuals[0]?.durationMs).toBe(2_000);
|
|
expect(visuals.map((event) => [event.phase, event.sourceSpellId])).toEqual([
|
|
["cast-start", 47540],
|
|
...Array.from({ length: 3 }, () => [["tick", 47750], ["impact", 47750]]).flat(),
|
|
["release", 47540],
|
|
]);
|
|
});
|
|
|
|
it("lets a resurrected tank rejoin independently instead of taunting or casting from its checkpoint", () => {
|
|
registerEngagedMob();
|
|
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
|
|
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
|
beginPartyRuntimeRejoin(tank.id, [1_000, 0, 0]);
|
|
const beforeHealth = useCombatStore.getState().mobs["pack:a"].health;
|
|
advanceDungeonPartyCombat(1_000);
|
|
expect(usePartyStore.getState().members.find((member) => member.id === tank.id))
|
|
.toMatchObject({ status: "advancing", activeCast: null, activeCastTargetId: null });
|
|
expect(usePartyStore.getState().memberTargetIds[tank.id]).toBeUndefined();
|
|
expect(useCombatStore.getState().mobs["pack:a"].forcedTarget?.actorId).not.toBe(tank.id);
|
|
expect(useCombatStore.getState().mobs["pack:a"].health).toBeLessThan(beforeHealth);
|
|
finishPartyRuntimeRejoin(tank.id);
|
|
registerPartyRuntimePosition(tank.id, [0, 0, 0]);
|
|
advanceDungeonPartyCombat(2_000);
|
|
expect(usePartyStore.getState().memberTargetIds[tank.id]).toBe("pack:a");
|
|
});
|
|
|
|
it("keeps returning teammates advancing during Stop, then resumes the party order when they arrive", () => {
|
|
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
|
|
usePartyStore.getState().setCommand("stop");
|
|
beginPartyRuntimeRejoin(tank.id, [0, 0, 0]);
|
|
advanceDungeonPartyCombat(1_000);
|
|
expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.status).toBe("advancing");
|
|
finishPartyRuntimeRejoin(tank.id);
|
|
advanceDungeonPartyCombat(2_000);
|
|
expect(usePartyStore.getState().command).toBe("stop");
|
|
expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.status).toBe("holding");
|
|
});
|
|
|
|
it("has the tank taunt the active mob while all damage allies attack it", () => {
|
|
registerEngagedMob();
|
|
for (const member of usePartyStore.getState().members) {
|
|
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
|
}
|
|
const beforeMobHealth = useCombatStore.getState().mobs["pack:a"].health;
|
|
const fullPlayerHealth = useCombatStore.getState().health;
|
|
const events: CombatPresentationEvent[] = [];
|
|
const unsubscribe = subscribeCombatPresentation((message) => {
|
|
if (message.kind === "event" && message.event.actorGroup === "party") events.push(message.event);
|
|
});
|
|
advanceDungeonPartyCombat(1_000);
|
|
|
|
const party = usePartyStore.getState();
|
|
const tank = party.members.find((member) => member.role === "tank")!;
|
|
expect(useCombatStore.getState().mobs["pack:a"]).toMatchObject({
|
|
targetActorId: tank.id,
|
|
forcedTarget: { actorId: tank.id, endsAt: 4_000 },
|
|
});
|
|
expect(party.memberTargetIds[tank.id]).toBe("pack:a");
|
|
expect(useCombatStore.getState().mobs["pack:a"].health).toBeLessThan(beforeMobHealth);
|
|
expect(party.members.filter((member) => member.nextActionAt > 1_000)).toHaveLength(4);
|
|
|
|
useCombatStore.getState().advanceMobCombat(3_000);
|
|
const woundedTank = usePartyStore.getState().members.find((member) => member.id === tank.id)!;
|
|
expect(woundedTank.health).toBeLessThan(woundedTank.maxHealth);
|
|
expect(useCombatStore.getState().health).toBe(fullPlayerHealth);
|
|
const instantReleases = events.filter((event) => event.phase === "release" && event.instantCast);
|
|
expect(instantReleases.length).toBeGreaterThan(0);
|
|
expect(instantReleases.every((event) => (
|
|
events.some((impact) => (
|
|
impact.phase === "impact"
|
|
&& impact.sourceActorId === event.sourceActorId
|
|
&& impact.abilityId === event.abilityId
|
|
&& impact.instantCast
|
|
))
|
|
))).toBe(true);
|
|
|
|
const actionRevisions = party.members.map((member) => member.nextActionAt);
|
|
advanceDungeonPartyCombat(1_100);
|
|
expect(usePartyStore.getState().members.map((member) => member.nextActionAt)).toEqual(actionRevisions);
|
|
unsubscribe();
|
|
});
|
|
|
|
it("keeps living companions in the fight while the player waits for resurrection", () => {
|
|
registerEngagedMob();
|
|
for (const member of usePartyStore.getState().members) {
|
|
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
|
}
|
|
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
|
const beforeMobHealth = useCombatStore.getState().mobs["pack:a"].health;
|
|
|
|
advanceDungeonPartyCombat(1_000);
|
|
|
|
expect(useCombatStore.getState().health).toBe(0);
|
|
expect(useCombatStore.getState().mobs["pack:a"].health).toBeLessThan(beforeMobHealth);
|
|
});
|
|
|
|
it("assigns the tank to a loose mob attacking the healer before one attacking DPS", () => {
|
|
const party = usePartyStore.getState();
|
|
const tank = party.members.find((member) => member.role === "tank")!;
|
|
const healerId = "__player__";
|
|
const damage = party.members.find((member) => member.role === "damage")!;
|
|
for (const member of party.members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
|
|
|
useCombatStore.getState().registerMob("loose:dps", { name: "DPS add", maxHealth: 200, xpReward: 0 });
|
|
useCombatStore.getState().registerMob("loose:healer", { name: "Healer add", maxHealth: 200, xpReward: 0 });
|
|
setMobPosition("loose:dps", [8, 0, 0]);
|
|
setMobPosition("loose:healer", [9, 0, 0]);
|
|
useCombatStore.getState().damageMob(
|
|
"loose:dps",
|
|
2,
|
|
100,
|
|
{ actorId: damage.id, role: "damage" },
|
|
);
|
|
useCombatStore.getState().damageMob(
|
|
"loose:healer",
|
|
2,
|
|
100,
|
|
{ actorId: healerId, role: "healer" },
|
|
);
|
|
for (const member of party.members) {
|
|
updatePartyRuntimeTargetVisibility(member.id, "loose:dps", true);
|
|
updatePartyRuntimeTargetVisibility(member.id, "loose:healer", true);
|
|
}
|
|
useCombatStore.getState().advanceMobCombat(200);
|
|
|
|
advanceDungeonPartyCombat(1_000);
|
|
|
|
expect(usePartyStore.getState().memberTargetIds[tank.id]).toBe("loose:healer");
|
|
expect(useCombatStore.getState().mobs["loose:healer"].forcedTarget).toMatchObject({
|
|
actorId: tank.id,
|
|
});
|
|
});
|
|
|
|
it("taunts a loose add off an ally while the rest of the party stays on the boss", () => {
|
|
const party = usePartyStore.getState();
|
|
const tank = party.members.find((member) => member.role === "tank")!;
|
|
const damage = party.members.find((member) => member.role === "damage")!;
|
|
for (const member of party.members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
|
|
|
useCombatStore.getState().registerMob("boss-with-add", {
|
|
name: "Boss with add",
|
|
boss: true,
|
|
maxHealth: 10_000,
|
|
xpReward: 0,
|
|
});
|
|
useCombatStore.getState().registerMob("boss-loose-add", {
|
|
name: "Loose add",
|
|
maxHealth: 1_000,
|
|
xpReward: 0,
|
|
});
|
|
setMobPosition("boss-with-add", [2, 0, 0]);
|
|
setMobPosition("boss-loose-add", [8, 0, 0]);
|
|
useCombatStore.getState().damageMob("boss-with-add", 1, 100, {
|
|
actorId: tank.id,
|
|
role: "tank",
|
|
});
|
|
useCombatStore.getState().damageMob("boss-loose-add", 1, 100, {
|
|
actorId: damage.id,
|
|
role: "damage",
|
|
});
|
|
useCombatStore.getState().selectMob("boss-with-add");
|
|
for (const member of party.members) {
|
|
updatePartyRuntimeTargetVisibility(member.id, "boss-with-add", true);
|
|
updatePartyRuntimeTargetVisibility(member.id, "boss-loose-add", true);
|
|
}
|
|
useCombatStore.getState().advanceMobCombat(200);
|
|
|
|
advanceDungeonPartyCombat(1_000);
|
|
|
|
const runtime = usePartyStore.getState();
|
|
expect(runtime.memberTargetIds[tank.id]).toBe("boss-loose-add");
|
|
expect(runtime.memberTargetIds[damage.id]).toBe("boss-with-add");
|
|
expect(useCombatStore.getState().mobs["boss-loose-add"].forcedTarget).toMatchObject({
|
|
actorId: tank.id,
|
|
endsAt: 4_000,
|
|
});
|
|
expect(runtime.members.find((member) => member.id === tank.id)?.tauntReadyAt).toBe(9_000);
|
|
});
|
|
|
|
it("shares an eight-second tank taunt cooldown across loose mobs", () => {
|
|
const party = usePartyStore.getState();
|
|
const tank = party.members.find((member) => member.role === "tank")!;
|
|
const damages = party.members.filter((member) => member.role === "damage");
|
|
for (const member of party.members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
|
for (const [index, id] of ["cooldown-a", "cooldown-b"].entries()) {
|
|
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 200, xpReward: 0 });
|
|
setMobPosition(id, [10 + index, 0, 0]);
|
|
useCombatStore.getState().damageMob(id, 2, 100, {
|
|
actorId: damages[index].id,
|
|
role: "damage",
|
|
});
|
|
}
|
|
useCombatStore.getState().advanceMobCombat(200);
|
|
for (const member of party.members) updatePartyRuntimeTargetVisibility(member.id, "cooldown-a", true);
|
|
|
|
advanceDungeonPartyCombat(1_000);
|
|
const firstTarget = usePartyStore.getState().memberTargetIds[tank.id];
|
|
const otherTarget = firstTarget === "cooldown-a" ? "cooldown-b" : "cooldown-a";
|
|
expect(useCombatStore.getState().mobs[firstTarget].forcedTarget?.actorId).toBe(tank.id);
|
|
|
|
for (const member of party.members) updatePartyRuntimeTargetVisibility(member.id, otherTarget, true);
|
|
advanceDungeonPartyCombat(4_100);
|
|
expect(usePartyStore.getState().memberTargetIds[tank.id]).toBe(otherTarget);
|
|
expect(useCombatStore.getState().mobs[otherTarget].forcedTarget).toBeNull();
|
|
expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.tauntReadyAt).toBe(9_000);
|
|
});
|
|
|
|
it("gives damage dealers a higher baseline hit than the tank", () => {
|
|
const [tank, damage] = usePartyStore.getState().members;
|
|
expect(partyAttackDamage(damage)).toBeGreaterThan(partyAttackDamage(tank));
|
|
const attackStat = baseClassFor(damage.classId) === "mage"
|
|
? { spellPower: 126 }
|
|
: baseClassFor(damage.classId) === "hunter"
|
|
? { rangedAttackPower: 126 }
|
|
: { attackPower: 126 };
|
|
expect(partyAttackDamage({
|
|
...damage,
|
|
gearStats: { ...EMPTY_ITEM_STATS, ...attackStat },
|
|
})).toBeGreaterThan(partyAttackDamage(damage));
|
|
});
|
|
|
|
it("lets a dedicated healer restore the lowest-health target on a cooldown", () => {
|
|
usePartyStore.getState().initializeParty("healer-runtime", 20, 100, "damage");
|
|
const members = usePartyStore.getState().members;
|
|
const healer = members.find((member) => member.role === "healer")!;
|
|
const tank = members.find((member) => member.role === "tank")!;
|
|
const wounded = members.map((member) => member.id === tank.id
|
|
? { ...member, health: Math.round(member.maxHealth * 0.25) }
|
|
: member);
|
|
|
|
const first = applyHealerSupport(wounded, 80, 100, 1_000);
|
|
expect(first.targetId).toBe(tank.id);
|
|
expect(first.members.find((member) => member.id === tank.id)!.health).toBeGreaterThan(
|
|
wounded.find((member) => member.id === tank.id)!.health,
|
|
);
|
|
expect(first.members.find((member) => member.id === healer.id)).toMatchObject({
|
|
status: "healing",
|
|
nextActionAt: 3_000,
|
|
});
|
|
const geared = applyHealerSupport(wounded.map((member) => member.id === healer.id
|
|
? { ...member, gearStats: { ...member.gearStats, spellPower: member.gearStats.spellPower + 1_000 } }
|
|
: member), 80, 100, 1_000);
|
|
expect(geared.effectiveHealing).toBeGreaterThan(first.effectiveHealing);
|
|
|
|
const coolingDown = applyHealerSupport(first.members, 50, 100, 2_000);
|
|
expect(coolingDown.playerHealing).toBe(0);
|
|
expect(coolingDown.targetId).toBeNull();
|
|
});
|
|
|
|
it("uses an unlocked party resurrection instead of treating a defeated player as a heal target", () => {
|
|
usePartyStore.getState().initializeParty("resurrection-runtime", 80, 100, "damage");
|
|
const support = applyHealerSupport(usePartyStore.getState().members, 0, 100, 1_000);
|
|
|
|
expect(support).toMatchObject({
|
|
action: "resurrection",
|
|
targetId: "__player__",
|
|
playerHealing: 0,
|
|
});
|
|
expect(support.playerResurrectionPercent).toBeGreaterThan(0);
|
|
expect(support.presentation?.name.toLowerCase()).toMatch(/rebirth|redemption|resurrect|ancestral|revive/);
|
|
});
|
|
|
|
it("lets the party raise the player in place while combat remains active", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
|
usePartyStore.getState().initializeParty("resurrection-runtime", 80, useCombatStore.getState().health, "damage");
|
|
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
|
expect(useCombatStore.getState().health).toBe(0);
|
|
|
|
advanceDungeonPartyCombat(1_000);
|
|
|
|
expect(useCombatStore.getState().health).toBeGreaterThan(0);
|
|
expect(useCombatStore.getState().feedback?.message).toContain("Resurrected");
|
|
});
|
|
|
|
it("classifies ranged classes and elemental shaman into a stand-off range band", () => {
|
|
const ranged = [
|
|
{ classId: "hunter", specialization: "Marksmanship", role: "damage" },
|
|
{ classId: "mage", specialization: "Frost", role: "damage" },
|
|
{ classId: "warlock", specialization: "Affliction", role: "damage" },
|
|
{ classId: "priest", specialization: "Shadow", role: "damage" },
|
|
{ classId: "shaman", specialization: "Elemental", role: "damage" },
|
|
] as const;
|
|
for (const member of ranged) {
|
|
expect(partyUsesRangedAttacks(member)).toBe(true);
|
|
expect(partyCombatRangeBand(member)).toEqual({
|
|
minimum: 10,
|
|
preferred: 14.5,
|
|
maximum: 18.1,
|
|
});
|
|
}
|
|
expect(partyUsesRangedAttacks({
|
|
classId: "shaman",
|
|
specialization: "Enhancement",
|
|
role: "damage",
|
|
})).toBe(false);
|
|
});
|
|
|
|
it("acquires nearby idle enemies only while commanded to attack", () => {
|
|
useCombatStore.getState().registerMob("idle-add", { name: "Idle add", maxHealth: 100, xpReward: 0 });
|
|
setMobPosition("idle-add", [4, 0, 0]);
|
|
updatePartyRuntimeAttackCorridor([0, 0, 0], [[10, 0, 0]], 0);
|
|
const combat = useCombatStore.getState();
|
|
|
|
expect(partyCommandTargetId("defend", combat.mobs, null, null, [0, 0, 0])).toBeNull();
|
|
expect(partyCommandTargetId("stop", combat.mobs, "idle-add", null, [0, 0, 0])).toBeNull();
|
|
expect(partyCommandTargetId("attack", combat.mobs, null, null, [0, 0, 0])).toBe("idle-add");
|
|
|
|
const before = combat.mobs["idle-add"].health;
|
|
usePartyStore.getState().setCommand("attack");
|
|
for (const member of usePartyStore.getState().members) {
|
|
updatePartyRuntimeTargetVisibility(member.id, "idle-add", true);
|
|
}
|
|
advanceDungeonPartyCombat(1_000);
|
|
expect(useCombatStore.getState().mobs["idle-add"].health).toBeLessThan(before);
|
|
});
|
|
|
|
it("ignores idle enemies on a side branch outside the approved Attack corridor", () => {
|
|
useCombatStore.getState().registerMob("road-add", { name: "Road add", maxHealth: 100, xpReward: 0 });
|
|
useCombatStore.getState().registerMob("side-add", { name: "Side add", maxHealth: 100, xpReward: 0 });
|
|
setMobPosition("road-add", [12, 0, 2]);
|
|
setMobPosition("side-add", [4, 0, 16]);
|
|
updatePartyRuntimeAttackCorridor([0, 0, 0], [[20, 0, 0]], 0);
|
|
|
|
expect(partyCommandTargetId(
|
|
"attack",
|
|
useCombatStore.getState().mobs,
|
|
null,
|
|
null,
|
|
[0, 0, 0],
|
|
)).toBe("road-add");
|
|
});
|
|
|
|
it("keeps a corridor-authorized add while the party switches to its pursuit route", () => {
|
|
useCombatStore.getState().registerMob("road-add", { name: "Road add", maxHealth: 100, xpReward: 0 });
|
|
setMobPosition("road-add", [12, 0, 2]);
|
|
updatePartyRuntimeAttackCorridor([0, 0, 0], [[20, 0, 0]], 0);
|
|
const mobs = useCombatStore.getState().mobs;
|
|
const acquired = partyCommandTargetId("attack", mobs, null, null, [0, 0, 0]);
|
|
clearPartyRuntimeRegistry();
|
|
setMobPosition("road-add", [12, 0, 2]);
|
|
|
|
expect(partyCommandTargetId("attack", mobs, null, acquired, [0, 0, 0])).toBe("road-add");
|
|
});
|
|
|
|
it("does not attack while stopped, even when a mob is engaged", () => {
|
|
registerEngagedMob();
|
|
const before = useCombatStore.getState().mobs["pack:a"].health;
|
|
usePartyStore.getState().setCommand("stop");
|
|
advanceDungeonPartyCombat(1_000);
|
|
|
|
expect(useCombatStore.getState().mobs["pack:a"].health).toBe(before);
|
|
expect(usePartyStore.getState().members.every((member) => member.status === "holding")).toBe(true);
|
|
});
|
|
|
|
it("waits to deal damage until party members are in their attack range", () => {
|
|
registerEngagedMob();
|
|
for (const member of usePartyStore.getState().members) {
|
|
registerPartyRuntimePosition(member.id, [80, 0, 0]);
|
|
}
|
|
const before = useCombatStore.getState().mobs["pack:a"].health;
|
|
advanceDungeonPartyCombat(1_000);
|
|
|
|
expect(useCombatStore.getState().mobs["pack:a"].health).toBe(before);
|
|
expect(usePartyStore.getState().members.every((member) => (
|
|
member.status === "advancing"
|
|
|| ((member.status === "attacking" || member.status === "guarding") && member.nextActionAt > 1_000)
|
|
))).toBe(true);
|
|
});
|
|
|
|
it("does not deal remote damage when a target has no world position", () => {
|
|
useCombatStore.getState().registerMob("missing", { name: "Missing", maxHealth: 100, xpReward: 0 });
|
|
useCombatStore.getState().damageMob("missing", 1, 100);
|
|
for (const member of usePartyStore.getState().members) {
|
|
updatePartyRuntimeTargetVisibility(member.id, "missing", true);
|
|
}
|
|
const before = useCombatStore.getState().mobs.missing.health;
|
|
advanceDungeonPartyCombat(1_000);
|
|
expect(useCombatStore.getState().mobs.missing.health).toBe(before);
|
|
});
|
|
});
|