249 lines
11 KiB
TypeScript
249 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|