Files
healer-man/src/game/coaBarbarianBeheaderRuntime.test.ts
T
2026-10-06 22:25:34 -04:00

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);
});
});