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

271 lines
10 KiB
TypeScript

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<AbilityEffect, { kind: "shield" }> = {
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<AbilityEffect, { kind: "shield" }> = {
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<AbilityEffect, { kind: "apply-aura" }> {
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<AbilityEffect, { kind: "apply-aura" }> {
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<CoaWitchDoctorIngredientId> {
const active = new Set<CoaWitchDoctorIngredientId>();
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<Record<string, number>>,
): 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<Record<string, number>>,
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<AbilityEffect, { kind: "heal" }> {
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<CoaWitchDoctorIngredientId> {
return activeIngredientIds(auras, actorId, now);
}