import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; import type { ActiveAura, AuraDefinition } from "./combatAuras"; /** August Vol'Jin Witch Doctor ingredient actions promoted for the first party. */ export type CoaWitchDoctorIngredientId = | "jungle-shrooms" | "frog-bones" | "bloodthistle" | "fish-oil"; interface IngredientSource { readonly id: CoaWitchDoctorIngredientId; readonly spellId: number; /** Null means taught by Mixologist rather than represented by its own talent node. */ readonly talentEntryId: number | null; readonly name: string; } const INGREDIENTS: readonly IngredientSource[] = [ { id: "jungle-shrooms", spellId: 801660, talentEntryId: 12645, name: "Jungle Shrooms" }, { id: "frog-bones", spellId: 801663, talentEntryId: 29741, name: "Frog Bones" }, { id: "bloodthistle", spellId: 801664, talentEntryId: 6021, name: "Bloodthistle" }, // Cauldron Brewer's DBC says Mixologist teaches this ingredient directly. { id: "fish-oil", spellId: 801662, talentEntryId: null, name: "Fish Oil" }, ]; export const COA_WITCH_DOCTOR_CAULDRON_BREWER_SPELL_ID = 92085; export const COA_WITCH_DOCTOR_POTION_TOSS_TALENT_ENTRY_ID = 12646; export const COA_WITCH_DOCTOR_POTION_TOSS_SPELL_ID = 801661; export const COA_WITCH_DOCTOR_MIXOLOGIST_TALENT_ENTRY_ID = 6645; export const COA_WITCH_DOCTOR_MIXOLOGIST_SPELL_ID = 561069; export const COA_WITCH_DOCTOR_SPLASH_POTION_TALENT_ENTRY_ID = 7128; export const COA_WITCH_DOCTOR_SPLASH_POTION_SPELL_ID = 802710; export const COA_WITCH_DOCTOR_FROG_BONES_POTION_SPELL_ID = 802971; const INGREDIENT_TAG_PREFIX = "coa-witch-doctor-active-ingredient:"; const ACTIVE_AURA_ID_PREFIX = "coa-witch-doctor:active-ingredient:"; /** Global Spell.dbc 802971: 8s absorb; base 100, 5 points/level from spell level 10, * plus 35% Spirit and 80% bonus healing. DBC maxLevel is 0, so it has no level cap. */ export const COA_WITCH_DOCTOR_FROG_BONES_POTION_SHIELD: Extract = { kind: "shield", coefficient: 0, basePoints: 100, dieSides: 1, pointsPerLevel: 5, sourceLevel: 10, spiritCoefficient: 0.35, bonusPowerCoefficient: 0.8, durationMs: 8_000, }; /** Global Spell.dbc 803699: reduced-effect Splash Potion version of Frog Bones. */ export const COA_WITCH_DOCTOR_FROG_BONES_SPLASH_SHIELD: Extract = { kind: "shield", coefficient: 0, basePoints: 25, dieSides: 1, pointsPerLevel: 1, sourceLevel: 15, durationMs: 8_000, scope: "party", center: "ability-target", radius: 10, maximumTargets: 8, }; function ingredientForAbility(ability: AbilityDefinition): IngredientSource | null { if (ability.classId !== "witch-doctor") return null; return INGREDIENTS.find((ingredient) => ingredient.spellId === ability.dbcSpellId && (ingredient.talentEntryId === null ? ability.talentEntryId === undefined : ability.talentEntryId === ingredient.talentEntryId)) ?? null; } /** Exact promoted identity for an Ingredient cast action, or null for every other ability. */ export function coaWitchDoctorIngredientIdForAbility( ability: AbilityDefinition, ): CoaWitchDoctorIngredientId | null { return ingredientForAbility(ability)?.id ?? null; } function isApplyAura(effect: AbilityEffect): effect is Extract { return effect.kind === "apply-aura"; } function ingredientStateAura(ingredient: IngredientSource): AuraDefinition { return { id: `${ACTIVE_AURA_ID_PREFIX}${ingredient.id}`, name: `Cauldron: ${ingredient.name}`, description: `Active Witch Doctor cauldron ingredient: ${ingredient.name}.`, disposition: "buff", hideFromAuraStrip: true, durationMs: null, maxStacks: 1, stackBehavior: "refresh", utilityTags: [`${INGREDIENT_TAG_PREFIX}${ingredient.id}`], }; } function ingredientStateEffect(ingredient: IngredientSource): Extract { return { kind: "apply-aura", aura: ingredientStateAura(ingredient), recipient: "caster", sourceEffectType: 6, sourceAuraType: 0, miscValue: 0, triggerSpellId: 0, }; } function activeIngredientIds( auras: readonly ActiveAura[], actorId: string, now: number, ): ReadonlySet { const active = new Set(); for (const ingredient of INGREDIENTS) { const tag = `${INGREDIENT_TAG_PREFIX}${ingredient.id}`; if (auras.some((aura) => aura.definition.id === `${ACTIVE_AURA_ID_PREFIX}${ingredient.id}` && aura.definition.utilityTags?.includes(tag) && aura.sourceId === actorId && aura.targetId === actorId && aura.stacks > 0 && (aura.expiresAt === null || aura.expiresAt > now))) { active.add(ingredient.id); } } return active; } /** Source-backed max: Cauldron Brewer allows one ingredient; Mixologist allows two. */ export function coaWitchDoctorIngredientCapacity( talentRanks: Readonly>, ): 1 | 2 { return (talentRanks[`coa-entry-${COA_WITCH_DOCTOR_MIXOLOGIST_TALENT_ENTRY_ID}`] ?? 0) > 0 ? 2 : 1; } /** * Check before paying for an Ingredient cast. The source establishes the capacity * and same-ingredient exclusivity; it does not define which active brew is replaced * when capacity is full, so this helper leaves a full cauldron untouched. */ export function coaWitchDoctorCanBrewIngredient( ability: AbilityDefinition, auras: readonly ActiveAura[], actorId: string, talentRanks: Readonly>, now: number, ): boolean { const ingredient = ingredientForAbility(ability); if (!ingredient) return true; const active = activeIngredientIds(auras, actorId, now); if (active.has(ingredient.id)) return true; return active.size < coaWitchDoctorIngredientCapacity(talentRanks); } function isFrogBonesPotionShield(effect: AbilityEffect): boolean { return effect.kind === "shield" && effect.basePoints === COA_WITCH_DOCTOR_FROG_BONES_POTION_SHIELD.basePoints && effect.pointsPerLevel === COA_WITCH_DOCTOR_FROG_BONES_POTION_SHIELD.pointsPerLevel && effect.sourceLevel === COA_WITCH_DOCTOR_FROG_BONES_POTION_SHIELD.sourceLevel && effect.spiritCoefficient === COA_WITCH_DOCTOR_FROG_BONES_POTION_SHIELD.spiritCoefficient && effect.bonusPowerCoefficient === COA_WITCH_DOCTOR_FROG_BONES_POTION_SHIELD.bonusPowerCoefficient && effect.durationMs === COA_WITCH_DOCTOR_FROG_BONES_POTION_SHIELD.durationMs; } export function isCoaWitchDoctorSplashPotion(ability: AbilityDefinition): boolean { return ability.classId === "witch-doctor" && ability.talentEntryId === COA_WITCH_DOCTOR_SPLASH_POTION_TALENT_ENTRY_ID && ability.dbcSpellId === COA_WITCH_DOCTOR_SPLASH_POTION_SPELL_ID; } function isSplashPotionBaseHeal(effect: AbilityEffect): effect is Extract { return effect.kind === "heal" && effect.basePoints === 54 && effect.dieSides === 3 && effect.pointsPerLevel === 1.3710700273513794 && effect.sourceLevel === 17 && effect.bonusPowerCoefficient === 0.224494 && effect.spiritCoefficient === 0.08 && effect.scope === "party" && effect.radius === 10 && effect.maximumTargets === 8; } /** * Project the selected source action before resolving its effects. Ingredient * state is added as an actor-owned aura effect, so the ordinary successful-cast * path applies it only after cast validation and payment have succeeded. */ export function coaWitchDoctorBrewingAbilityForCast( ability: AbilityDefinition, auras: readonly ActiveAura[], actorId: string, now: number, ): AbilityDefinition { if (ability.classId !== "witch-doctor") return ability; const ingredient = ingredientForAbility(ability); const isPotionToss = ability.talentEntryId === COA_WITCH_DOCTOR_POTION_TOSS_TALENT_ENTRY_ID && ability.dbcSpellId === COA_WITCH_DOCTOR_POTION_TOSS_SPELL_ID; const isSplash = isCoaWitchDoctorSplashPotion(ability); if (!ingredient && !isPotionToss && !isSplash) return ability; if (isSplash) { const baseHeal = ability.effects.find(isSplashPotionBaseHeal); if (!baseHeal) return { ...ability, effects: [] }; // 802710 expands Jungle, Fish Oil, Frog Bones, and SLS combo payloads into // one static effect list. Source text says riders depend on ingredients in // the Cauldron; until their individual rider semantics are implemented, // retain the verified area heal and project only Frog Bones' sourced 803699 // absorb when that ingredient marker is active. const effects: AbilityEffect[] = [baseHeal]; if (activeIngredientIds(auras, actorId, now).has("frog-bones")) { effects.push(COA_WITCH_DOCTOR_FROG_BONES_SPLASH_SHIELD); } return effects.length === ability.effects.length && effects.every((effect, index) => effect === ability.effects[index]) ? ability : { ...ability, effects }; } let effects = [...ability.effects]; if (ingredient?.id === "frog-bones") { // 801663 is the cauldron toggle. The generic importer mistakes Aura 4's // damage-taken modifier for a short shield; the actual Potion shield is the // separate 802971 payload and belongs on Potion Toss's selected ally. effects = effects.filter((effect) => effect.kind !== "shield" && !(isApplyAura(effect) && effect.sourceAuraType === 4)); effects = effects.map((effect) => { if (!isApplyAura(effect) || effect.sourceAuraType !== 23) return effect; return { ...effect, aura: { ...effect.aura, maxStacks: 1, stackBehavior: "refresh" }, }; }); } if (ingredient) { const stateAuraId = `${ACTIVE_AURA_ID_PREFIX}${ingredient.id}`; if (!effects.some((effect) => isApplyAura(effect) && effect.aura.id === stateAuraId)) { effects.push(ingredientStateEffect(ingredient)); } } if (isPotionToss && activeIngredientIds(auras, actorId, now).has("frog-bones") && !effects.some(isFrogBonesPotionShield)) { // 802971's shield effect uses Potion Toss's unit target, not the caster and // not the whole party. The DBC maxLevel is 0, so no maxScalingLevel is set. effects.push(COA_WITCH_DOCTOR_FROG_BONES_POTION_SHIELD); } return effects.length === ability.effects.length && effects.every((effect, index) => effect === ability.effects[index]) ? ability : { ...ability, effects }; } /** Active ingredient markers owned by this actor only; expired markers do not count. */ export function coaWitchDoctorActiveIngredients( auras: readonly ActiveAura[], actorId: string, now: number, ): ReadonlySet { return activeIngredientIds(auras, actorId, now); }