271 lines
10 KiB
TypeScript
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);
|
|
}
|