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

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