368 lines
16 KiB
TypeScript
368 lines
16 KiB
TypeScript
import type { AbilityEffect } from "./abilityCatalog";
|
|
import type { RomGeneratedAbilityLike } from "./romAbilitySemantics";
|
|
import {
|
|
isRomDruidRoguePair,
|
|
romDruidRogueCorrosivePoisonEffect,
|
|
romDruidRogueCooldownRider,
|
|
} from "./romDruidRogueCurrentCoa";
|
|
import {
|
|
isRomMageDruidPair,
|
|
romMageDruidGreenGuardianAuraEffect,
|
|
} from "./romMageDruidGreenGuardianRuntime";
|
|
|
|
export const ROM_DRUID_GREEN_GUARDIAN_TAG = "rom-druid-green-guardian";
|
|
export const ROM_DRUID_MOTHER_EARTH_PROTECTION_TAG = "rom-druid-mother-earth-protection";
|
|
|
|
function sourceDurationMs(row: RomGeneratedAbilityLike, fallbackSeconds: number): number {
|
|
const duration = row.source?.components?.find((component) => (component.effectTime ?? 0) > 0)?.effectTime;
|
|
return Math.max(1_000, Math.round((duration ?? fallbackSeconds) * 1_000));
|
|
}
|
|
|
|
function spiritOfLifeHot(effects: readonly AbilityEffect[]): AbilityEffect {
|
|
const heals = effects.filter((effect): effect is Extract<AbilityEffect, { kind: "heal" }> => effect.kind === "heal");
|
|
const sum = (key: "coefficient" | "basePoints" | "dieSides" | "pointsPerLevel" | "coefficientPerLevel"
|
|
| "bonusPowerCoefficient" | "attackPowerCoefficient" | "spiritCoefficient" | "percentMaxHealth") => (
|
|
heals.reduce((total, effect) => total + (effect[key] ?? 0), 0)
|
|
);
|
|
const hot: Extract<AbilityEffect, { kind: "hot" }> = {
|
|
kind: "hot",
|
|
coefficient: sum("coefficient"),
|
|
ticks: 10,
|
|
intervalMs: 4_000,
|
|
recipient: "caster",
|
|
scope: "party",
|
|
center: "caster",
|
|
radius: 7,
|
|
...(sum("percentMaxHealth") ? { percentMaxHealth: sum("percentMaxHealth") } : {}),
|
|
...(sum("basePoints") ? { basePoints: sum("basePoints") } : {}),
|
|
...(sum("dieSides") ? { dieSides: sum("dieSides") } : {}),
|
|
...(sum("pointsPerLevel") ? { pointsPerLevel: sum("pointsPerLevel") } : {}),
|
|
...(sum("coefficientPerLevel") ? { coefficientPerLevel: sum("coefficientPerLevel") } : {}),
|
|
...(sum("bonusPowerCoefficient") ? { bonusPowerCoefficient: sum("bonusPowerCoefficient") } : {}),
|
|
...(sum("attackPowerCoefficient") ? { attackPowerCoefficient: sum("attackPowerCoefficient") } : {}),
|
|
...(sum("spiritCoefficient") ? { spiritCoefficient: sum("spiritCoefficient") } : {}),
|
|
...(heals.find((effect) => effect.sourceLevel !== undefined)?.sourceLevel === undefined
|
|
? {} : { sourceLevel: heals.find((effect) => effect.sourceLevel !== undefined)!.sourceLevel }),
|
|
};
|
|
return hot;
|
|
}
|
|
|
|
function stateAura(
|
|
id: string,
|
|
name: string,
|
|
durationMs: number,
|
|
modifiers: NonNullable<Extract<AbilityEffect, { kind: "apply-aura" }>['aura']['modifiers']>,
|
|
): AbilityEffect {
|
|
return {
|
|
kind: "apply-aura",
|
|
recipient: "caster",
|
|
aura: {
|
|
id,
|
|
name,
|
|
disposition: "buff",
|
|
durationMs,
|
|
modifiers,
|
|
utilityTags: ["rom-druid-unity-with-mother-earth", "rom-druid-movement-locked"],
|
|
},
|
|
sourceEffectType: -1,
|
|
sourceAuraType: -1,
|
|
miscValue: 0,
|
|
triggerSpellId: 0,
|
|
};
|
|
}
|
|
|
|
function passiveState(sourceId: number, name: string, tag: string): AbilityEffect {
|
|
return {
|
|
kind: "apply-aura",
|
|
recipient: "caster",
|
|
aura: {
|
|
id: `rom-druid-passive-${sourceId}`,
|
|
name,
|
|
disposition: "buff",
|
|
durationMs: null,
|
|
utilityTags: [tag],
|
|
},
|
|
sourceEffectType: -1,
|
|
sourceAuraType: -1,
|
|
miscValue: 0,
|
|
triggerSpellId: sourceId,
|
|
};
|
|
}
|
|
|
|
/** Source-specific player effects for Druid rows the generic translator cannot express. */
|
|
export function overrideRomDruidEffects(
|
|
row: RomGeneratedAbilityLike,
|
|
translated: readonly AbilityEffect[],
|
|
): readonly AbilityEffect[] | null {
|
|
if (isRomDruidRoguePair(row)) {
|
|
if (row.sourceSkillId === 494024) return [romDruidRogueCorrosivePoisonEffect()];
|
|
if (row.sourceSkillId === 494321) return [
|
|
{ kind: "damage", coefficient: 3.297, school: "shadow" },
|
|
{
|
|
kind: "utility",
|
|
action: "class-script",
|
|
effectType: -1,
|
|
auraType: -1,
|
|
miscValue: 0,
|
|
triggerSpellId: 494024,
|
|
durationMs: 12_000,
|
|
},
|
|
];
|
|
if (row.sourceSkillId === 494023) return [];
|
|
}
|
|
switch (row.sourceSkillId) {
|
|
case 493533:
|
|
case 493558: { // Both Mother Earth's Protection ranks can trigger Green Guardian on expiry.
|
|
let tagged = false;
|
|
return translated.map((effect) => {
|
|
if (tagged || effect.kind !== "apply-aura" || effect.aura.durationMs === null) return effect;
|
|
tagged = true;
|
|
return {
|
|
...effect,
|
|
aura: {
|
|
...effect.aura,
|
|
utilityTags: [...(effect.aura.utilityTags ?? []), ROM_DRUID_MOTHER_EARTH_PROTECTION_TAG],
|
|
},
|
|
};
|
|
});
|
|
}
|
|
case 494331:
|
|
if (isRomMageDruidPair(row)) return [romMageDruidGreenGuardianAuraEffect()];
|
|
// Keep RuneWaker's legacy HoTs only for rows outside the current CoA pair.
|
|
return [
|
|
passiveState(494331, row.name, ROM_DRUID_GREEN_GUARDIAN_TAG),
|
|
...translated.filter((effect) => effect.kind === "hot"),
|
|
];
|
|
case 493531: // Savage Blessing buffs a selected friendly target.
|
|
return translated.map((effect) => effect.kind === "apply-aura" ? {
|
|
...effect,
|
|
recipient: "ability-target",
|
|
aura: { ...effect.aura, disposition: "buff" },
|
|
} : effect);
|
|
case 493536: { // Spirit Guidance charms a non-player enemy for the source effect duration.
|
|
const effectTimeSeconds = row.source?.components?.find((component) => component.id === 503805)?.effectTime ?? 3;
|
|
return [{
|
|
kind: "status",
|
|
status: "charm",
|
|
durationMs: Math.max(1_000, Math.round(effectTimeSeconds * 1_000)),
|
|
maxTargets: 1,
|
|
breakOnDamage: false,
|
|
}];
|
|
}
|
|
case 493548: // Unity guarantees critical spell results and adds 10% healing for 15 seconds.
|
|
return [stateAura(
|
|
"rom-druid-unity-with-mother-earth",
|
|
row.name,
|
|
sourceDurationMs(row, 15),
|
|
[
|
|
{ kind: "stat", stat: "magical-critical-strike", operation: "flat", value: 100 },
|
|
{ kind: "healing", direction: "done", operation: "percent", value: 0.1 },
|
|
],
|
|
)];
|
|
case 494200: // Extended Recovery: passive state extends Recover by six seconds.
|
|
return [passiveState(494200, row.name, "rom-druid-extended-recovery")];
|
|
case 494935: // Holy Fury: enables the zero-Nature-Power Wrath duration branch.
|
|
return [passiveState(494935, row.name, "rom-druid-holy-fury")];
|
|
case 499944: // Knee Ulcer gates the five-second root child on Necrotic Wound.
|
|
return [passiveState(499944, row.name, "rom-druid-knee-ulcer")];
|
|
case 494025: // Preserve both Earth Chain payload branches for cast-time selection.
|
|
return translated.filter((effect) => effect.kind === "damage").map((effect) => ({
|
|
...effect,
|
|
falloffPerJump: 0.7,
|
|
}));
|
|
case 494321: // The CoA rework replaces RuneWaker's healing reduction and Knee Ulcer root.
|
|
return translated.filter((effect) => effect.kind === "damage");
|
|
case 494938: // 505938 supplies the 40-second lifetime; the tooltip states 4-second party pulses.
|
|
return [spiritOfLifeHot(translated)];
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function hasTag(tags: readonly string[] | ReadonlySet<string>, tag: string): boolean {
|
|
return Array.isArray(tags) ? tags.includes(tag) : (tags as ReadonlySet<string>).has(tag);
|
|
}
|
|
|
|
function naturePowerDrain(): AbilityEffect {
|
|
return { kind: "resource-drain", amount: 1, burn: false, resource: "nature-power", recipient: "caster" };
|
|
}
|
|
|
|
function spendBeforeNaturePowerRefund(effects: readonly AbilityEffect[]): readonly AbilityEffect[] {
|
|
const refundIndex = effects.findIndex((effect) => effect.kind === "resource"
|
|
&& effect.resource === "nature-power" && effect.amount > 0);
|
|
if (refundIndex < 0) return [...effects, naturePowerDrain()];
|
|
return [...effects.slice(0, refundIndex), naturePowerDrain(), ...effects.slice(refundIndex)];
|
|
}
|
|
|
|
function selectSeedModifierBranch(
|
|
effects: readonly AbilityEffect[],
|
|
componentIds: readonly [number, number],
|
|
selectedComponentId: number,
|
|
): readonly AbilityEffect[] {
|
|
return effects.filter((effect) => {
|
|
if (effect.kind !== "apply-aura") return true;
|
|
const sourceComponentId = componentIds.find((id) => effect.aura.id.includes(`-${id}-`));
|
|
return sourceComponentId === undefined || sourceComponentId === selectedComponentId;
|
|
});
|
|
}
|
|
|
|
/** Resolve Druid source branches that depend on current Nature Power or learned passive state. */
|
|
export function romDruidCastEffects(
|
|
ability: import("./abilityCatalog").AbilityDefinition,
|
|
effects: readonly AbilityEffect[],
|
|
naturePower: number,
|
|
passiveTags: readonly string[] | ReadonlySet<string> = [],
|
|
): readonly AbilityEffect[] {
|
|
if (ability.source !== "runewaker") return effects;
|
|
const empowered = naturePower >= 1;
|
|
const damage = effects.filter((effect) => effect.kind === "damage");
|
|
const heals = effects.filter((effect): effect is Extract<AbilityEffect, { kind: "heal" }> => effect.kind === "heal");
|
|
const selectDamage = (index: number, count = 1): readonly AbilityEffect[] => {
|
|
const selected = damage[index];
|
|
return selected ? Array.from({ length: count }, () => selected) : [];
|
|
};
|
|
|
|
switch (ability.dbcSpellId) {
|
|
case 494373: // Essence Extraction's 10% MP conversion is already represented as its cast cost.
|
|
return effects.filter((effect) => !(effect.kind === "resource-drain" && effect.resource === "mana"));
|
|
case 494937: { // Grace of the Forest only applies RuneWaker's second component while Nature Power is held.
|
|
if (ability.classId !== "rom-druid" || ability.secondaryClassId !== "rom-warden") return effects;
|
|
if (naturePower < 1) return effects.filter((effect) => effect.kind !== "apply-aura"
|
|
|| !effect.aura.id.includes("-494937-505711-"));
|
|
const poweredHealingPresent = effects.some((effect) => effect.kind === "apply-aura"
|
|
&& effect.aura.id.includes("-494937-505711-")
|
|
&& effect.aura.modifiers?.some((modifier) => modifier.kind === "healing"
|
|
&& modifier.direction === "done" && modifier.operation === "percent" && modifier.value === 0.03));
|
|
return poweredHealingPresent ? effects : [...effects, {
|
|
kind: "apply-aura",
|
|
recipient: "caster",
|
|
aura: {
|
|
id: "rom-druid-grace-of-the-forest-powered-healing",
|
|
name: `${ability.name}: Nature Power`,
|
|
disposition: "buff",
|
|
durationMs: 4_000,
|
|
modifiers: [{ kind: "healing", direction: "done", operation: "percent", value: 0.03 }],
|
|
},
|
|
sourceEffectType: -1,
|
|
sourceAuraType: 149,
|
|
miscValue: 3,
|
|
triggerSpellId: 494937,
|
|
}];
|
|
}
|
|
case 493529: { // DMG1 is baseline; DMG0 is the one-point Nature Power branch.
|
|
const selected = heals[empowered ? 1 : 0] ?? heals[0];
|
|
if (!selected) return effects;
|
|
return [
|
|
...effects.filter((effect) => effect.kind !== "heal"),
|
|
{
|
|
...selected,
|
|
scope: "party",
|
|
center: "ability-target",
|
|
radius: 10,
|
|
},
|
|
...(empowered ? [naturePowerDrain()] : []),
|
|
];
|
|
}
|
|
case 493546: { // Buff1 is the base branch; Buff0 is the powered branch.
|
|
const selected = selectSeedModifierBranch(effects, [503815, 620458], empowered ? 620458 : 503815);
|
|
return empowered ? spendBeforeNaturePowerRefund(selected) : selected;
|
|
}
|
|
case 493545: { // Buff1 is the base branch; Buff0 is the powered branch.
|
|
const selected = selectSeedModifierBranch(effects, [503814, 620457], empowered ? 620457 : 503814);
|
|
return empowered ? spendBeforeNaturePowerRefund(selected) : selected;
|
|
}
|
|
case 493549: { // Select DMG1/DMG0, keep the single source DoT, and consume one point if powered.
|
|
const selectedDamage = damage[empowered ? 1 : 0];
|
|
if (!selectedDamage) return effects;
|
|
const selected = effects.filter((effect) => effect.kind !== "damage" || effect === selectedDamage)
|
|
.map((effect) => (
|
|
effect.kind === "dot" && !empowered && hasTag(passiveTags, "rom-druid-holy-fury")
|
|
? { ...effect, ticks: effect.ticks + 3 }
|
|
: effect
|
|
));
|
|
return empowered ? spendBeforeNaturePowerRefund(selected) : selected;
|
|
}
|
|
case 493542: { // The powered DMG0 component hits all targets twice; no creature summon row exists.
|
|
const hits = selectDamage(empowered ? 1 : 0, empowered ? 2 : 1);
|
|
return [
|
|
...hits,
|
|
...effects.filter((effect) => effect.kind !== "damage" && effect.kind !== "summon"),
|
|
...(empowered ? [naturePowerDrain()] : []),
|
|
];
|
|
}
|
|
case 494025: { // Earth Chain keeps its 70% per-jump falloff in either branch.
|
|
const selectedDamage = damage[empowered ? 1 : 0];
|
|
if (!selectedDamage) return effects;
|
|
return [selectedDamage, ...(empowered ? [naturePowerDrain()] : [])];
|
|
}
|
|
case 494365: { // Healing Wind selects DMG1 or DMG0 and heals the caster's nearby party.
|
|
const selected = heals[empowered ? 1 : 0] ?? heals[0];
|
|
if (!selected) return effects;
|
|
return [{
|
|
...selected,
|
|
scope: "party",
|
|
center: "caster",
|
|
radius: 10,
|
|
}, ...(empowered ? [naturePowerDrain()] : [])];
|
|
}
|
|
case 494216: { // Heart Piercing selects its DMG1/DMG0 strike and spends one point when powered.
|
|
const selectedDamage = damage[empowered ? 1 : 0];
|
|
if (!selectedDamage) return effects;
|
|
return [
|
|
...effects.filter((effect) => effect.kind !== "damage" || effect === selectedDamage),
|
|
...(empowered ? [naturePowerDrain()] : []),
|
|
];
|
|
}
|
|
case 494018: { // Cross of Thorns uses two DMG1 hits or two DMG0 hits after spending one point.
|
|
const selectedHits = empowered ? selectDamage(1, 2) : [damage[0], damage[2]].filter((effect) => effect !== undefined);
|
|
if (!selectedHits.length) return effects;
|
|
const selected = effects.filter((effect) => effect.kind !== "damage"
|
|
&& !(empowered && effect.kind === "resource" && effect.resource === "nature-power"));
|
|
return [...selectedHits, ...selected, ...(empowered ? [naturePowerDrain()] : [])];
|
|
}
|
|
case 499956: { // Briar Technique unlocks this source row, with both DMG branches on distinct components.
|
|
const selectedHits = empowered
|
|
? [damage[1], damage[3]].filter((effect) => effect !== undefined)
|
|
: [damage[0], damage[2]].filter((effect) => effect !== undefined);
|
|
if (selectedHits.length !== 2) return effects;
|
|
const selected = effects.filter((effect) => effect.kind !== "damage"
|
|
&& !(empowered && effect.kind === "resource" && effect.resource === "nature-power"));
|
|
return [...selectedHits, ...selected, ...(empowered ? [naturePowerDrain()] : [])];
|
|
}
|
|
case 494022: { // Poisonous Widow Embrace selects one damage component and refunds a spent point.
|
|
const selectedDamage = damage[empowered ? 1 : 0];
|
|
if (!selectedDamage) return effects;
|
|
const selected = effects.filter((effect) => effect.kind !== "damage" || effect === selectedDamage);
|
|
const branch = empowered ? spendBeforeNaturePowerRefund(selected) : selected;
|
|
return isRomDruidRoguePair(ability)
|
|
? [...branch, romDruidRogueCooldownRider()]
|
|
: branch;
|
|
}
|
|
case 493528: { // Extended Recovery's source-backed duration increase; proc chance is absent.
|
|
if (!hasTag(passiveTags, "rom-druid-extended-recovery")) return effects;
|
|
return effects.map((effect) => effect.kind === "hot"
|
|
? { ...effect, ticks: effect.ticks + 3 }
|
|
: effect);
|
|
}
|
|
case 494321: { // CoA's current poison-gated channel replaces the old healing-reduction/root row.
|
|
if (!isRomDruidRoguePair(ability)) return effects;
|
|
return [
|
|
...effects.filter((effect) => effect.kind === "damage" || (effect.kind === "utility"
|
|
&& effect.action === "class-script" && effect.triggerSpellId === 494024)),
|
|
romDruidRogueCooldownRider(),
|
|
];
|
|
}
|
|
default:
|
|
return effects;
|
|
}
|
|
}
|
|
|
|
/** Add Spirit of Life's source target-max-health component to a periodic heal tick. */
|
|
export function romDruidPeriodicHealAmount(
|
|
baseAmount: number,
|
|
targetMaxHealth: number,
|
|
percentMaxHealth: number,
|
|
): number {
|
|
return Math.max(0, baseAmount) + Math.max(0, targetMaxHealth) * Math.max(0, percentMaxHealth);
|
|
}
|