Files
healer-man/src/game/romDruidOverrides.ts
T
2026-10-02 10:38:12 -04:00

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);
}