From ab29745d6a2e361442acb0a105213453a0853cbc Mon Sep 17 00:00:00 2001 From: blinkysc <37940565+blinkysc@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:58:17 -0600 Subject: [PATCH] fix(Core/Spells): fix PPM proc chance calculation for healing spells (#24761) Co-authored-by: blinkysc --- .../rev_1771524241618710557.sql | 11 ++++++ src/server/game/Spells/Auras/SpellAuras.cpp | 5 ++- src/test/mocks/ProcChanceTestHelper.h | 8 ++-- .../game/Spells/SpellProcChanceTest.cpp | 37 +++++++++++++++++-- 4 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 data/sql/updates/pending_db_world/rev_1771524241618710557.sql diff --git a/data/sql/updates/pending_db_world/rev_1771524241618710557.sql b/data/sql/updates/pending_db_world/rev_1771524241618710557.sql new file mode 100644 index 000000000..fa345e0b4 --- /dev/null +++ b/data/sql/updates/pending_db_world/rev_1771524241618710557.sql @@ -0,0 +1,11 @@ +-- Soul Preserver (60510): add spell_proc entry to fix proc for non-Paladin healers +-- Auto-generated entry had SpellFamilyName=10 (Paladin) which blocked other classes +-- ProcFlags 0x4400: DONE_SPELL_MAGIC_DMG_CLASS_POS + DONE_SPELL_NONE_DMG_CLASS_POS (direct heals + HoTs) +-- SpellTypeMask 6: HEAL + NO_DMG_HEAL (HoT applications) +DELETE FROM `spell_proc` WHERE `SpellId` = 60510; +INSERT INTO `spell_proc` (`SpellId`, `SchoolMask`, `SpellFamilyName`, `SpellFamilyMask0`, `SpellFamilyMask1`, `SpellFamilyMask2`, `ProcFlags`, `SpellTypeMask`, `SpellPhaseMask`, `HitMask`, `AttributesMask`, `DisableEffectsMask`, `ProcsPerMinute`, `Chance`, `Cooldown`, `Charges`) +VALUES (60510, 0, 0, 0, 0, 0, 0x4400, 6, 2, 0, 0, 0, 0, 0, 0, 0); + +-- Spark of Life (60519): add DONE_SPELL_NONE_DMG_CLASS_POS (0x400) and NO_DMG_HEAL to SpellTypeMask +-- Allows HoT casts to trigger the proc +UPDATE `spell_proc` SET `ProcFlags` = 0x14400, `SpellTypeMask` = 7 WHERE `SpellId` = 60519; diff --git a/src/server/game/Spells/Auras/SpellAuras.cpp b/src/server/game/Spells/Auras/SpellAuras.cpp index 63da507eb..0ea002cff 100644 --- a/src/server/game/Spells/Auras/SpellAuras.cpp +++ b/src/server/game/Spells/Auras/SpellAuras.cpp @@ -2280,13 +2280,14 @@ float Aura::CalcProcChance(SpellProcEntry const& procEntry, ProcEventInfo& event if (Unit* caster = GetCaster()) { // If PPM exists calculate chance from PPM - if (eventInfo.GetDamageInfo() && procEntry.ProcsPerMinute != 0) + if ((eventInfo.GetDamageInfo() || eventInfo.GetHealInfo()) && procEntry.ProcsPerMinute != 0) { SpellInfo const* procSpell = eventInfo.GetSpellInfo(); uint32 attackSpeed = 0; if (!procSpell || procSpell->DmgClass == SPELL_DAMAGE_CLASS_MELEE || procSpell->IsRangedWeaponSpell()) { - attackSpeed = caster->GetAttackTime(eventInfo.GetDamageInfo()->GetAttackType()); + if (eventInfo.GetDamageInfo()) + attackSpeed = caster->GetAttackTime(eventInfo.GetDamageInfo()->GetAttackType()); } else // spells use their cast time for PPM calculations { diff --git a/src/test/mocks/ProcChanceTestHelper.h b/src/test/mocks/ProcChanceTestHelper.h index 58436973d..32a3dac98 100644 --- a/src/test/mocks/ProcChanceTestHelper.h +++ b/src/test/mocks/ProcChanceTestHelper.h @@ -79,6 +79,7 @@ public: * @param chanceModifier Talent/aura modifier to chance * @param ppmModifier Talent/aura modifier to PPM * @param hasDamageInfo Whether a DamageInfo is present (enables PPM) + * @param hasHealInfo Whether a HealInfo is present (also enables PPM) * @return Calculated proc chance */ static float SimulateCalcProcChance( @@ -87,12 +88,13 @@ public: uint32 weaponSpeed = 2500, float chanceModifier = 0.0f, float ppmModifier = 0.0f, - bool hasDamageInfo = true) + bool hasDamageInfo = true, + bool hasHealInfo = false) { float chance = procEntry.Chance; - // PPM calculation overrides base chance if PPM > 0 and we have DamageInfo - if (hasDamageInfo && procEntry.ProcsPerMinute > 0.0f) + // PPM calculation overrides base chance if PPM > 0 and we have DamageInfo or HealInfo + if ((hasDamageInfo || hasHealInfo) && procEntry.ProcsPerMinute > 0.0f) { chance = CalculatePPMChance(weaponSpeed, procEntry.ProcsPerMinute, ppmModifier); } diff --git a/src/test/server/game/Spells/SpellProcChanceTest.cpp b/src/test/server/game/Spells/SpellProcChanceTest.cpp index 7c7f6fbbf..189f36823 100644 --- a/src/test/server/game/Spells/SpellProcChanceTest.cpp +++ b/src/test/server/game/Spells/SpellProcChanceTest.cpp @@ -91,19 +91,50 @@ TEST_F(SpellProcChanceTest, PPM_OverridesBaseChance_WithDamageInfo) EXPECT_NEAR(result, 25.0f, 0.01f); } -TEST_F(SpellProcChanceTest, PPM_NotApplied_WithoutDamageInfo) +TEST_F(SpellProcChanceTest, PPM_NotApplied_WithoutDamageOrHealInfo) { auto procEntry = SpellProcEntryBuilder() .WithChance(50.0f) .WithProcsPerMinute(6.0f) .Build(); - // Without DamageInfo, base chance is used + // Without DamageInfo or HealInfo, base chance is used float result = ProcChanceTestHelper::SimulateCalcProcChance( - procEntry, 80, 2500, 0.0f, 0.0f, false); + procEntry, 80, 2500, 0.0f, 0.0f, false, false); EXPECT_NEAR(result, 50.0f, 0.01f); } +TEST_F(SpellProcChanceTest, PPM_Applied_WithHealInfo) +{ + auto procEntry = SpellProcEntryBuilder() + .WithChance(0.0f) + .WithProcsPerMinute(3.5f) + .Build(); + + // With HealInfo (no DamageInfo), PPM should still calculate + // 3000ms cast time * 3.5 PPM / 600 = 17.5% + float result = ProcChanceTestHelper::SimulateCalcProcChance( + procEntry, 80, 3000, 0.0f, 0.0f, false, true); + EXPECT_NEAR(result, 17.5f, 0.01f); +} + +TEST_F(SpellProcChanceTest, PPM_HealInfo_ZeroBaseChance_WouldBeZeroWithoutFix) +{ + // Reproduces the Omen of Clarity healing bug: + // PPM=3.5, Chance=0, and only HealInfo present (no DamageInfo) + // Without the fix, chance would be 0% because PPM branch was skipped + auto procEntry = SpellProcEntryBuilder() + .WithChance(0.0f) + .WithProcsPerMinute(3.5f) + .Build(); + + // Instant cast uses 1500ms minimum + float result = ProcChanceTestHelper::SimulateCalcProcChance( + procEntry, 80, 1500, 0.0f, 0.0f, false, true); + EXPECT_NEAR(result, 8.75f, 0.01f); + EXPECT_GT(result, 0.0f) << "PPM with HealInfo must produce non-zero chance"; +} + TEST_F(SpellProcChanceTest, PPM_WithWeaponSpeedVariation) { auto procEntry = SpellProcEntryBuilder()