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> = { 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; } }