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 { 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[]): 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) })); }