forked from phenom/healer-man
322 lines
14 KiB
TypeScript
322 lines
14 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { useCombatStore } from "./combatStore";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import {
|
|
clearPartyRuntimeRegistry,
|
|
registerPartyRuntimePosition,
|
|
updatePartyRuntimeAttackCorridor,
|
|
updatePartyRuntimeTargetVisibility,
|
|
} from "./partyRuntimeRegistry";
|
|
import {
|
|
activePartyTargetId,
|
|
advanceDungeonPartyCombat,
|
|
applyHealerSupport,
|
|
partyAttackDamage,
|
|
partyCombatRangeBand,
|
|
partyCommandTargetId,
|
|
partyUsesRangedAttacks,
|
|
resetPartyRuntimeClock,
|
|
} from "./partyRuntime";
|
|
import { usePartyStore } from "./partyStore";
|
|
import { EMPTY_ITEM_STATS } from "./itemStats";
|
|
|
|
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("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;
|
|
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 actionRevisions = party.members.map((member) => member.nextActionAt);
|
|
advanceDungeonPartyCombat(1_100);
|
|
expect(usePartyStore.getState().members.map((member) => member.nextActionAt)).toEqual(actionRevisions);
|
|
});
|
|
|
|
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));
|
|
expect(partyAttackDamage({
|
|
...damage,
|
|
gearStats: { ...EMPTY_ITEM_STATS, attackPower: 126 },
|
|
})).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")).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);
|
|
});
|
|
});
|