345 lines
13 KiB
TypeScript
345 lines
13 KiB
TypeScript
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, { kind: "damage" }>): 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 [];
|
|
}
|