import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; import type { RomGeneratedAbilityLike } from "./romAbilitySemantics"; import { romWarlockPriestSpreadingPainEffects } from "./romWarlockPriestSpreadingPain"; import { romWarlockRogueShadowSliceEffects } from "./romWarlockRogueShadowSlice"; import { ROM_WARLOCK_MAGE_WILLPOWER_BLADE_STATE_TAG, romWarlockMageSoulBrandStingEffects, } from "./romWarlockMageSoulBrandSting"; import { isRomWarlockPriestSoulEnlightenment, romWarlockPriestSoulEnlightenmentEffect, } from "./romWarlockPriestSoulEnlightenment"; export const ROM_WARLOCK_STATE_TAGS = Object.freeze({ heartFence: "rom-warlock-state-501577", willpowerBlade: ROM_WARLOCK_MAGE_WILLPOWER_BLADE_STATE_TAG, perceptionExtraction: "rom-warlock-state-497960", soulPain: "rom-warlock-state-497964", soulPoisonedFang: "rom-warlock-state-498670", } as const); const CRIME_AND_PUNISHMENT_STATE_TAGS = [ ROM_WARLOCK_STATE_TAGS.perceptionExtraction, ROM_WARLOCK_STATE_TAGS.soulPain, ROM_WARLOCK_STATE_TAGS.soulPoisonedFang, ] as const; function appendAuraTag(effect: AbilityEffect, tag: string): AbilityEffect { if (effect.kind !== "apply-aura") return effect; return { ...effect, aura: { ...effect.aura, utilityTags: [...(effect.aura.utilityTags ?? []), tag] }, }; } function divineAssistanceAura(): AbilityEffect { const schools = ["holy", "fire", "nature", "frost", "shadow", "arcane"] as const; return { kind: "apply-aura", recipient: "caster", aura: { id: "rom-warlock-divine-assistance-498668", name: "Divine Assistance", disposition: "buff", durationMs: null, modifiers: [ ...schools.map((school) => ({ kind: "damage" as const, direction: "dealt" as const, school, operation: "percent" as const, value: 0.16 })), { kind: "healing", direction: "done", operation: "percent", value: 0.16 }, ], }, sourceEffectType: -1, sourceAuraType: -1, miscValue: 16, triggerSpellId: 498668, }; } function healingFortuneAura(durationMs = 20_000, requiresCriticalHit = false): AbilityEffect { return { kind: "apply-aura", recipient: "caster", requiresLandedHit: true, ...(requiresCriticalHit ? { requiresCriticalHit: true } : {}), aura: { id: "rom-warlock-healing-fortune-498633", name: "Healing Fortune", disposition: "buff", durationMs, modifiers: [ { kind: "healing", direction: "done", operation: "percent", value: 0.12 }, { kind: "stat", stat: "wisdom", operation: "percent", value: 0.12 }, ], }, sourceEffectType: -1, sourceAuraType: -1, miscValue: 12, triggerSpellId: 498633, }; } function damageAsFriendlyHeal(effect: Extract): AbilityEffect { const { kind: _kind, school: _school, weaponHand: _weaponHand, falloffPerJump: _falloffPerJump, bonusPerSunderStack: _bonusPerSunderStack, onDamageResource: _onDamageResource, ...scaling } = effect; return { ...scaling, kind: "heal", recipient: "ability-target" }; } /** Source-specific state markers and recipient mapping for the Warlock's paired skills. */ export function overrideRomWarlockEffects( row: RomGeneratedAbilityLike, translated: readonly AbilityEffect[], ): readonly AbilityEffect[] | null { if (row.classId !== "rom-warlock") return null; if (isRomWarlockPriestSoulEnlightenment(row)) { // Current CoA 498649 replaces RuneWaker's maximum-HP/healing aura with // damage dealt and received-healing modifiers on the exact pair. return [romWarlockPriestSoulEnlightenmentEffect()]; } if (row.sourceSkillId === 498642) { // Current CoA Mind Transference spends 75 Rage and grants six Psi; the // local component's unrelated physical-attack aura is superseded. return [{ kind: "resource", resource: "psi", amount: 6 }]; } if (row.sourceSkillId === 498633) return []; if (row.sourceSkillId === 498668) return [divineAssistanceAura()]; const soulBrandSting = romWarlockMageSoulBrandStingEffects(row, translated); if (soulBrandSting) return soulBrandSting; if (row.sourceSkillId === 498549) { // Defense Net's active component supplies the 5-second magic mitigation; // the source tooltip also describes immunity to Helpless, Stun, Root, and // Slow. In this runtime, Helpless maps to fear control. return translated.map((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 142 ? { ...effect, aura: { ...effect.aura, controlImmunities: ["fear", "stun", "root", "slow"], }, } : effect); } if (row.sourceSkillId === 498662) { return [ ...translated.filter((effect) => effect.kind !== "cooldown-reset"), { kind: "cooldown-reset", mode: "reset", abilityName: "Rising Tide" }, ]; } if ([498634, 498648].includes(row.sourceSkillId ?? 0)) { // These passive tooltips describe event-gated resets. Keep their cooldown // riders out of the generic cast path until their source events are wired. return translated.filter((effect) => effect.kind !== "cooldown-reset"); } if (row.sourceSkillId === 498695) { let selectedTargetThreatAura = false; const selectedTargetEffects: AbilityEffect[] = translated.flatMap((effect): AbilityEffect[] => { if (effect.kind !== "apply-aura") return [effect]; const threatModifiers = effect.aura.modifiers?.filter((modifier) => ( modifier.kind === "stat" && modifier.stat === "threat" )) ?? []; const threatModifier = threatModifiers[0]; if (!threatModifier || threatModifier.kind !== "stat") return [effect]; if (threatModifier.value > 0) { // 621654 repeats the +5% modifier on a hidden script component; the tooltip // describes one buff on the selected friendly target. if (selectedTargetThreatAura) return []; selectedTargetThreatAura = true; return [{ ...effect, recipient: "ability-target" }]; } if (threatModifier.value < 0) { return [{ ...effect, recipient: "caster", scope: "party" }]; } return [effect]; }); const reductionComponent = row.source?.components?.find((component) => component.id === 621433); const reductionModifier = reductionComponent?.abilityModifiers?.find((modifier) => modifier.type === 138); if (!reductionComponent || !reductionModifier) return selectedTargetEffects; const partyReductionAura: AbilityEffect = { kind: "apply-aura", aura: { id: "rom-warlock-liquidation-party-threat-reduction", name: `${row.name}: party threat reduction`, disposition: "buff", durationMs: Math.max(0, Math.round((reductionComponent.effectTime ?? 10) * 1_000)), modifiers: [{ kind: "stat", stat: "threat", operation: "percent", value: reductionModifier.value / 100, }], }, recipient: "caster", scope: "party", sourceEffectType: -1, sourceAuraType: 138, miscValue: reductionModifier.value, triggerSpellId: row.sourceSkillId ?? 0, }; return [...selectedTargetEffects, partyReductionAura]; } if (row.sourceSkillId === 497955) { // Mark the locally generated aura by its actual state name. Current CoA // lists the state-entry skill as 1494096; this does not bridge the IDs. return translated.map((effect) => effect.kind === "apply-aura" && effect.aura.name === "Willpower Blade" ? appendAuraTag(effect, ROM_WARLOCK_STATE_TAGS.willpowerBlade) : effect); } const stateTag = row.sourceSkillId === 497960 ? ROM_WARLOCK_STATE_TAGS.perceptionExtraction : row.sourceSkillId === 497964 ? ROM_WARLOCK_STATE_TAGS.soulPain : row.sourceSkillId === 498670 ? ROM_WARLOCK_STATE_TAGS.soulPoisonedFang : null; if (stateTag) { return translated.map((effect) => effect.kind === "dot" ? { ...effect, tag: stateTag } : appendAuraTag(effect, stateTag)); } if (row.source?.components?.some((component) => component.id === 501577)) { return translated.map((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 226 ? appendAuraTag(effect, ROM_WARLOCK_STATE_TAGS.heartFence) : effect); } return null; } function scaleDamage(effect: AbilityEffect, factor: number): AbilityEffect { if (effect.kind !== "damage") 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 }), ...(effect.bonusFlat === undefined ? {} : { bonusFlat: effect.bonusFlat * factor }), }; } /** Add only the source-described conditional riders for the Warlock primary build. */ export function romWarlockCastEffects( ability: AbilityDefinition, effects: readonly AbilityEffect[], targetTags: readonly string[], learnedPassiveSpellIds: readonly number[] = [], targetHealthPercent = 1, selectedTargetRelationship: "friendly" | "hostile" = "hostile", ): readonly AbilityEffect[] { if (ability.source !== "runewaker") return effects; const spreadingPainEffects = romWarlockPriestSpreadingPainEffects(ability, effects); if (spreadingPainEffects) return spreadingPainEffects; if (ability.classId === "rom-priest" && ability.dbcSpellId === 490256 && learnedPassiveSpellIds.includes(498643)) { return [ ...effects, { kind: "resource", resource: "psi", amount: 1 }, ...(learnedPassiveSpellIds.includes(498633) ? [healingFortuneAura(20_000, true)] : []), ]; } if (ability.classId === "rom-priest" && ability.dbcSpellId === 491147 && learnedPassiveSpellIds.includes(498648)) { // CoA attaches +1 Psi to Urgent Heal without naming its recipient. Treat // the resource as caster-owned; the ally selected for the heal receives none. return [...effects, { kind: "resource", resource: "psi", amount: 1, scope: "caster" }]; } const shadowSliceEffects = romWarlockRogueShadowSliceEffects(ability, learnedPassiveSpellIds); if (shadowSliceEffects) return shadowSliceEffects; if (ability.classId !== "rom-warlock") return effects; if (ability.dbcSpellId === 498641 && ability.secondaryClassId === "rom-priest") { // CoA says Ring of Comfort provides 1 Psi. Treat this cast resource as // caster-owned so area-heal recipients do not multiply or receive it. return [...effects, { kind: "resource", resource: "psi", amount: 1, scope: "caster" }]; } if (ability.dbcSpellId === 497957 && learnedPassiveSpellIds.includes(498633)) { return [...effects, healingFortuneAura()]; } if (ability.dbcSpellId === 497958 && learnedPassiveSpellIds.includes(498633) && selectedTargetRelationship === "friendly") { return effects.map((effect) => effect.kind === "damage" ? damageAsFriendlyHeal(effect) : effect); } if (ability.dbcSpellId === 498544) { return effects.flatMap((effect): AbilityEffect[] => { if (effect.kind !== "apply-aura" || effect.sourceAuraType !== 192 || effect.miscValue !== 5) { return [effect]; } if (targetHealthPercent >= 0.2) return []; return [{ ...effect, recipient: "caster", aura: { ...effect.aura, disposition: "buff" }, }]; }); } if (ability.dbcSpellId === 498549 && learnedPassiveSpellIds.includes(498648)) { // Mind Shield (498648) reduces Defense Net's sourced five-second aura by one second. return effects.map((effect) => effect.kind === "apply-aura" && effect.sourceAuraType === 142 ? { ...effect, aura: { ...effect.aura, durationMs: Math.max(0, (effect.aura.durationMs ?? 0) - 1_000) }, } : effect); } if (ability.dbcSpellId === 498646 && ability.secondaryClassId === "rom-warrior" && targetTags.includes(ROM_WARLOCK_STATE_TAGS.heartFence)) { return [...effects, { kind: "resource", amount: 100, percentage: true, resource: "rage" }]; } if (ability.dbcSpellId === 498700 && ability.secondaryClassId === "rom-rogue") { const matchedStates = CRIME_AND_PUNISHMENT_STATE_TAGS.filter((tag) => targetTags.includes(tag)); const additionalStates = Math.max(0, matchedStates.length - 1); return additionalStates > 0 ? effects.map((effect) => scaleDamage(effect, 1 + additionalStates * 0.25)) : effects; } return effects; } /** Return state tags whose source durations are refreshed by this successful cast. */ export function romWarlockStateRefreshTags( ability: AbilityDefinition, targetTags: readonly string[], ): readonly string[] { if (ability.source !== "runewaker" || ability.classId !== "rom-warlock") return []; if (ability.dbcSpellId === 498646 && ability.secondaryClassId === "rom-warrior") { return targetTags.includes(ROM_WARLOCK_STATE_TAGS.heartFence) ? [ROM_WARLOCK_STATE_TAGS.heartFence] : []; } if (ability.dbcSpellId === 498700 && ability.secondaryClassId === "rom-rogue") { return CRIME_AND_PUNISHMENT_STATE_TAGS.filter((tag) => targetTags.includes(tag)); } return []; }