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 => 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 = { 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['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, tag: string): boolean { return Array.isArray(tags) ? tags.includes(tag) : (tags as ReadonlySet).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 = [], ): 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 => 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); }