import { beforeEach, describe, expect, it } from "vitest"; import { abilitiesForClass } from "./abilityCatalog"; import { coaTreeEntryById } from "./coaLiveCatalog"; import { COA_BARBARIAN_BEHEADER_TALENT_ENTRY_ID, COA_BARBARIAN_DECAPITATE_TALENT_ENTRY_ID, coaBarbarianDecapitateTargetAllowed, coaBarbarianBeheaderEnergyRefund, } from "./coaBarbarianExecuteRuntime"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility, } from "./partyRuntimeRegistry"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import { useCombatStore } from "./combatStore"; const TARGET_ID = "coa-beheader-target"; const PLAYER_IDENTITY_RANKS = Object.freeze({ [`coa-entry-${COA_BARBARIAN_DECAPITATE_TALENT_ENTRY_ID}`]: 1, }); function decapitate() { const ability = abilitiesForClass("barbarian").find((candidate) => ( candidate.talentEntryId === COA_BARBARIAN_DECAPITATE_TALENT_ENTRY_ID )); if (!ability) throw new Error("The sourced CoA Barbarian Decapitate action is missing."); return ability; } function setTargetHealth(health: number, maxHealth: number): void { const mob = useCombatStore.getState().mobs[TARGET_ID]; if (!mob) throw new Error("The Beheader target fixture is missing."); useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [TARGET_ID]: { ...mob, maxHealth, health, dead: health <= 0 }, }, })); } function initializePlayer(beheaderRank: number): void { useCombatStore.getState().initializeCharacter({ classId: "barbarian", specializationId: "barbarian:brutality", role: "damage", level: 60, talentRanks: { ...PLAYER_IDENTITY_RANKS, [`coa-entry-${COA_BARBARIAN_BEHEADER_TALENT_ENTRY_ID}`]: beheaderRank, }, }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); useCombatStore.getState().registerMob(TARGET_ID, { name: "Beheader target", level: 60, maxHealth: 10_000, xpReward: 0, }); setMobPosition(TARGET_ID, [2, 0, 0]); setTargetHealth(1, 10_000); useCombatStore.getState().selectMob(TARGET_ID); } function initializeCompanion(beheaderRank: number) { useCombatStore.getState().initializeCharacter({ classId: "warrior", role: "damage", level: 60 }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); usePartyStore.getState().initializeParty("coa-beheader", 60, useCombatStore.getState().health, "damage", 1, "coa"); const ability = decapitate(); const base = createConfiguredPartyMember({ name: "Beheader companion", classId: "barbarian", specialization: "Brutality", specializationId: "barbarian:brutality", talentTreeId: "coa-barbarian-54", talentRanks: { ...PLAYER_IDENTITY_RANKS, [`coa-entry-${COA_BARBARIAN_BEHEADER_TALENT_ENTRY_ID}`]: beheaderRank, }, role: "damage", }, 60, 0, "coa-beheader-companion"); const maximumEnergy = base.resourceMaximums?.energy; if (!(maximumEnergy && maximumEnergy > 0)) throw new Error("The companion Energy maximum is missing."); const otherCooldowns = Object.fromEntries( abilitiesForClass("barbarian") .filter((candidate) => candidate.id !== ability.id) .map((candidate) => [candidate.id, 60_000]), ); const member = { ...base, cooldowns: otherCooldowns, resource: maximumEnergy, resourcePools: { ...base.resourcePools, energy: maximumEnergy }, nextActionAt: 0, }; usePartyStore.setState({ members: [member], command: "attack", activeMobId: TARGET_ID }); registerPartyRuntimePosition(member.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); useCombatStore.getState().commitCompanionActors([member]); useCombatStore.getState().registerMob(TARGET_ID, { name: "Beheader target", level: 60, maxHealth: 10_000, xpReward: 0, }); // Decapitate reaches five yards; this four-and-a-half-yard position is beyond // the Barbarian's 4.2-yard auto-attack range, isolating the ability's kill. setMobPosition(TARGET_ID, [4.5, 0, 0]); useCombatStore.getState().damageMob(TARGET_ID, 1, 100); setTargetHealth(1, 10_000); useCombatStore.getState().selectMob(TARGET_ID); updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true); useCombatStore.getState().synchronizeActors(); return { ability, member, maximumEnergy }; } describe("CoA Barbarian Beheader Energy refund", () => { beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); }); it("pins refund eligibility to Beheader node 6744 and a positive Decapitate kill", () => { const source = coaTreeEntryById(COA_BARBARIAN_BEHEADER_TALENT_ENTRY_ID); expect(source?.rankDescriptions?.[0]).toMatchObject({ spellId: 705222 }); expect(source?.description).toContain("Scoring a kill blow with Decapitate now refunds 100% of your maximum Energy."); const ability = decapitate(); const learned = { [`coa-entry-${COA_BARBARIAN_BEHEADER_TALENT_ENTRY_ID}`]: 1 }; expect(coaBarbarianBeheaderEnergyRefund(ability, learned, 137, { amount: 1, killed: true })).toBe(137); expect(coaBarbarianBeheaderEnergyRefund(ability, {}, 137, { amount: 1, killed: true })).toBe(0); expect(coaBarbarianBeheaderEnergyRefund(ability, learned, 137, { amount: 10, killed: false })).toBe(0); expect(coaBarbarianBeheaderEnergyRefund(ability, learned, 137, { amount: 0, killed: false })).toBe(0); expect(coaBarbarianBeheaderEnergyRefund(ability, learned, 0, { amount: 1, killed: true })).toBe(0); expect(coaBarbarianBeheaderEnergyRefund( { classId: "barbarian", talentEntryId: undefined }, learned, 137, { amount: 1, killed: true }, )).toBe(0); }); it.each([ { beheaderRank: 1 }, { beheaderRank: 0 }, ])("applies the player's paid Decapitate kill result with Beheader rank $beheaderRank", ({ beheaderRank }) => { initializePlayer(beheaderRank); const ability = decapitate(); const before = useCombatStore.getState(); const maximumEnergy = before.maxResourcePools.energy ?? before.maxResource; expect(before.resourcePools.energy).toBe(maximumEnergy); expect(coaBarbarianDecapitateTargetAllowed(ability, before.mobs[TARGET_ID]), JSON.stringify({ mob: before.mobs[TARGET_ID] && { health: before.mobs[TARGET_ID]!.health, maxHealth: before.mobs[TARGET_ID]!.maxHealth, dead: before.mobs[TARGET_ID]!.dead, }, ranks: before.talentRanks, decapitateKnown: before.abilities.some((candidate) => candidate.id === ability.id), })).toBe(true); const result = useCombatStore.getState().castAbility(ability.id, { now: 1_000, targetActorId: TARGET_ID }); expect(result, JSON.stringify(result)).toMatchObject({ ok: true, abilityId: ability.id }); expect(useCombatStore.getState().mobs[TARGET_ID]?.dead).toBe(true); const after = useCombatStore.getState(); const energyAfter = after.resourcePools.energy ?? after.resource; if (beheaderRank > 0) { expect(energyAfter).toBe(maximumEnergy); expect(after.combatEvents).toContainEqual(expect.objectContaining({ kind: "resource", sourceActorId: "__player__", targetActorId: "__player__", abilityId: ability.id, rawAmount: maximumEnergy, effectiveAmount: expect.any(Number), })); } else { expect(energyAfter).toBeLessThan(maximumEnergy); expect(after.combatEvents.some((event) => event.kind === "resource" && event.abilityId === ability.id)).toBe(false); } }); it.each([ { beheaderRank: 1 }, { beheaderRank: 0 }, ])("applies the companion's paid Decapitate kill result with Beheader rank $beheaderRank", ({ beheaderRank }) => { const { ability, maximumEnergy } = initializeCompanion(beheaderRank); const before = useCombatStore.getState().actors[usePartyStore.getState().members[0]!.id]; expect(before?.resources.energy).toMatchObject({ current: maximumEnergy, maximum: maximumEnergy }); advanceDungeonPartyCombat(1_000); const member = usePartyStore.getState().members[0]!; const actor = useCombatStore.getState().actors[member.id]; expect(useCombatStore.getState().mobs[TARGET_ID]?.dead, JSON.stringify({ mob: useCombatStore.getState().mobs[TARGET_ID] && { health: useCombatStore.getState().mobs[TARGET_ID]!.health, maxHealth: useCombatStore.getState().mobs[TARGET_ID]!.maxHealth, dead: useCombatStore.getState().mobs[TARGET_ID]!.dead, }, member: { targetId: usePartyStore.getState().memberTargetIds[member.id], energy: member.resourcePools.energy }, events: useCombatStore.getState().combatEvents.filter((event) => event.sourceActorId === member.id), })).toBe(true); expect(useCombatStore.getState().combatEvents).toContainEqual(expect.objectContaining({ kind: "damage", sourceActorId: member.id, targetActorId: TARGET_ID, abilityId: ability.id, })); expect(actor?.resources.energy?.maximum).toBe(maximumEnergy); if (beheaderRank > 0) { expect(actor?.resources.energy?.current).toBe(maximumEnergy); } else { expect(actor?.resources.energy?.current).toBeLessThan(maximumEnergy); } }); it("does not refund a nonlethal Decapitate, a rejected dead-target cast, or an unrelated killing ability", () => { initializePlayer(1); const ability = decapitate(); const maximumEnergy = useCombatStore.getState().maxResourcePools.energy!; setTargetHealth(3_400, 10_000); expect(useCombatStore.getState().castAbility(ability.id, { now: 1_000, targetActorId: TARGET_ID })) .toMatchObject({ ok: true }); expect(useCombatStore.getState().mobs[TARGET_ID]?.dead).toBe(false); expect(useCombatStore.getState().resourcePools.energy).toBeLessThan(maximumEnergy); expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "resource" && event.abilityId === ability.id)).toBe(false); setTargetHealth(0, 10_000); const beforeRejected = useCombatStore.getState(); expect(useCombatStore.getState().castAbility(ability.id, { now: 3_000, targetActorId: TARGET_ID })) .toMatchObject({ ok: false }); expect(useCombatStore.getState().resourcePools).toEqual(beforeRejected.resourcePools); const unrelated = abilitiesForClass("barbarian").find((candidate) => candidate.name === "Barbaric Strike"); if (!unrelated) throw new Error("Missing CoA Barbaric Strike for unrelated-kill regression."); setTargetHealth(1, 10_000); const beforeUnrelated = useCombatStore.getState(); expect(useCombatStore.getState().castAbility(unrelated.id, { now: 4_000, targetActorId: TARGET_ID })) .toMatchObject({ ok: true, abilityId: unrelated.id }); const afterUnrelated = useCombatStore.getState(); expect(afterUnrelated.mobs[TARGET_ID]?.dead).toBe(true); expect(afterUnrelated.resourcePools.energy).toBeLessThan(beforeUnrelated.resourcePools.energy!); expect(afterUnrelated.combatEvents.some((event) => event.kind === "resource" && event.abilityId === unrelated.id)).toBe(false); }); });