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 { usePartyStore } from "./partyStore"; import { EMPTY_ITEM_STATS } from "./itemStats"; import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation"; 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("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)); 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); }); });