371 lines
18 KiB
TypeScript
371 lines
18 KiB
TypeScript
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
|
import { romPriestKnightCastEffects } from "./romPriestKnightOverrides";
|
|
import { romPriestWarlockCastEffects } from "./romPriestWarlockOverrides";
|
|
import { romMageWarlockCastEffects } from "./romMageWarlockOverrides";
|
|
import { romScoutRogueCastEffects } from "./romScoutRogueOverrides";
|
|
import { romDruidCastEffects } from "./romDruidOverrides";
|
|
import { romChampionCastEffects } from "./romChampionOverrides";
|
|
import { romWardenCastEffects } from "./romWardenOverrides";
|
|
import { romWarlockCastEffects } from "./romWarlockOverrides";
|
|
import { romKnightCastEffects } from "./romKnightOverrides";
|
|
import { romPriestWarriorCondensedRagePhysicalDamageMultiplier } from "./romPriestChampionOverrides";
|
|
import { withoutCurrentCoaHolySealPeriodicDamage } from "./romWarriorCurrentCoaMechanics";
|
|
import { romCoaWarriorRogueProbingAttackDpsCoefficient } from "./romWarriorRogueExhaustion";
|
|
import { isRomDruidRoguePair, ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG } from "./romDruidRogueCurrentCoa";
|
|
|
|
const ROGUE_BLEED_ABILITIES = new Set([
|
|
"rom-rogue-primary-1", // Low Blow's conditional Grievous Wound
|
|
"rom-rogue-primary-4", // Blind Spot
|
|
"rom-rogue-primary-9", // Sneak Attack
|
|
"rom-rogue-general-0", // Shadowstab
|
|
]);
|
|
|
|
export interface RomCastMechanics {
|
|
readonly sealStacks: number;
|
|
readonly bleedCount: number;
|
|
readonly charged: boolean;
|
|
readonly mainHandDagger: boolean;
|
|
readonly mainHandTwoHandHammer?: boolean;
|
|
readonly casterMaxHealth: number;
|
|
readonly targetHealthPercent: number;
|
|
readonly friendlyTargetHealthPercent?: number;
|
|
readonly naturePower?: number;
|
|
readonly passiveTags?: readonly string[];
|
|
readonly learnedPassiveSpellIds?: readonly number[];
|
|
/** True when the ability's default damage school resolves to Physical. */
|
|
readonly defaultDamageIsPhysical?: boolean;
|
|
readonly selectedTargetRelationship?: "friendly" | "hostile";
|
|
readonly eruptionProcRoll?: number;
|
|
readonly targetTags?: readonly string[];
|
|
readonly slashBleed?: boolean;
|
|
}
|
|
|
|
export interface RomWarriorAbilityAdjustment {
|
|
readonly rageCostReduction: number;
|
|
readonly cooldownReductionMs: number;
|
|
}
|
|
|
|
/** Source periodic values for self-sacrifice toggle skills are percentages of HP. */
|
|
export function romSelfDrainPercent(spellId: number): number {
|
|
if (spellId === 490304) return 6; // Priest Holy Candle
|
|
if (spellId === 490434) return 3; // Scout Blood Arrow
|
|
return 0;
|
|
}
|
|
|
|
export function romSingleTargetControl(ability: AbilityDefinition | null): boolean {
|
|
return ability?.source === "runewaker"
|
|
&& [490454, 491172].includes(ability.dbcSpellId);
|
|
}
|
|
|
|
export function romAbilityNeedsShield(ability: AbilityDefinition): boolean {
|
|
return ability.source === "runewaker"
|
|
&& [490175, 490186, 490192, 490468, 491333, 491484].includes(ability.dbcSpellId);
|
|
}
|
|
|
|
export function romAbilityNeedsTwoHand(ability: AbilityDefinition): boolean {
|
|
return ability.source === "runewaker" && [490121, 491142].includes(ability.dbcSpellId);
|
|
}
|
|
|
|
export function romAbilityNeedsRangedWeapon(ability: AbilityDefinition): boolean {
|
|
return ability.source === "runewaker"
|
|
&& [
|
|
490420, // Scout Joint Blow uses a ranged weapon.
|
|
490423, // Scout Shot uses a ranged weapon.
|
|
490438, // Scout Neck Strike requires a ranged weapon.
|
|
491128, // Scout Wind Arrows uses a ranged weapon.
|
|
491292, // Scout Vampire Arrows uses a ranged weapon.
|
|
491452,
|
|
491616,
|
|
492589, // Scout Autoshot uses a ranged weapon.
|
|
494021,
|
|
494037,
|
|
494039,
|
|
494040,
|
|
494042,
|
|
494629,
|
|
].includes(ability.dbcSpellId);
|
|
}
|
|
|
|
export function romAbilityRequiredTargetStateTag(ability: AbilityDefinition): string | null {
|
|
if (ability.source !== "runewaker") return null;
|
|
if (ability.dbcSpellId === 494321 && isRomDruidRoguePair(ability)) return ROM_DRUID_ROGUE_CORROSIVE_POISON_TAG;
|
|
if (ability.dbcSpellId === 492617) return "rom-weakened";
|
|
if (ability.dbcSpellId === 491136 || ability.dbcSpellId === 492619) return "rom-vulnerable";
|
|
return null;
|
|
}
|
|
|
|
/** Source-described Warrior elite passives that reduce a skill's Rage cost and cooldown. */
|
|
export function romWarriorAbilityAdjustment(
|
|
ability: AbilityDefinition,
|
|
learnedPassiveSpellIds: readonly number[],
|
|
): RomWarriorAbilityAdjustment {
|
|
if (ability.source !== "runewaker" || ability.classId !== "rom-warrior") {
|
|
return { rageCostReduction: 0, cooldownReductionMs: 0 };
|
|
}
|
|
const passiveIds = new Set(learnedPassiveSpellIds);
|
|
const rules: Readonly<Record<number, {
|
|
readonly abilitySpellId: number;
|
|
readonly rageCostReduction: number;
|
|
readonly cooldownReductionMs: number;
|
|
}>> = {
|
|
491458: { abilitySpellId: 490121, rageCostReduction: 10, cooldownReductionMs: 20_000 }, // Moon Cleave Perfection
|
|
493248: { abilitySpellId: 490133, rageCostReduction: 20, cooldownReductionMs: 10_000 }, // Exhaustion
|
|
499491: { abilitySpellId: 490133, rageCostReduction: 25, cooldownReductionMs: 15_000 }, // Whirlwind Battle Technique
|
|
499827: { abilitySpellId: 490053, rageCostReduction: 10, cooldownReductionMs: 0 }, // Speed Slash
|
|
};
|
|
const applicable = Object.entries(rules)
|
|
.filter(([passiveSpellId, rule]) => passiveIds.has(Number(passiveSpellId))
|
|
&& ability.dbcSpellId === rule.abilitySpellId)
|
|
.map(([, rule]) => rule);
|
|
return {
|
|
rageCostReduction: applicable.reduce((sum, rule) => sum + rule.rageCostReduction, 0),
|
|
cooldownReductionMs: applicable.reduce((sum, rule) => sum + rule.cooldownReductionMs, 0),
|
|
};
|
|
}
|
|
|
|
/** Heroic Surprise Attack makes Surprise Attack generate five times its usual threat. */
|
|
export function romWarriorThreatMultiplier(
|
|
ability: AbilityDefinition,
|
|
learnedPassiveSpellIds: readonly number[],
|
|
): number {
|
|
return ability.source === "runewaker"
|
|
&& ability.classId === "rom-warrior"
|
|
&& ability.dbcSpellId === 490129
|
|
&& learnedPassiveSpellIds.includes(492661)
|
|
? 5
|
|
: 1;
|
|
}
|
|
|
|
/** Some Warrior source components mark physical weapon attacks as an untyped spell. */
|
|
export function romWarriorPhysicalWeaponTooltip(ability: AbilityDefinition): boolean {
|
|
return ability.source === "runewaker"
|
|
&& ability.classId === "rom-warrior"
|
|
&& /\b(?:physical DPS|main hand weapon DPS)\b/i.test(ability.description)
|
|
&& !/\b(?:Dark|Wind|Fire|Water|Light|Earth) Damage\b/i.test(ability.description);
|
|
}
|
|
|
|
export function romTargetBleedCount(
|
|
effects: readonly { readonly kind: string; readonly sourceAbilityId: string; readonly sourceActorId: string; readonly targetId: string | null; readonly remainingTicks: number }[],
|
|
sourceActorId: string,
|
|
targetId: string | null,
|
|
): number {
|
|
if (!targetId) return 0;
|
|
return new Set(effects.filter((effect) => effect.kind === "dot"
|
|
&& effect.remainingTicks > 0
|
|
&& effect.sourceActorId === sourceActorId
|
|
&& effect.targetId === targetId
|
|
&& ROGUE_BLEED_ABILITIES.has(effect.sourceAbilityId))
|
|
.map((effect) => effect.sourceAbilityId)).size;
|
|
}
|
|
|
|
export function romTargetHasSlashBleed(
|
|
effects: readonly {
|
|
readonly kind: string;
|
|
readonly sourceAbilityId: string;
|
|
readonly targetId: string | null;
|
|
readonly remainingTicks: number;
|
|
}[],
|
|
targetId: string | null,
|
|
): boolean {
|
|
return Boolean(targetId && effects.some((effect) => effect.kind === "dot"
|
|
&& effect.remainingTicks > 0
|
|
&& effect.targetId === targetId
|
|
&& effect.sourceAbilityId === "rom-warrior-general-0"));
|
|
}
|
|
|
|
function scaleOutput(effect: AbilityEffect, factor: number): AbilityEffect {
|
|
if (effect.kind !== "damage" && effect.kind !== "dot") return effect;
|
|
return {
|
|
...effect,
|
|
coefficient: effect.coefficient * factor,
|
|
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * factor }),
|
|
...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * factor }),
|
|
...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * factor }),
|
|
...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * factor }),
|
|
};
|
|
}
|
|
|
|
/** Select source-conditional RoM payloads once for both player and companion casts. */
|
|
export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMechanics): readonly AbilityEffect[] {
|
|
if (ability.source !== "runewaker") return ability.effects;
|
|
const priestKnightEffects = romPriestKnightCastEffects(
|
|
ability,
|
|
ability.effects,
|
|
context.passiveTags ?? [],
|
|
context.casterMaxHealth,
|
|
context.targetTags ?? [],
|
|
context.friendlyTargetHealthPercent ?? 1,
|
|
context.learnedPassiveSpellIds ?? [],
|
|
);
|
|
const priestWarlockEffects = romPriestWarlockCastEffects(
|
|
ability,
|
|
priestKnightEffects,
|
|
context.friendlyTargetHealthPercent ?? 1,
|
|
context.passiveTags ?? [],
|
|
);
|
|
const knightEffects = romKnightCastEffects(
|
|
ability,
|
|
priestWarlockEffects,
|
|
context.passiveTags ?? [],
|
|
context.sealStacks,
|
|
);
|
|
const mageWarlockEffects = romMageWarlockCastEffects(
|
|
ability,
|
|
knightEffects,
|
|
new Set(context.passiveTags ?? []),
|
|
context.eruptionProcRoll,
|
|
);
|
|
const scoutRogueEffects = romScoutRogueCastEffects(ability, mageWarlockEffects, context.passiveTags ?? [], context.targetTags ?? []);
|
|
const druidEffects = romDruidCastEffects(ability, scoutRogueEffects, context.naturePower ?? 0, context.passiveTags ?? []);
|
|
const championEffects = romChampionCastEffects(
|
|
ability, druidEffects, context.targetHealthPercent, context.casterMaxHealth,
|
|
);
|
|
const wardenEffects = romWardenCastEffects(
|
|
ability, championEffects, context.naturePower ?? 0, context.passiveTags ?? [],
|
|
);
|
|
const effects = romWarlockCastEffects(
|
|
ability,
|
|
wardenEffects,
|
|
context.targetTags ?? [],
|
|
context.learnedPassiveSpellIds ?? [],
|
|
context.targetHealthPercent,
|
|
context.selectedTargetRelationship ?? "hostile",
|
|
);
|
|
const coaEffects = withoutCurrentCoaHolySealPeriodicDamage(ability.dbcSpellId, effects);
|
|
const warriorPassives = new Set(context.learnedPassiveSpellIds ?? []);
|
|
const condensedRageMultiplier = ability.classId === "rom-priest"
|
|
? romPriestWarriorCondensedRagePhysicalDamageMultiplier(
|
|
(context.learnedPassiveSpellIds ?? []).includes(493038),
|
|
)
|
|
: 1;
|
|
const condensedRageEffects = condensedRageMultiplier > 1
|
|
? coaEffects.map((effect) => (effect.kind === "damage" || effect.kind === "dot")
|
|
&& (effect.school ?? (context.defaultDamageIsPhysical ? "physical" : undefined)) === "physical"
|
|
? scaleOutput(effect, condensedRageMultiplier)
|
|
: effect)
|
|
: coaEffects;
|
|
const sourceSchoolAdjustedEffects = romWarriorPhysicalWeaponTooltip(ability)
|
|
? condensedRageEffects.map((effect) => (effect.kind === "damage" || effect.kind === "dot") && effect.school === "arcane"
|
|
? { ...effect, school: "physical" as const }
|
|
: effect)
|
|
: condensedRageEffects;
|
|
const warriorAdjustedEffects = ability.classId === "rom-warrior" && warriorPassives.has(499806)
|
|
? sourceSchoolAdjustedEffects.map((effect) => (effect.kind === "damage" || effect.kind === "dot")
|
|
&& (effect.school === undefined || effect.school === "physical")
|
|
? scaleOutput(effect, 1.2)
|
|
: effect)
|
|
: sourceSchoolAdjustedEffects;
|
|
const requiredTargetStateTag = romAbilityRequiredTargetStateTag(ability);
|
|
if (requiredTargetStateTag && !context.targetTags?.includes(requiredTargetStateTag)) return [];
|
|
const damage = warriorAdjustedEffects.filter((effect) => effect.kind === "damage");
|
|
const seals = Math.max(0, Math.min(4, context.sealStacks));
|
|
switch (ability.dbcSpellId) {
|
|
case 493283: { // Current CoA Warrior/Rogue Probing Attack has a sourced empowered weapon-DPS branch.
|
|
const weaponDpsCoefficient = romCoaWarriorRogueProbingAttackDpsCoefficient({
|
|
hasCasterOwnedBleed: context.bleedCount > 0,
|
|
equippedTwoHandHammer: context.mainHandTwoHandHammer ?? false,
|
|
});
|
|
return warriorAdjustedEffects.map((effect) => effect.kind === "damage"
|
|
? { ...effect, weaponDpsCoefficient }
|
|
: effect);
|
|
}
|
|
case 491142: // Tactical Attack's copied Slash bleed only applies when Slash is already bleeding the target.
|
|
return [
|
|
...damage,
|
|
...(context.slashBleed ? warriorAdjustedEffects.filter((effect) => effect.kind === "dot") : []),
|
|
...warriorAdjustedEffects.filter((effect) => effect.kind !== "damage" && effect.kind !== "dot"
|
|
&& !(effect.kind === "scripted" && effect.label.startsWith("RoM conditional-use:"))),
|
|
];
|
|
case 494021: { // Healing Arrows chooses the Nature's Power HoT when a point is available.
|
|
const hots = warriorAdjustedEffects.filter((effect) => effect.kind === "hot");
|
|
const empowered = (context.naturePower ?? 0) >= 1;
|
|
const selectedHot = empowered ? hots[0] : hots[1] ?? hots[0];
|
|
return [
|
|
...(selectedHot ? [selectedHot] : []),
|
|
...(empowered ? [{ kind: "resource-drain" as const, amount: 1, burn: false, resource: "nature-power" as const, recipient: "caster" as const }] : []),
|
|
];
|
|
}
|
|
case 493247: // The Final Battle only adds its second hit below 30% target HP.
|
|
return [
|
|
...damage.slice(0, context.targetHealthPercent < 0.3 ? 2 : 1),
|
|
...warriorAdjustedEffects.filter((effect) => effect.kind !== "damage"),
|
|
];
|
|
case 490121: // Moon Cleave has three main-hand attack pulses.
|
|
return [...damage, ...damage, ...damage,
|
|
...warriorAdjustedEffects.filter((effect) => effect.kind !== "damage")];
|
|
case 490129: { // Heroic Surprise Attack restores Rage only when its hit lands.
|
|
if (!warriorPassives.has(492661)) return warriorAdjustedEffects;
|
|
let attachedRage = false;
|
|
return warriorAdjustedEffects.map((effect) => {
|
|
if (effect.kind !== "damage" || attachedRage) return effect;
|
|
attachedRage = true;
|
|
return { ...effect, onDamageResource: { resource: "rage", amount: 25 } };
|
|
});
|
|
}
|
|
case 492625: // Infection's poison rider needs Poisonous active.
|
|
return context.passiveTags?.includes("rom-poisonous")
|
|
? warriorAdjustedEffects : warriorAdjustedEffects.filter((effect) => effect.kind !== "dot");
|
|
case 491156: // Electrostatic Charge's source shield is a percent of max HP.
|
|
return warriorAdjustedEffects.map((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length
|
|
? {
|
|
...effect,
|
|
aura: {
|
|
...effect.aura,
|
|
absorbs: effect.aura.absorbs.map((pool) => ({
|
|
...pool,
|
|
amount: Math.max(1, Math.round(context.casterMaxHealth * pool.amount / 100)),
|
|
})),
|
|
},
|
|
} : effect);
|
|
case 490148: // A new Holy Strike increases the target's Holy Seal bleed stack.
|
|
case 490150: { // Charge adds two stacks.
|
|
const gained = ability.dbcSpellId === 490148 ? 1 : 2;
|
|
const nextStacks = Math.min(4, seals + gained);
|
|
return warriorAdjustedEffects.map((effect) => effect.kind === "dot" ? scaleOutput(effect, nextStacks) : effect);
|
|
}
|
|
case 490313: // Wound Attack: one hit plus one rider per self-applied bleed.
|
|
return damage.slice(0, 1 + Math.min(2, context.bleedCount));
|
|
case 490323: // Low Blow only creates Grievous Wound on a bleeding target.
|
|
return context.bleedCount > 0 ? warriorAdjustedEffects : warriorAdjustedEffects.filter((effect) => effect.kind !== "dot");
|
|
case 491170: // Electric Explosion has mutually exclusive normal/Charged hits.
|
|
return context.charged ? damage.slice(0, 1) : damage.slice(-1);
|
|
case 490151: // Punishment spends Holy Seals and scales its one payoff.
|
|
return seals > 0 ? warriorAdjustedEffects.map((effect) => scaleOutput(effect, seals / 4)) : [];
|
|
case 490153: { // Threaten: target debuffs and extra hit require Holy Seals.
|
|
const base = damage[0];
|
|
const threat = warriorAdjustedEffects.find((effect) => effect.kind === "apply-aura" && effect.recipient === "caster");
|
|
const debuffs = seals > 0 ? warriorAdjustedEffects.filter((effect) => effect.kind === "apply-aura" && effect.recipient !== "caster") : [];
|
|
const bonus = seals > 0 && damage.length > 1 ? scaleOutput(damage[damage.length - 1]!, seals / 4) : null;
|
|
return [...(base ? [base] : []), ...(threat ? [threat] : []), ...debuffs, ...(bonus ? [bonus] : [])];
|
|
}
|
|
case 490154: { // Shock: one hit, one stun, plus a seal-dependent rider.
|
|
const stun = warriorAdjustedEffects.find((effect) => effect.kind === "apply-aura" && effect.control?.kind === "stun");
|
|
const scaledStun = stun && stun.kind === "apply-aura"
|
|
? { ...stun, aura: { ...stun.aura, durationMs: 1_000 * (1 + seals) } }
|
|
: null;
|
|
const bonus = seals > 0 && damage.length > 1 ? scaleOutput(damage[damage.length - 1]!, seals / 4) : null;
|
|
return [...(damage[0] ? [damage[0]] : []), ...(scaledStun ? [scaledStun] : []), ...(bonus ? [bonus] : [])];
|
|
}
|
|
case 490186: { // Shield Bash shields its caster only when consuming seals.
|
|
const absorb = warriorAdjustedEffects.find((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length);
|
|
const shield = seals > 0 && absorb?.kind === "apply-aura" ? {
|
|
...absorb,
|
|
recipient: "caster" as const,
|
|
aura: {
|
|
...absorb.aura,
|
|
absorbs: absorb.aura.absorbs?.map((pool) => ({
|
|
...pool, amount: Math.max(1, Math.round(context.casterMaxHealth * 0.05 * seals)),
|
|
})),
|
|
},
|
|
} : null;
|
|
return [...(damage[0] ? [damage[0]] : []), ...(shield ? [shield, { kind: "combo" as const, amount: -5 }] : [])];
|
|
}
|
|
case 490306: // Shadowstab's bleed needs a main-hand dagger.
|
|
return context.mainHandDagger ? warriorAdjustedEffects : warriorAdjustedEffects.filter((effect) => effect.kind !== "dot");
|
|
case 490337: // Sneak Attack always stuns, but its bleed needs a dagger.
|
|
return context.mainHandDagger ? warriorAdjustedEffects : warriorAdjustedEffects.filter((effect) => effect.kind !== "dot");
|
|
default:
|
|
return warriorAdjustedEffects;
|
|
}
|
|
}
|