Files
healer-man/src/game/partyRuntime.test.ts
T
2026-09-20 11:23:13 -04:00

683 lines
32 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import {
clearPartyRuntimeRegistry,
registerPartyRuntimePosition,
updatePartyRuntimeAttackCorridor,
updatePartyRuntimeTargetVisibility,
beginPartyRuntimeRejoin,
finishPartyRuntimeRejoin,
} from "./partyRuntimeRegistry";
import {
activePartyTargetId,
advanceDungeonPartyCombat,
applyHealerSupport,
partyAttackDamage,
partyCombatRangeBand,
partyCommandTargetId,
partyUsesRangedAttacks,
resetPartyRuntimeClock,
} from "./partyRuntime";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
import { abilitiesForClass } from "./abilityCatalog";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
import { baseClassFor } from "../app/characterCatalog";
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);
}
}
describe("party combat runtime", () => {
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("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: {}, 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();
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("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));
for (let now = 1_000; now <= 30_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
}
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]));
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("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("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);
});
});