diff --git a/src/server/game/Spells/SpellMgr.cpp b/src/server/game/Spells/SpellMgr.cpp index 5e6b0b2fc..cb4f80d39 100644 --- a/src/server/game/Spells/SpellMgr.cpp +++ b/src/server/game/Spells/SpellMgr.cpp @@ -2029,6 +2029,24 @@ void SpellMgr::LoadSpellProcs() if (!addTriggerFlag && isAlwaysTriggeredAura[auraName]) addTriggerFlag = true; + + // Many proc auras with taken procFlag mask don't have + // attribute "can proc with triggered" — they should + // proc nevertheless (e.g. mage armor spells with + // judgement) + if (!addTriggerFlag + && (spellInfo->ProcFlags & TAKEN_HIT_PROC_FLAG_MASK)) + { + switch (auraName) + { + case SPELL_AURA_PROC_TRIGGER_SPELL: + case SPELL_AURA_PROC_TRIGGER_DAMAGE: + addTriggerFlag = true; + break; + default: + break; + } + } } if (!found) diff --git a/src/test/mocks/ProcChanceTestHelper.h b/src/test/mocks/ProcChanceTestHelper.h index e4caf332f..46c5100c4 100644 --- a/src/test/mocks/ProcChanceTestHelper.h +++ b/src/test/mocks/ProcChanceTestHelper.h @@ -523,6 +523,54 @@ public: // Per-aura check: aura itself suppresses cascading if (config.auraHasDisableProcAttr) return true; + + return false; + } + + // ============================================================================= + // TAKEN Auto-Trigger Logic - simulates SpellMgr.cpp:2033-2049 + // ============================================================================= + + /** + * @brief Configuration for simulating the TAKEN auto-trigger logic + * from SpellMgr::LoadSpellProcs() auto-generation + */ + struct TakenAutoTriggerConfig + { + uint32 procFlags = 0; // SpellInfo::ProcFlags + uint32 auraName = 0; // Effect's ApplyAuraName + bool isAlwaysTriggeredAura = false; // Already set by isAlwaysTriggeredAura[] + }; + + /** + * @brief Simulate the TAKEN auto-trigger logic from SpellMgr::LoadSpellProcs() + * + * During auto-generation of proc entries, TAKEN-proc auras with + * SPELL_AURA_PROC_TRIGGER_SPELL or SPELL_AURA_PROC_TRIGGER_DAMAGE + * should get PROC_ATTR_TRIGGERED_CAN_PROC set automatically. + * + * @param config Configuration describing the aura + * @return true if addTriggerFlag should be set + */ + static bool ShouldAutoAddTriggeredCanProc(TakenAutoTriggerConfig const& config) + { + // If already marked as always-triggered, keep it + if (config.isAlwaysTriggeredAura) + return true; + + // TAKEN auto-trigger: TAKEN proc flags + PROC_TRIGGER_SPELL/DAMAGE + if (config.procFlags & TAKEN_HIT_PROC_FLAG_MASK) + { + switch (config.auraName) + { + case SPELL_AURA_PROC_TRIGGER_SPELL: + case SPELL_AURA_PROC_TRIGGER_DAMAGE: + return true; + default: + break; + } + } + return false; } @@ -641,4 +689,94 @@ private: std::unique_ptr _aura; }; +/** + * @brief Simulates the proc chain guard logic from Unit::TriggerAurasProcOnEvent + * + * Tracks the m_procDeep counter to verify that: + * - TRIGGERED_DISALLOW_PROC_EVENTS on the triggering spell disables procs + * for all auras in the container + * - SPELL_ATTR3_INSTANT_TARGET_PROCS on individual auras disables procs + * only during that specific aura's TriggerProcOnEvent call + * - The counter is properly balanced (returns to 0 after function exits) + */ +class ProcChainGuardSimulator +{ +public: + struct AuraConfig + { + uint32 spellId = 0; + bool hasInstantTargetProcs = false; // SPELL_ATTR3_INSTANT_TARGET_PROCS + bool isRemoved = false; // AuraApplication::GetRemoveMode() + }; + + struct ProcRecord + { + uint32 spellId; + bool canProcDuringTrigger; // CanProc() state when TriggerProcOnEvent fires + int32 procDeepDuringTrigger; // m_procDeep value during trigger + }; + + ProcChainGuardSimulator() : _procDeep(0) {} + + /** + * @brief Simulate Unit::TriggerAurasProcOnEvent + * + * @param triggeringSpellHasDisallowProcEvents Whether the triggering spell + * has TRIGGERED_DISALLOW_PROC_EVENTS cast flag + * @param auras List of auras in the proc container + */ + void SimulateTriggerAurasProc( + bool triggeringSpellHasDisallowProcEvents, + std::vector const& auras) + { + _records.clear(); + + bool const disableProcs = triggeringSpellHasDisallowProcEvents; + if (disableProcs) + SetCantProc(true); + + for (auto const& aura : auras) + { + if (aura.isRemoved) + continue; + + if (aura.hasInstantTargetProcs) + SetCantProc(true); + + // Record CanProc() state during TriggerProcOnEvent + _records.push_back({ + aura.spellId, + CanProc(), + _procDeep + }); + + if (aura.hasInstantTargetProcs) + SetCantProc(false); + } + + if (disableProcs) + SetCantProc(false); + } + + [[nodiscard]] std::vector const& GetRecords() const + { + return _records; + } + + [[nodiscard]] int32 GetProcDeep() const { return _procDeep; } + [[nodiscard]] bool CanProc() const { return _procDeep == 0; } + +private: + void SetCantProc(bool apply) + { + if (apply) + ++_procDeep; + else + --_procDeep; + } + + int32 _procDeep; + std::vector _records; +}; + #endif // AZEROTHCORE_PROC_CHANCE_TEST_HELPER_H diff --git a/src/test/server/game/Spells/SpellProcChainGuardTest.cpp b/src/test/server/game/Spells/SpellProcChainGuardTest.cpp new file mode 100644 index 000000000..fed9908a8 --- /dev/null +++ b/src/test/server/game/Spells/SpellProcChainGuardTest.cpp @@ -0,0 +1,600 @@ +/* + * This file is part of the AzerothCore Project. See AUTHORS file for Copyright information + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +/** + * @file SpellProcChainGuardTest.cpp + * @brief Unit tests for proc chain guard and TAKEN auto-trigger logic + * + * Tests two fixes ported from TrinityCore: + * + * 1. Proc chain guard (Unit::TriggerAurasProcOnEvent): + * - TRIGGERED_DISALLOW_PROC_EVENTS on triggering spell blocks all + * cascading procs during the proc event + * - SPELL_ATTR3_INSTANT_TARGET_PROCS on individual auras blocks + * cascading procs during that specific aura's proc trigger + * - Prevents infinite proc loops between reactive damage auras + * (e.g. Molten Armor <-> Eye for an Eye) + * + * 2. TAKEN auto-trigger (SpellMgr::LoadSpellProcs auto-generation): + * - TAKEN-proc auras with SPELL_AURA_PROC_TRIGGER_SPELL or + * SPELL_AURA_PROC_TRIGGER_DAMAGE automatically get + * PROC_ATTR_TRIGGERED_CAN_PROC so they can proc from + * triggered spells (e.g. Mage Armor proccing from Judgement) + */ + +#include "ProcChanceTestHelper.h" +#include "ProcEventInfoHelper.h" +#include "gtest/gtest.h" + +using namespace testing; + +// ============================================================================= +// TAKEN Auto-Trigger Logic Tests +// Simulates SpellMgr.cpp:2033-2049 auto-generation +// ============================================================================= + +class TakenAutoTriggerTest : public ::testing::Test +{ +protected: + void SetUp() override {} +}; + +TEST_F(TakenAutoTriggerTest, TakenProcTriggerSpell_SetsTriggeredCanProc) +{ + ProcChanceTestHelper::TakenAutoTriggerConfig config; + config.procFlags = PROC_FLAG_TAKEN_MELEE_AUTO_ATTACK; + config.auraName = SPELL_AURA_PROC_TRIGGER_SPELL; + config.isAlwaysTriggeredAura = false; + + EXPECT_TRUE(ProcChanceTestHelper::ShouldAutoAddTriggeredCanProc(config)) + << "TAKEN proc + PROC_TRIGGER_SPELL should auto-add TRIGGERED_CAN_PROC"; +} + +TEST_F(TakenAutoTriggerTest, TakenProcTriggerDamage_SetsTriggeredCanProc) +{ + ProcChanceTestHelper::TakenAutoTriggerConfig config; + config.procFlags = PROC_FLAG_TAKEN_SPELL_MAGIC_DMG_CLASS_NEG; + config.auraName = SPELL_AURA_PROC_TRIGGER_DAMAGE; + config.isAlwaysTriggeredAura = false; + + EXPECT_TRUE(ProcChanceTestHelper::ShouldAutoAddTriggeredCanProc(config)) + << "TAKEN proc + PROC_TRIGGER_DAMAGE should auto-add TRIGGERED_CAN_PROC"; +} + +TEST_F(TakenAutoTriggerTest, TakenProcOtherAura_DoesNotSetTriggeredCanProc) +{ + ProcChanceTestHelper::TakenAutoTriggerConfig config; + config.procFlags = PROC_FLAG_TAKEN_MELEE_AUTO_ATTACK; + config.auraName = SPELL_AURA_DUMMY; + config.isAlwaysTriggeredAura = false; + + EXPECT_FALSE(ProcChanceTestHelper::ShouldAutoAddTriggeredCanProc(config)) + << "TAKEN proc + DUMMY aura should NOT auto-add TRIGGERED_CAN_PROC"; +} + +TEST_F(TakenAutoTriggerTest, DoneProcTriggerSpell_DoesNotSetTriggeredCanProc) +{ + ProcChanceTestHelper::TakenAutoTriggerConfig config; + config.procFlags = PROC_FLAG_DONE_MELEE_AUTO_ATTACK; + config.auraName = SPELL_AURA_PROC_TRIGGER_SPELL; + config.isAlwaysTriggeredAura = false; + + EXPECT_FALSE(ProcChanceTestHelper::ShouldAutoAddTriggeredCanProc(config)) + << "DONE-only proc flags should NOT trigger TAKEN auto-add logic"; +} + +TEST_F(TakenAutoTriggerTest, NoProcFlags_DoesNotSetTriggeredCanProc) +{ + ProcChanceTestHelper::TakenAutoTriggerConfig config; + config.procFlags = 0; + config.auraName = SPELL_AURA_PROC_TRIGGER_SPELL; + config.isAlwaysTriggeredAura = false; + + EXPECT_FALSE(ProcChanceTestHelper::ShouldAutoAddTriggeredCanProc(config)) + << "Zero proc flags should NOT auto-add TRIGGERED_CAN_PROC"; +} + +TEST_F(TakenAutoTriggerTest, AlwaysTriggeredAura_StaysTrue) +{ + ProcChanceTestHelper::TakenAutoTriggerConfig config; + config.procFlags = 0; // No TAKEN flags + config.auraName = SPELL_AURA_DUMMY; + config.isAlwaysTriggeredAura = true; + + EXPECT_TRUE(ProcChanceTestHelper::ShouldAutoAddTriggeredCanProc(config)) + << "isAlwaysTriggeredAura should keep addTriggerFlag = true"; +} + +TEST_F(TakenAutoTriggerTest, TakenDamage_WithProcTriggerSpell_SetsFlag) +{ + // PROC_FLAG_TAKEN_DAMAGE is in TAKEN_HIT_PROC_FLAG_MASK + ProcChanceTestHelper::TakenAutoTriggerConfig config; + config.procFlags = PROC_FLAG_TAKEN_DAMAGE; + config.auraName = SPELL_AURA_PROC_TRIGGER_SPELL; + config.isAlwaysTriggeredAura = false; + + EXPECT_TRUE(ProcChanceTestHelper::ShouldAutoAddTriggeredCanProc(config)) + << "PROC_FLAG_TAKEN_DAMAGE + PROC_TRIGGER_SPELL should set flag"; +} + +TEST_F(TakenAutoTriggerTest, TakenPeriodic_WithProcTriggerDamage_SetsFlag) +{ + ProcChanceTestHelper::TakenAutoTriggerConfig config; + config.procFlags = PROC_FLAG_TAKEN_PERIODIC; + config.auraName = SPELL_AURA_PROC_TRIGGER_DAMAGE; + config.isAlwaysTriggeredAura = false; + + EXPECT_TRUE(ProcChanceTestHelper::ShouldAutoAddTriggeredCanProc(config)) + << "PROC_FLAG_TAKEN_PERIODIC + PROC_TRIGGER_DAMAGE should set flag"; +} + +TEST_F(TakenAutoTriggerTest, MixedDoneAndTaken_WithProcTriggerSpell_SetsFlag) +{ + // Both DONE and TAKEN flags present - TAKEN mask still matches + ProcChanceTestHelper::TakenAutoTriggerConfig config; + config.procFlags = PROC_FLAG_DONE_MELEE_AUTO_ATTACK + | PROC_FLAG_TAKEN_MELEE_AUTO_ATTACK; + config.auraName = SPELL_AURA_PROC_TRIGGER_SPELL; + config.isAlwaysTriggeredAura = false; + + EXPECT_TRUE(ProcChanceTestHelper::ShouldAutoAddTriggeredCanProc(config)) + << "Mixed DONE+TAKEN flags should still trigger TAKEN auto-add"; +} + +TEST_F(TakenAutoTriggerTest, TakenProcModAura_DoesNotSetTriggeredCanProc) +{ + // SPELL_AURA_ADD_FLAT_MODIFIER is not PROC_TRIGGER_SPELL/DAMAGE + ProcChanceTestHelper::TakenAutoTriggerConfig config; + config.procFlags = PROC_FLAG_TAKEN_MELEE_AUTO_ATTACK; + config.auraName = SPELL_AURA_ADD_FLAT_MODIFIER; + config.isAlwaysTriggeredAura = false; + + EXPECT_FALSE(ProcChanceTestHelper::ShouldAutoAddTriggeredCanProc(config)) + << "TAKEN proc + modifier aura should NOT auto-add TRIGGERED_CAN_PROC"; +} + +// ============================================================================= +// Real spell scenarios for TAKEN auto-trigger +// ============================================================================= + +TEST_F(TakenAutoTriggerTest, Scenario_MoltenArmor_TakenAutoTrigger) +{ + // Molten Armor (30482): TAKEN_MELEE_AUTO_ATTACK + PROC_TRIGGER_DAMAGE + // Note: Molten Armor has an explicit spell_proc entry so auto-gen + // is skipped, but the logic should still apply if it didn't + ProcChanceTestHelper::TakenAutoTriggerConfig config; + config.procFlags = PROC_FLAG_TAKEN_MELEE_AUTO_ATTACK + | PROC_FLAG_TAKEN_SPELL_MELEE_DMG_CLASS; + config.auraName = SPELL_AURA_PROC_TRIGGER_DAMAGE; + config.isAlwaysTriggeredAura = false; + + EXPECT_TRUE(ProcChanceTestHelper::ShouldAutoAddTriggeredCanProc(config)) + << "Molten Armor-like aura should auto-add TRIGGERED_CAN_PROC"; +} + +TEST_F(TakenAutoTriggerTest, Scenario_Reckoning_TakenAutoTrigger) +{ + // Reckoning (20177): TAKEN_MELEE_AUTO_ATTACK + PROC_TRIGGER_SPELL + ProcChanceTestHelper::TakenAutoTriggerConfig config; + config.procFlags = PROC_FLAG_TAKEN_MELEE_AUTO_ATTACK; + config.auraName = SPELL_AURA_PROC_TRIGGER_SPELL; + config.isAlwaysTriggeredAura = false; + + EXPECT_TRUE(ProcChanceTestHelper::ShouldAutoAddTriggeredCanProc(config)) + << "Reckoning-like aura should auto-add TRIGGERED_CAN_PROC"; +} + +TEST_F(TakenAutoTriggerTest, Scenario_Redoubt_TakenAutoTrigger) +{ + // Redoubt (20127): TAKEN_MELEE_AUTO_ATTACK + PROC_TRIGGER_SPELL + ProcChanceTestHelper::TakenAutoTriggerConfig config; + config.procFlags = PROC_FLAG_TAKEN_MELEE_AUTO_ATTACK; + config.auraName = SPELL_AURA_PROC_TRIGGER_SPELL; + config.isAlwaysTriggeredAura = false; + + EXPECT_TRUE(ProcChanceTestHelper::ShouldAutoAddTriggeredCanProc(config)) + << "Redoubt-like aura should auto-add TRIGGERED_CAN_PROC"; +} + +// ============================================================================= +// Integration: TAKEN auto-trigger affects triggered spell filtering +// ============================================================================= + +TEST_F(TakenAutoTriggerTest, AutoGenTriggeredCanProc_AllowsTriggeredSpells) +{ + // Verify that when TAKEN auto-trigger sets addTriggerFlag, + // the resulting proc entry with PROC_ATTR_TRIGGERED_CAN_PROC + // allows triggered spells through the filter + + // Step 1: Auto-trigger logic says yes + ProcChanceTestHelper::TakenAutoTriggerConfig autoConfig; + autoConfig.procFlags = PROC_FLAG_TAKEN_MELEE_AUTO_ATTACK; + autoConfig.auraName = SPELL_AURA_PROC_TRIGGER_SPELL; + ASSERT_TRUE(ProcChanceTestHelper::ShouldAutoAddTriggeredCanProc(autoConfig)); + + // Step 2: Build proc entry with the auto-added attribute + auto procEntry = SpellProcEntryBuilder() + .WithProcFlags(PROC_FLAG_TAKEN_MELEE_AUTO_ATTACK) + .WithAttributesMask(PROC_ATTR_TRIGGERED_CAN_PROC) + .WithChance(100.0f) + .Build(); + + // Step 3: Verify triggered spells pass through the filter + ProcChanceTestHelper::TriggeredSpellConfig trigConfig; + trigConfig.isTriggered = true; // e.g. Judgement damage is triggered + trigConfig.auraHasCanProcFromProcs = false; + trigConfig.spellHasNotAProc = false; + + EXPECT_FALSE(ProcChanceTestHelper::ShouldBlockTriggeredSpell( + trigConfig, procEntry, PROC_FLAG_TAKEN_MELEE_AUTO_ATTACK)) + << "Auto-generated TRIGGERED_CAN_PROC should allow triggered spells"; +} + +TEST_F(TakenAutoTriggerTest, WithoutAutoTrigger_TriggeredSpellsBlocked) +{ + // Without the TAKEN auto-trigger, DONE-only proc auras would block + // triggered spells (no TRIGGERED_CAN_PROC set) + + ProcChanceTestHelper::TakenAutoTriggerConfig autoConfig; + autoConfig.procFlags = PROC_FLAG_DONE_MELEE_AUTO_ATTACK; // DONE only + autoConfig.auraName = SPELL_AURA_PROC_TRIGGER_SPELL; + ASSERT_FALSE(ProcChanceTestHelper::ShouldAutoAddTriggeredCanProc(autoConfig)); + + // Build proc entry WITHOUT TRIGGERED_CAN_PROC + auto procEntry = SpellProcEntryBuilder() + .WithProcFlags(PROC_FLAG_DONE_MELEE_AUTO_ATTACK) + .WithAttributesMask(0) // No TRIGGERED_CAN_PROC + .WithChance(100.0f) + .Build(); + + // Triggered spells should be blocked + ProcChanceTestHelper::TriggeredSpellConfig trigConfig; + trigConfig.isTriggered = true; + trigConfig.auraHasCanProcFromProcs = false; + trigConfig.spellHasNotAProc = false; + + EXPECT_TRUE(ProcChanceTestHelper::ShouldBlockTriggeredSpell( + trigConfig, procEntry, PROC_FLAG_DONE_SPELL_MAGIC_DMG_CLASS_NEG)) + << "Without TRIGGERED_CAN_PROC, triggered spells should be blocked"; +} + +// ============================================================================= +// Proc Chain Guard Tests - simulates Unit::TriggerAurasProcOnEvent +// ============================================================================= + +class ProcChainGuardTest : public ::testing::Test +{ +protected: + void SetUp() override {} + + ProcChainGuardSimulator _sim; +}; + +// ----------------------------------------------------------------------------- +// TRIGGERED_DISALLOW_PROC_EVENTS behavior +// ----------------------------------------------------------------------------- + +TEST_F(ProcChainGuardTest, DisallowProcEvents_BlocksAllAuras) +{ + // When triggering spell has TRIGGERED_DISALLOW_PROC_EVENTS, + // all auras should fire with CanProc() == false + std::vector auras = { + {.spellId = 100, .hasInstantTargetProcs = false}, + {.spellId = 200, .hasInstantTargetProcs = false}, + {.spellId = 300, .hasInstantTargetProcs = false}, + }; + + _sim.SimulateTriggerAurasProc(/*disallowProcEvents=*/true, auras); + + auto const& records = _sim.GetRecords(); + ASSERT_EQ(records.size(), 3u); + + for (auto const& rec : records) + { + EXPECT_FALSE(rec.canProcDuringTrigger) + << "Aura " << rec.spellId + << " should have CanProc()=false with DISALLOW_PROC_EVENTS"; + EXPECT_EQ(rec.procDeepDuringTrigger, 1) + << "m_procDeep should be 1 for all auras"; + } +} + +TEST_F(ProcChainGuardTest, DisallowProcEvents_CounterBalanced) +{ + std::vector auras = { + {.spellId = 100}, + }; + + _sim.SimulateTriggerAurasProc(/*disallowProcEvents=*/true, auras); + + EXPECT_EQ(_sim.GetProcDeep(), 0) + << "m_procDeep should return to 0 after function exits"; + EXPECT_TRUE(_sim.CanProc()) + << "CanProc() should be true after function exits"; +} + +TEST_F(ProcChainGuardTest, NoDisallowProcEvents_AllAurasCanProc) +{ + std::vector auras = { + {.spellId = 100, .hasInstantTargetProcs = false}, + {.spellId = 200, .hasInstantTargetProcs = false}, + }; + + _sim.SimulateTriggerAurasProc(/*disallowProcEvents=*/false, auras); + + auto const& records = _sim.GetRecords(); + ASSERT_EQ(records.size(), 2u); + + for (auto const& rec : records) + { + EXPECT_TRUE(rec.canProcDuringTrigger) + << "Aura " << rec.spellId + << " should have CanProc()=true without DISALLOW_PROC_EVENTS"; + EXPECT_EQ(rec.procDeepDuringTrigger, 0); + } +} + +// ----------------------------------------------------------------------------- +// SPELL_ATTR3_INSTANT_TARGET_PROCS per-aura behavior +// ----------------------------------------------------------------------------- + +TEST_F(ProcChainGuardTest, InstantTargetProcs_BlocksDuringSpecificAura) +{ + std::vector auras = { + {.spellId = 100, .hasInstantTargetProcs = false}, + {.spellId = 200, .hasInstantTargetProcs = true}, // This one blocks + {.spellId = 300, .hasInstantTargetProcs = false}, + }; + + _sim.SimulateTriggerAurasProc(/*disallowProcEvents=*/false, auras); + + auto const& records = _sim.GetRecords(); + ASSERT_EQ(records.size(), 3u); + + // Aura 100: normal, can proc + EXPECT_TRUE(records[0].canProcDuringTrigger) + << "First aura (no INSTANT_TARGET_PROCS) should allow procs"; + EXPECT_EQ(records[0].procDeepDuringTrigger, 0); + + // Aura 200: has INSTANT_TARGET_PROCS, blocked + EXPECT_FALSE(records[1].canProcDuringTrigger) + << "Aura with INSTANT_TARGET_PROCS should block procs during its trigger"; + EXPECT_EQ(records[1].procDeepDuringTrigger, 1); + + // Aura 300: normal again, can proc (counter was decremented) + EXPECT_TRUE(records[2].canProcDuringTrigger) + << "Next aura after INSTANT_TARGET_PROCS should allow procs again"; + EXPECT_EQ(records[2].procDeepDuringTrigger, 0); +} + +TEST_F(ProcChainGuardTest, InstantTargetProcs_CounterBalanced) +{ + std::vector auras = { + {.spellId = 100, .hasInstantTargetProcs = true}, + }; + + _sim.SimulateTriggerAurasProc(/*disallowProcEvents=*/false, auras); + + EXPECT_EQ(_sim.GetProcDeep(), 0) + << "m_procDeep should return to 0 after INSTANT_TARGET_PROCS aura"; + EXPECT_TRUE(_sim.CanProc()); +} + +TEST_F(ProcChainGuardTest, MultipleInstantTargetProcs_EachIndependent) +{ + std::vector auras = { + {.spellId = 100, .hasInstantTargetProcs = true}, + {.spellId = 200, .hasInstantTargetProcs = true}, + {.spellId = 300, .hasInstantTargetProcs = true}, + }; + + _sim.SimulateTriggerAurasProc(/*disallowProcEvents=*/false, auras); + + auto const& records = _sim.GetRecords(); + ASSERT_EQ(records.size(), 3u); + + // Each aura should independently block during its own trigger + for (auto const& rec : records) + { + EXPECT_FALSE(rec.canProcDuringTrigger) + << "Aura " << rec.spellId + << " with INSTANT_TARGET_PROCS should block during trigger"; + EXPECT_EQ(rec.procDeepDuringTrigger, 1) + << "Each aura should increment to exactly 1 (not accumulate)"; + } + + EXPECT_EQ(_sim.GetProcDeep(), 0) + << "Counter should be balanced after all auras processed"; +} + +// ----------------------------------------------------------------------------- +// Combined: DISALLOW_PROC_EVENTS + INSTANT_TARGET_PROCS +// ----------------------------------------------------------------------------- + +TEST_F(ProcChainGuardTest, Combined_DisallowAndInstantTarget_StackCorrectly) +{ + std::vector auras = { + {.spellId = 100, .hasInstantTargetProcs = false}, + {.spellId = 200, .hasInstantTargetProcs = true}, + {.spellId = 300, .hasInstantTargetProcs = false}, + }; + + _sim.SimulateTriggerAurasProc(/*disallowProcEvents=*/true, auras); + + auto const& records = _sim.GetRecords(); + ASSERT_EQ(records.size(), 3u); + + // Aura 100: disableProcs active, procDeep=1 + EXPECT_FALSE(records[0].canProcDuringTrigger); + EXPECT_EQ(records[0].procDeepDuringTrigger, 1); + + // Aura 200: disableProcs + INSTANT_TARGET_PROCS, procDeep=2 + EXPECT_FALSE(records[1].canProcDuringTrigger); + EXPECT_EQ(records[1].procDeepDuringTrigger, 2) + << "DISALLOW_PROC_EVENTS + INSTANT_TARGET_PROCS should stack to 2"; + + // Aura 300: back to just disableProcs, procDeep=1 + EXPECT_FALSE(records[2].canProcDuringTrigger); + EXPECT_EQ(records[2].procDeepDuringTrigger, 1); + + EXPECT_EQ(_sim.GetProcDeep(), 0) + << "Counter should be balanced after combined scenario"; +} + +// ----------------------------------------------------------------------------- +// Removed aura handling +// ----------------------------------------------------------------------------- + +TEST_F(ProcChainGuardTest, RemovedAura_SkippedInLoop) +{ + std::vector auras = { + {.spellId = 100, .hasInstantTargetProcs = false, .isRemoved = false}, + {.spellId = 200, .hasInstantTargetProcs = true, .isRemoved = true}, + {.spellId = 300, .hasInstantTargetProcs = false, .isRemoved = false}, + }; + + _sim.SimulateTriggerAurasProc(/*disallowProcEvents=*/false, auras); + + auto const& records = _sim.GetRecords(); + ASSERT_EQ(records.size(), 2u) + << "Removed aura should be skipped"; + + EXPECT_EQ(records[0].spellId, 100u); + EXPECT_EQ(records[1].spellId, 300u); + + // The INSTANT_TARGET_PROCS aura was removed, so it shouldn't affect + // the counter for the next aura + EXPECT_TRUE(records[1].canProcDuringTrigger); +} + +TEST_F(ProcChainGuardTest, RemovedAuraWithInstantProcs_DoesNotAffectCounter) +{ + std::vector auras = { + {.spellId = 100, .hasInstantTargetProcs = true, .isRemoved = true}, + }; + + _sim.SimulateTriggerAurasProc(/*disallowProcEvents=*/false, auras); + + EXPECT_EQ(_sim.GetRecords().size(), 0u); + EXPECT_EQ(_sim.GetProcDeep(), 0) + << "Removed aura should not touch the proc deep counter"; +} + +// ----------------------------------------------------------------------------- +// Empty container +// ----------------------------------------------------------------------------- + +TEST_F(ProcChainGuardTest, EmptyContainer_NoEffect) +{ + std::vector auras; + + _sim.SimulateTriggerAurasProc(/*disallowProcEvents=*/false, auras); + + EXPECT_EQ(_sim.GetRecords().size(), 0u); + EXPECT_EQ(_sim.GetProcDeep(), 0); +} + +TEST_F(ProcChainGuardTest, EmptyContainer_WithDisallowProc_StillBalanced) +{ + std::vector auras; + + _sim.SimulateTriggerAurasProc(/*disallowProcEvents=*/true, auras); + + EXPECT_EQ(_sim.GetRecords().size(), 0u); + EXPECT_EQ(_sim.GetProcDeep(), 0) + << "Even with disableProcs and empty container, counter should balance"; +} + +// ============================================================================= +// Real spell scenarios for proc chain guard +// ============================================================================= + +TEST_F(ProcChainGuardTest, Scenario_MoltenArmorVsEyeForAnEye) +{ + // Molten Armor (43046) has SPELL_ATTR3_INSTANT_TARGET_PROCS + // When Molten Armor deals fire damage to an attacker, that damage + // should not trigger the attacker's reactive procs (Eye for an Eye) + // back on the mage, preventing infinite ping-pong + + // Mage's perspective: paladin hits mage, mage's auras proc + std::vector mageAuras = { + {.spellId = 43046, .hasInstantTargetProcs = true}, // Molten Armor + }; + + // The paladin's melee hit is NOT TRIGGERED_DISALLOW_PROC_EVENTS + _sim.SimulateTriggerAurasProc(/*disallowProcEvents=*/false, mageAuras); + + auto const& records = _sim.GetRecords(); + ASSERT_EQ(records.size(), 1u); + + // During Molten Armor's proc trigger, CanProc() is false + // This means the fire damage it deals cannot trigger further procs + EXPECT_FALSE(records[0].canProcDuringTrigger) + << "Molten Armor proc should block cascading procs (prevents EfaE loop)"; + + EXPECT_EQ(_sim.GetProcDeep(), 0) + << "Counter balanced after Molten Armor scenario"; +} + +TEST_F(ProcChainGuardTest, Scenario_SealOfRighteousness_TriggeredDamage) +{ + // SoR bonus damage is triggered with TRIGGERED_DISALLOW_PROC_EVENTS + // It should not trigger the target's reactive damage auras back + + std::vector targetAuras = { + {.spellId = 43046, .hasInstantTargetProcs = true}, // Molten Armor + {.spellId = 12345, .hasInstantTargetProcs = false}, // Some other aura + }; + + _sim.SimulateTriggerAurasProc(/*disallowProcEvents=*/true, targetAuras); + + auto const& records = _sim.GetRecords(); + ASSERT_EQ(records.size(), 2u); + + // All auras should see CanProc() = false + for (auto const& rec : records) + { + EXPECT_FALSE(rec.canProcDuringTrigger) + << "All target auras should be blocked when SoR damage" + << " has DISALLOW_PROC_EVENTS"; + } +} + +TEST_F(ProcChainGuardTest, Scenario_NormalMeleeHit_AurasCanProc) +{ + // A normal melee swing should allow all auras to proc normally + // (no DISALLOW_PROC_EVENTS, no INSTANT_TARGET_PROCS) + + std::vector targetAuras = { + {.spellId = 20177, .hasInstantTargetProcs = false}, // Reckoning + {.spellId = 20127, .hasInstantTargetProcs = false}, // Redoubt + {.spellId = 16958, .hasInstantTargetProcs = false}, // Blood Craze + }; + + _sim.SimulateTriggerAurasProc(/*disallowProcEvents=*/false, targetAuras); + + auto const& records = _sim.GetRecords(); + ASSERT_EQ(records.size(), 3u); + + for (auto const& rec : records) + { + EXPECT_TRUE(rec.canProcDuringTrigger) + << "Aura " << rec.spellId + << " should proc normally from a regular melee hit"; + EXPECT_EQ(rec.procDeepDuringTrigger, 0); + } +}