forked from phenom/healer-man
346 lines
15 KiB
TypeScript
346 lines
15 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import type { ClassId } from "../app/characterCatalog";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
import { abilitiesForClass, abilityById, type AbilityDefinition } from "./abilityCatalog";
|
|
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
|
|
import { equipmentAdjustedCombatAmount, useCombatStore } from "./combatStore";
|
|
import { applyAura } from "./combatAuras";
|
|
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import {
|
|
clearPartyRuntimeRegistry,
|
|
registerPartyRuntimePosition,
|
|
updatePartyRuntimeTargetVisibility,
|
|
} from "./partyRuntimeRegistry";
|
|
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
|
import {
|
|
coaWitchDoctorBrewingAbilityForCast,
|
|
COA_WITCH_DOCTOR_FROG_BONES_POTION_SHIELD,
|
|
} from "./coaWitchDoctorBrewingRuntime";
|
|
|
|
const PLAYER_AURA_ID = PLAYER_AGGRO_ID;
|
|
const FROG_BONES_MARKER_ID = "coa-witch-doctor:active-ingredient:frog-bones";
|
|
const JUNGLE_SHROOMS_MARKER_ID = "coa-witch-doctor:active-ingredient:jungle-shrooms";
|
|
|
|
function abilityByTalentEntry(talentEntryId: number): AbilityDefinition {
|
|
const ability = abilitiesForClass("witch-doctor").find(candidate => candidate.talentEntryId === talentEntryId);
|
|
if (!ability) throw new Error(`Missing Witch Doctor Talent action ${talentEntryId}`);
|
|
return ability;
|
|
}
|
|
|
|
function ranksFor(...entries: readonly number[]): Record<string, number> {
|
|
return Object.fromEntries(entries.map(entry => [`coa-entry-${entry}`, 1]));
|
|
}
|
|
|
|
function initializeWitchDoctorPlayer(entries: readonly number[] = []): void {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "witch-doctor",
|
|
specializationId: "witch-doctor:brewing",
|
|
role: "healer",
|
|
level: 60,
|
|
talentRanks: ranksFor(...entries),
|
|
});
|
|
// Test configurations are deliberately surgical and do not model a legal build allocation.
|
|
useCombatStore.setState(state => ({ talentRanks: { ...state.talentRanks, ...ranksFor(...entries) } }));
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
}
|
|
|
|
function initializePlayerAndParty(playerClassId: ClassId = "mage", playerRole: "damage" | "tank" = "damage") {
|
|
useCombatStore.getState().initializeCharacter({ classId: playerClassId, level: 60, role: playerRole });
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
usePartyStore.getState().initializeParty("coa-witch-doctor-brewing-integration", 60,
|
|
useCombatStore.getState().health, playerRole, 1, "coa");
|
|
}
|
|
|
|
function makeCompanion(
|
|
name: string,
|
|
role: "healer" | "tank" | "damage",
|
|
entries: readonly number[],
|
|
index: number,
|
|
) {
|
|
return createConfiguredPartyMember({
|
|
name,
|
|
classId: "witch-doctor",
|
|
specialization: "Brewing",
|
|
specializationId: "witch-doctor:brewing",
|
|
talentTreeId: "witch-doctor-brewing",
|
|
talentRanks: ranksFor(...entries),
|
|
role,
|
|
}, 60, index, "coa-witch-doctor-brewing-integration");
|
|
}
|
|
|
|
function installParty(members: ReturnType<typeof makeCompanion>[]): void {
|
|
usePartyStore.setState({ members });
|
|
members.forEach((member, index) => registerPartyRuntimePosition(member.id, [index + 1, 0, 0]));
|
|
useCombatStore.getState().synchronizeActors();
|
|
}
|
|
|
|
function activeMarker(casterId: string, ability: AbilityDefinition, markerId: string, now: number) {
|
|
const projected = coaWitchDoctorBrewingAbilityForCast(ability, [], casterId, now);
|
|
const marker = projected.effects.find(effect => effect.kind === "apply-aura" && effect.aura.id === markerId);
|
|
if (!marker || marker.kind !== "apply-aura") throw new Error(`Missing ${markerId} marker effect`);
|
|
return { projected, marker };
|
|
}
|
|
|
|
function setTargetHealth(memberId: string, health: number): void {
|
|
usePartyStore.setState(state => ({
|
|
members: state.members.map(member => member.id === memberId
|
|
? { ...member, health: Math.max(0, Math.min(member.maxHealth, health)) }
|
|
: member),
|
|
}));
|
|
}
|
|
|
|
function castPlayerAbility(ability: AbilityDefinition, targetActorId: string | null, now: number) {
|
|
return useCombatStore.getState().castAbility(ability.id, { targetActorId, now });
|
|
}
|
|
|
|
function clearRegistries(): void {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetPartyRuntimeClock();
|
|
}
|
|
|
|
describe("CoA Witch Doctor Brewing player and companion integration", () => {
|
|
beforeEach(() => {
|
|
clearRegistries();
|
|
});
|
|
|
|
it("casts Frog Bones as an actor-owned ingredient marker without granting its malformed generic caster shield", () => {
|
|
initializeWitchDoctorPlayer([29741, 12646]);
|
|
const frogBones = abilityByTalentEntry(29741);
|
|
|
|
const result = castPlayerAbility(frogBones, null, 1_000);
|
|
expect(result).toMatchObject({ ok: true, abilityId: frogBones.id });
|
|
|
|
const state = useCombatStore.getState();
|
|
const marker = state.auras.find(aura => aura.definition.id === FROG_BONES_MARKER_ID);
|
|
expect(marker).toMatchObject({
|
|
sourceId: PLAYER_AURA_ID,
|
|
targetId: PLAYER_AURA_ID,
|
|
expiresAt: null,
|
|
stacks: 1,
|
|
definition: { maxStacks: 1, stackBehavior: "refresh" },
|
|
});
|
|
expect(state.shield).toBe(0);
|
|
expect(state.auras.some(aura => aura.targetId === PLAYER_AURA_ID
|
|
&& (aura.absorbs.length > 0 || aura.definition.absorbs?.length))).toBe(false);
|
|
expect(state.combatEvents.some(event => event.kind === "absorb" && event.abilityId === frogBones.id)).toBe(false);
|
|
});
|
|
|
|
it("lets a Brewing companion toss Frog Bones' sourced shield onto the injured player for eight seconds", () => {
|
|
initializePlayerAndParty();
|
|
const healer = makeCompanion("Brewing companion", "healer", [12646, 29741], 0);
|
|
installParty([healer]);
|
|
|
|
const frogBones = abilityByTalentEntry(29741);
|
|
const { projected, marker } = activeMarker(healer.id, frogBones, FROG_BONES_MARKER_ID, 500);
|
|
expect(useCombatStore.getState().applyActorAura(healer.id, healer.id, projected, marker, 500)).toBe(true);
|
|
const member = usePartyStore.getState().members[0];
|
|
const potionToss = abilityByTalentEntry(12646);
|
|
usePartyStore.setState(state => ({
|
|
members: state.members.map(candidate => candidate.id === healer.id
|
|
? {
|
|
...candidate,
|
|
cooldowns: Object.fromEntries([
|
|
...abilitiesForClass("witch-doctor"),
|
|
abilityById("coa-13-loas-brew-49-801670")!,
|
|
].map(ability => [ability.id, ability.id === potionToss.id ? 0 : 60_000])),
|
|
globalCooldownEndsAt: 0,
|
|
nextActionAt: 0,
|
|
}
|
|
: candidate),
|
|
}));
|
|
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
|
|
setHealthForPlayer(0.5);
|
|
useCombatStore.getState().registerMob("brew-integration-mob", {
|
|
name: "Brewing integration target", maxHealth: 100_000, xpReward: 0,
|
|
});
|
|
setMobPosition("brew-integration-mob", [2, 0, 0]);
|
|
useCombatStore.getState().damageMob("brew-integration-mob", 1, 100);
|
|
usePartyStore.setState({ command: "attack", activeMobId: "brew-integration-mob" });
|
|
updatePartyRuntimeTargetVisibility(healer.id, "brew-integration-mob", true);
|
|
useCombatStore.getState().synchronizeActors();
|
|
|
|
const events: StructuredCombatEvent[] = [];
|
|
const stop = subscribeCombatEvents(event => events.push(event));
|
|
try {
|
|
advanceDungeonPartyCombat(1_000);
|
|
} finally {
|
|
stop();
|
|
}
|
|
const absorb = events.find(event => event.kind === "absorb" && event.abilityId === potionToss.id);
|
|
const expectedAmount = equipmentAdjustedCombatAmount(
|
|
COA_WITCH_DOCTOR_FROG_BONES_POTION_SHIELD,
|
|
member.level,
|
|
member.gearStats,
|
|
"witch-doctor",
|
|
potionToss,
|
|
0,
|
|
undefined,
|
|
member.raceId,
|
|
member.gender,
|
|
member.statRuleset ?? "wow",
|
|
member.secondaryClassId,
|
|
);
|
|
expect(events).toEqual(expect.arrayContaining([expect.objectContaining({
|
|
kind: "healing",
|
|
sourceActorId: healer.id,
|
|
targetActorId: PLAYER_AURA_ID,
|
|
abilityId: potionToss.id,
|
|
effectiveAmount: expect.any(Number),
|
|
})]));
|
|
expect(absorb).toMatchObject({
|
|
sourceActorId: healer.id,
|
|
targetActorId: PLAYER_AURA_ID,
|
|
rawAmount: expectedAmount,
|
|
effectiveAmount: expectedAmount,
|
|
absorbed: expectedAmount,
|
|
});
|
|
const shieldAura = useCombatStore.getState().auras.find(aura => (
|
|
aura.sourceId === healer.id && aura.targetId === PLAYER_AURA_ID
|
|
&& aura.definition.id === `shield:${healer.id}:${potionToss.id}`
|
|
));
|
|
expect(shieldAura).toMatchObject({ appliedAt: 1_000, expiresAt: 9_000 });
|
|
expect(shieldAura?.definition.absorbs?.[0]?.amount).toBe(expectedAmount);
|
|
expect(useCombatStore.getState().health).toBeGreaterThan(Math.round(useCombatStore.getState().maxHealth / 2));
|
|
});
|
|
|
|
it("executes a companion Frog Bones cast through the party runtime and stores its marker on that caster", () => {
|
|
initializePlayerAndParty();
|
|
const healer = makeCompanion("Brewing ingredient caster", "healer", [29741], 0);
|
|
installParty([healer]);
|
|
const frogBones = abilityByTalentEntry(29741);
|
|
const completedAt = 2_000;
|
|
// Ingredient actions have no NPC auto-selection policy. Seed an otherwise
|
|
// valid in-flight companion cast so the normal party cast-completion path
|
|
// resolves Frog Bones without inventing an AI priority.
|
|
usePartyStore.setState(state => ({
|
|
members: state.members.map(candidate => candidate.id === healer.id
|
|
? {
|
|
...candidate,
|
|
activeCast: {
|
|
abilityId: frogBones.id,
|
|
school: "nature",
|
|
mode: "cast",
|
|
startedAt: completedAt - 1,
|
|
completesAt: completedAt,
|
|
originalDurationMs: 1,
|
|
pushbackHits: 0,
|
|
origin: [1, 0, 0] as const,
|
|
targetId: healer.id,
|
|
},
|
|
activeCastTargetId: healer.id,
|
|
nextActionAt: completedAt,
|
|
}
|
|
: candidate),
|
|
}));
|
|
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
|
|
|
|
const events: StructuredCombatEvent[] = [];
|
|
const stop = subscribeCombatEvents(event => events.push(event));
|
|
try {
|
|
advanceDungeonPartyCombat(completedAt);
|
|
} finally {
|
|
stop();
|
|
}
|
|
|
|
const state = useCombatStore.getState();
|
|
const marker = state.auras.find(aura => aura.definition.id === FROG_BONES_MARKER_ID);
|
|
expect(marker).toMatchObject({
|
|
sourceId: healer.id,
|
|
targetId: healer.id,
|
|
expiresAt: null,
|
|
stacks: 1,
|
|
definition: { maxStacks: 1, stackBehavior: "refresh" },
|
|
});
|
|
expect(state.auras.some(aura => aura.sourceId === healer.id && aura.targetId === healer.id
|
|
&& (aura.absorbs.length > 0 || aura.definition.absorbs?.length))).toBe(false);
|
|
expect(state.actors[healer.id]?.absorb).toBe(0);
|
|
expect(events.some(event => event.kind === "absorb" && event.sourceActorId === healer.id
|
|
&& event.abilityId === frogBones.id)).toBe(false);
|
|
expect(usePartyStore.getState().members.find(candidate => candidate.id === healer.id)!.activeCast).toBeNull();
|
|
});
|
|
|
|
it("does not let another actor's Frog Bones marker enable the player's Potion Toss rider", () => {
|
|
initializeWitchDoctorPlayer([12646]);
|
|
const otherBrewer = makeCompanion("Other brewer", "damage", [29741], 0);
|
|
const recipient = createConfiguredPartyMember({
|
|
name: "Injured ally",
|
|
classId: "warrior",
|
|
specialization: "Protection",
|
|
specializationId: "warrior:protection",
|
|
talentTreeId: "warrior-protection",
|
|
role: "tank",
|
|
}, 60, 1, "coa-witch-doctor-brewing-integration");
|
|
installParty([otherBrewer, recipient]);
|
|
setTargetHealth(recipient.id, Math.round(recipient.maxHealth / 2));
|
|
const frogBones = abilityByTalentEntry(29741);
|
|
const potionToss = abilityByTalentEntry(12646);
|
|
const firstEvents: StructuredCombatEvent[] = [];
|
|
const stopFirst = subscribeCombatEvents(event => firstEvents.push(event));
|
|
try {
|
|
expect(castPlayerAbility(potionToss, recipient.id, 1_000).ok).toBe(true);
|
|
} finally {
|
|
stopFirst();
|
|
}
|
|
expect(firstEvents.some(event => event.kind === "absorb" && event.abilityId === potionToss.id)).toBe(false);
|
|
expect(useCombatStore.getState().auras.some(aura => aura.sourceId === PLAYER_AURA_ID
|
|
&& aura.targetId === recipient.id && aura.definition.id.includes("shield"))).toBe(false);
|
|
|
|
setTargetHealth(recipient.id, Math.round(recipient.maxHealth / 2));
|
|
const { projected, marker } = activeMarker(otherBrewer.id, frogBones, FROG_BONES_MARKER_ID, 10_000);
|
|
expect(useCombatStore.getState().applyActorAura(otherBrewer.id, otherBrewer.id, projected, marker, 10_000)).toBe(true);
|
|
const foreignMarkerEvents: StructuredCombatEvent[] = [];
|
|
const stopSecond = subscribeCombatEvents(event => foreignMarkerEvents.push(event));
|
|
try {
|
|
expect(castPlayerAbility(potionToss, recipient.id, 20_000).ok).toBe(true);
|
|
} finally {
|
|
stopSecond();
|
|
}
|
|
expect(foreignMarkerEvents.some(event => event.kind === "absorb" && event.abilityId === potionToss.id)).toBe(false);
|
|
expect(useCombatStore.getState().auras.some(aura => aura.sourceId === PLAYER_AURA_ID
|
|
&& aura.targetId === recipient.id && aura.definition.id.includes("shield"))).toBe(false);
|
|
});
|
|
|
|
it("rejects a second ingredient at capacity before cost, cooldown, or aura state changes, while Mixologist allows two", () => {
|
|
const jungleShrooms = abilityByTalentEntry(12645);
|
|
const frogBones = abilityByTalentEntry(29741);
|
|
|
|
initializeWitchDoctorPlayer([12645, 29741, 12646]);
|
|
expect(castPlayerAbility(jungleShrooms, null, 1_000).ok).toBe(true);
|
|
const fullBefore = useCombatStore.getState();
|
|
const resourceBefore = fullBefore.resource;
|
|
const resourcePoolsBefore = fullBefore.resourcePools;
|
|
const cooldownsBefore = fullBefore.cooldowns;
|
|
const globalCooldownBefore = fullBefore.globalCooldownEndsAt;
|
|
const aurasBefore = fullBefore.auras;
|
|
expect(castPlayerAbility(frogBones, null, 2_600)).toMatchObject({ ok: false, reason: "condition-not-met" });
|
|
const fullAfter = useCombatStore.getState();
|
|
expect(fullAfter.resource).toBe(resourceBefore);
|
|
expect(fullAfter.resourcePools).toEqual(resourcePoolsBefore);
|
|
expect(fullAfter.cooldowns).toEqual(cooldownsBefore);
|
|
expect(fullAfter.globalCooldownEndsAt).toBe(globalCooldownBefore);
|
|
expect(fullAfter.auras).toEqual(aurasBefore);
|
|
expect(fullAfter.auras.some(aura => aura.definition.id === JUNGLE_SHROOMS_MARKER_ID
|
|
&& aura.sourceId === PLAYER_AURA_ID && aura.targetId === PLAYER_AURA_ID)).toBe(true);
|
|
expect(fullAfter.auras.some(aura => aura.definition.id === FROG_BONES_MARKER_ID)).toBe(false);
|
|
|
|
initializeWitchDoctorPlayer([12645, 29741, 12646, 6645]);
|
|
expect(castPlayerAbility(jungleShrooms, null, 1_000).ok).toBe(true);
|
|
expect(castPlayerAbility(frogBones, null, 2_600).ok).toBe(true);
|
|
const activeIngredientMarkers = useCombatStore.getState().auras.filter(aura => (
|
|
aura.sourceId === PLAYER_AURA_ID && aura.targetId === PLAYER_AURA_ID
|
|
&& [JUNGLE_SHROOMS_MARKER_ID, FROG_BONES_MARKER_ID].includes(aura.definition.id)
|
|
&& (aura.expiresAt === null || aura.expiresAt > 2_600)
|
|
));
|
|
expect(activeIngredientMarkers.map(aura => aura.definition.id).sort()).toEqual([
|
|
FROG_BONES_MARKER_ID,
|
|
JUNGLE_SHROOMS_MARKER_ID,
|
|
]);
|
|
});
|
|
});
|
|
|
|
function setHealthForPlayer(fraction: number): void {
|
|
useCombatStore.setState(state => ({ health: Math.round(state.maxHealth * fraction) }));
|
|
}
|