diff --git a/src/test/mocks/IntegrationTestFixture.h b/src/test/mocks/IntegrationTestFixture.h new file mode 100644 index 0000000000..494279a880 --- /dev/null +++ b/src/test/mocks/IntegrationTestFixture.h @@ -0,0 +1,189 @@ +/* + * 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 . + */ + +#ifndef AZEROTHCORE_INTEGRATION_TEST_FIXTURE_H +#define AZEROTHCORE_INTEGRATION_TEST_FIXTURE_H + +#include "TestMap.h" +#include "TestPlayer.h" +#include "TestCreature.h" +#include "WorldMock.h" +#include "WorldSession.h" +#include "DBCStores.h" +#include "SharedDefines.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include + +using namespace testing; + +// Faction template IDs for test creatures +static constexpr uint32 TEST_FACTION_HOSTILE_TO_MONSTERS = 90001; +static constexpr uint32 TEST_FACTION_HOSTILE_TO_ALL = 90002; + +class IntegrationTestFixture : public ::testing::Test +{ +protected: + void SetUp() override + { + TestMap::EnsureDBC(); + EnsureFactionTemplates(); + + _originalWorld = sWorld.release(); + _worldMock = new NiceMock(); + sWorld.reset(_worldMock); + + static std::string emptyString; + ON_CALL(*_worldMock, GetDataPath()).WillByDefault(ReturnRef(emptyString)); + ON_CALL(*_worldMock, GetRealmName()).WillByDefault(ReturnRef(emptyString)); + ON_CALL(*_worldMock, GetDefaultDbcLocale()).WillByDefault(Return(LOCALE_enUS)); + ON_CALL(*_worldMock, getRate(_)).WillByDefault(Return(1.0f)); + ON_CALL(*_worldMock, getBoolConfig(_)).WillByDefault(Return(false)); + ON_CALL(*_worldMock, getIntConfig(_)).WillByDefault(Return(0)); + ON_CALL(*_worldMock, getFloatConfig(_)).WillByDefault(Return(0.0f)); + ON_CALL(*_worldMock, GetPlayerSecurityLimit()).WillByDefault(Return(SEC_PLAYER)); + + _testMap = new TestMap(); + } + + void TearDown() override + { + for (auto* creature : _trackedCreatures) + { + if (creature->IsInWorld()) + creature->RemoveFromWorld(); + delete creature; + } + _trackedCreatures.clear(); + + for (auto* player : _trackedPlayers) + { + if (player->IsInWorld()) + player->RemoveFromWorld(); + } + _trackedPlayers.clear(); + + for (auto* tmpl : _ownedCreatureTemplates) + delete tmpl; + _ownedCreatureTemplates.clear(); + + delete _testMap; + _testMap = nullptr; + + IWorld* currentWorld = sWorld.release(); + delete currentWorld; + _worldMock = nullptr; + + sWorld.reset(_originalWorld); + _originalWorld = nullptr; + + // Intentional leaks of session/player to avoid database access in destructors + } + + TestPlayer* CreateTestPlayer(ObjectGuid::LowType guidLow = 1, + std::string const& name = "TestPlayer", + AccountTypes security = SEC_PLAYER) + { + auto* session = new WorldSession(guidLow, std::string(name), 0, nullptr, security, + EXPANSION_WRATH_OF_THE_LICH_KING, 0, LOCALE_enUS, 0, false, false, 0); + + auto* player = new TestPlayer(session); + player->ForceInitValues(guidLow); + session->SetPlayer(player); + player->SetSession(session); + player->SetMap(_testMap); + player->AddToWorld(); + _trackedPlayers.push_back(player); + + return player; + } + + TestCreature* CreateTestCreature(ObjectGuid::LowType guidLow, uint32 entry, uint32 factionId) + { + // CreatureTemplate must be heap-allocated AFTER WorldMock is installed + // because CreatureMovementData() constructor calls sWorld->getIntConfig() + auto* tmpl = new CreatureTemplate(); + tmpl->Entry = entry; + tmpl->faction = factionId; + tmpl->minlevel = 80; + tmpl->maxlevel = 80; + tmpl->unit_class = CLASS_WARRIOR; + tmpl->DamageModifier = 1.0f; + tmpl->BaseAttackTime = 2000; + tmpl->RangeAttackTime = 2000; + tmpl->BaseVariance = 1.0f; + tmpl->RangeVariance = 1.0f; + tmpl->ModHealth = 1.0f; + _ownedCreatureTemplates.push_back(tmpl); + + auto* creature = new TestCreature(); + creature->ForceInitValues(guidLow, entry); + creature->SetTestMap(_testMap); + creature->SetInWorld(true); + creature->SetAlive(true); + creature->SetPhase(1); + creature->SetFaction(factionId); + creature->SetLevel(80); + creature->SetMaxHealth(10000); + creature->SetHealth(10000); + creature->InitializeThreatManager(); + _trackedCreatures.push_back(creature); + + return creature; + } + + NiceMock* GetWorldMock() { return _worldMock; } + TestMap* GetTestMap() { return _testMap; } + +private: + static void EnsureFactionTemplates() + { + static bool initialized = false; + if (initialized) + return; + initialized = true; + + // Faction 90001: "player-like" — ourMask=1 (FACTION_MASK_PLAYER), hostile to monsters (hostileMask=8) + auto* f1 = new FactionTemplateEntry(); + std::memset(f1, 0, sizeof(FactionTemplateEntry)); + f1->ID = TEST_FACTION_HOSTILE_TO_MONSTERS; + f1->faction = TEST_FACTION_HOSTILE_TO_MONSTERS; + f1->ourMask = 0x1; // FACTION_MASK_PLAYER + f1->friendlyMask = 0x1; // friendly to players + f1->hostileMask = 0x8; // hostile to monsters + sFactionTemplateStore.SetEntry(TEST_FACTION_HOSTILE_TO_MONSTERS, f1); + + // Faction 90002: "monster" — ourMask=8 (FACTION_MASK_MONSTER), hostile to players (hostileMask=1) + auto* f2 = new FactionTemplateEntry(); + std::memset(f2, 0, sizeof(FactionTemplateEntry)); + f2->ID = TEST_FACTION_HOSTILE_TO_ALL; + f2->faction = TEST_FACTION_HOSTILE_TO_ALL; + f2->ourMask = 0x8; // FACTION_MASK_MONSTER + f2->friendlyMask = 0x0; // friendly to none + f2->hostileMask = 0x1; // hostile to players + sFactionTemplateStore.SetEntry(TEST_FACTION_HOSTILE_TO_ALL, f2); + } + + IWorld* _originalWorld = nullptr; + NiceMock* _worldMock = nullptr; + TestMap* _testMap = nullptr; + std::vector _trackedPlayers; + std::vector _trackedCreatures; + std::vector _ownedCreatureTemplates; +}; + +#endif //AZEROTHCORE_INTEGRATION_TEST_FIXTURE_H diff --git a/src/test/mocks/SpellInfoTestHelper.h b/src/test/mocks/SpellInfoTestHelper.h index 9828234794..2b5a14eca9 100644 --- a/src/test/mocks/SpellInfoTestHelper.h +++ b/src/test/mocks/SpellInfoTestHelper.h @@ -128,6 +128,13 @@ public: return *this; } + TestSpellEntryHelper& WithEffectMiscValue(uint8 effIndex, int32 miscValue) + { + if (effIndex < MAX_SPELL_EFFECTS) + _entry.EffectMiscValue[effIndex] = miscValue; + return *this; + } + TestSpellEntryHelper& WithEffectDieSides(uint8 effIndex, int32 dieSides) { if (effIndex < MAX_SPELL_EFFECTS) @@ -233,6 +240,12 @@ public: return *this; } + SpellInfoBuilder& WithEffectMiscValue(uint8 effIndex, int32 miscValue) + { + _entryHelper.WithEffectMiscValue(effIndex, miscValue); + return *this; + } + SpellInfoBuilder& WithEffectDieSides(uint8 effIndex, int32 dieSides) { _entryHelper.WithEffectDieSides(effIndex, dieSides); diff --git a/src/test/mocks/TestMap.cpp b/src/test/mocks/TestMap.cpp index 012f6eeeeb..43352c50eb 100644 --- a/src/test/mocks/TestMap.cpp +++ b/src/test/mocks/TestMap.cpp @@ -17,10 +17,13 @@ #include "DBCStores.h" #include "ScriptMgr.h" #include "ScriptDefines/AllMapScript.h" +#include "ScriptDefines/AllSpellScript.h" #include "ScriptDefines/GlobalScript.h" #include "ScriptDefines/MiscScript.h" +#include "ScriptDefines/PlayerScript.h" #include "ScriptDefines/UnitScript.h" #include "ScriptDefines/WorldObjectScript.h" +#include "ScriptDefines/CommandScript.h" TestMap::TestMap() : Map(0, 0, REGULAR_DIFFICULTY, nullptr) @@ -55,8 +58,11 @@ TestMap::~TestMap() // Initialize all script registries so CALL_ENABLED_HOOKS doesn't // crash on uninitialized vectors during Object/Unit/Map operations ScriptRegistry::InitEnabledHooksIfNeeded(ALLMAPHOOK_END); + ScriptRegistry::InitEnabledHooksIfNeeded(ALLSPELLHOOK_END); ScriptRegistry::InitEnabledHooksIfNeeded(GLOBALHOOK_END); ScriptRegistry::InitEnabledHooksIfNeeded(MISCHOOK_END); + ScriptRegistry::InitEnabledHooksIfNeeded(PLAYERHOOK_END); ScriptRegistry::InitEnabledHooksIfNeeded(UNITHOOK_END); ScriptRegistry::InitEnabledHooksIfNeeded(WORLDOBJECTHOOK_END); + ScriptRegistry::InitEnabledHooksIfNeeded(ALLCOMMANDHOOK_END); } diff --git a/src/test/mocks/TestPlayer.h b/src/test/mocks/TestPlayer.h new file mode 100644 index 0000000000..e827ff9ba4 --- /dev/null +++ b/src/test/mocks/TestPlayer.h @@ -0,0 +1,42 @@ +/* + * 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 . + */ + +#ifndef AZEROTHCORE_TEST_PLAYER_H +#define AZEROTHCORE_TEST_PLAYER_H + +#include "Player.h" + +class TestPlayer : public Player +{ +public: + using Player::Player; + + void UpdateObjectVisibility(bool /*forced*/ = true, bool /*fromUpdate*/ = false) override { } + + void AddToWorld() override { Object::AddToWorld(); } + void RemoveFromWorld() override { Object::RemoveFromWorld(); } + + void SaveToDB(bool /*create*/, bool /*logout*/) { } + void SaveToDB(CharacterDatabaseTransaction /*trans*/, bool /*create*/, bool /*logout*/) { } + + void ForceInitValues(ObjectGuid::LowType guidLow = 1) + { + Object::_Create(guidLow, uint32(0), HighGuid::Player); + } +}; + +#endif //AZEROTHCORE_TEST_PLAYER_H diff --git a/src/test/server/game/Commands/GmVisibleCommandTest.cpp b/src/test/server/game/Commands/GmVisibleCommandTest.cpp index f916b079e4..4be3b06e9b 100644 --- a/src/test/server/game/Commands/GmVisibleCommandTest.cpp +++ b/src/test/server/game/Commands/GmVisibleCommandTest.cpp @@ -15,16 +15,13 @@ * with this program. If not, see . */ -#include "Player.h" +#include "TestPlayer.h" +#include "TestMap.h" #include "ScriptMgr.h" #include "WorldSession.h" #include "WorldMock.h" #include "ObjectGuid.h" -#include "ScriptDefines/MiscScript.h" #include "ScriptDefines/PlayerScript.h" -#include "ScriptDefines/WorldObjectScript.h" -#include "ScriptDefines/UnitScript.h" -#include "ScriptDefines/CommandScript.h" #include "SharedDefines.h" #include "gmock/gmock.h" #include "gtest/gtest.h" @@ -68,25 +65,12 @@ public: inline static AccountTypes LastSecurity = SEC_PLAYER; }; -class TestPlayer : public Player -{ -public: - using Player::Player; - - void UpdateObjectVisibility(bool /*forced*/ = true, bool /*fromUpdate*/ = false) override { } - - void ForceInitValues(ObjectGuid::LowType guidLow = 1) - { - Object::_Create(guidLow, uint32(0), HighGuid::Player); - } -}; - class GmVisibleCommandTest : public ::testing::Test { protected: void SetUp() override { - EnsureScriptRegistriesInitialized(); + TestMap::EnsureDBC(); TestVisibilityScript::EnsureRegistered(); @@ -139,20 +123,6 @@ protected: player->SetServerSideVisibility(SERVERSIDE_VISIBILITY_GM, SEC_PLAYER); } - static void EnsureScriptRegistriesInitialized() - { - static bool initialized = false; - if (!initialized) - { - ScriptRegistry::InitEnabledHooksIfNeeded(MISCHOOK_END); - ScriptRegistry::InitEnabledHooksIfNeeded(WORLDOBJECTHOOK_END); - ScriptRegistry::InitEnabledHooksIfNeeded(UNITHOOK_END); - ScriptRegistry::InitEnabledHooksIfNeeded(PLAYERHOOK_END); - ScriptRegistry::InitEnabledHooksIfNeeded(ALLCOMMANDHOOK_END); - initialized = true; - } - } - IWorld* originalWorld = nullptr; NiceMock* worldMock = nullptr; WorldSession* session = nullptr; diff --git a/src/test/server/game/Modules/OmenOfClarityGlyphLockTest.cpp b/src/test/server/game/Modules/OmenOfClarityGlyphLockTest.cpp new file mode 100644 index 0000000000..4563a29a5d --- /dev/null +++ b/src/test/server/game/Modules/OmenOfClarityGlyphLockTest.cpp @@ -0,0 +1,300 @@ +/* + * Unit test for the mod-omen-of-clarity glyph slot locking behavior. + * + * Verifies that when the "empowered omen" feature is enabled for a player, + * the major glyph slot 5 (bit 0x20 in PLAYER_GLYPHS_ENABLED) stays locked + * even after leveling to 80 (which normally enables that slot). + * + * Because the module's internal cache (s_oocEnabled) and helper functions + * are static to its .cpp file, this test uses a test-local PlayerScript + * that replicates the same glyph-locking logic the module uses. This + * proves the mechanism works end-to-end through the hook system. + */ + +#include "TestPlayer.h" +#include "TestMap.h" +#include "ScriptMgr.h" +#include "WorldSession.h" +#include "WorldMock.h" +#include "ObjectGuid.h" +#include "ScriptDefines/PlayerScript.h" +#include "SharedDefines.h" +#include "gtest/gtest.h" + +#ifndef TEST_F +#define TEST_F(fixture, name) void fixture##_##name() +#endif + +#include + +using namespace testing; + +namespace +{ + +// Constants matching the module's values +static constexpr uint8 OOC_LOCKED_GLYPH_SLOT = 5; +static constexpr uint32 OOC_LOCKED_SLOT_BIT = 0x20; + +// Test-local cache mirroring the module's s_oocEnabled +static std::unordered_set s_testOocEnabled; + +// ------------------------------------------------------- +// Test PlayerScript: replicates the module's level-change +// and quest-complete hooks for glyph slot locking. +// ------------------------------------------------------- +class TestOocPlayerScript : public PlayerScript +{ +public: + TestOocPlayerScript() + : PlayerScript("TestOocPlayerScript", + { PLAYERHOOK_ON_LEVEL_CHANGED, + PLAYERHOOK_ON_PLAYER_COMPLETE_QUEST }) { } + + void OnPlayerLevelChanged(Player* player, + uint8 /*oldLevel*/) override + { + if (s_testOocEnabled.count( + player->GetGUID().GetCounter())) + { + // Same logic as EnsureGlyphSlotLocked in the module + uint32 bits = player->GetUInt32Value( + PLAYER_GLYPHS_ENABLED); + if (bits & OOC_LOCKED_SLOT_BIT) + { + player->SetUInt32Value(PLAYER_GLYPHS_ENABLED, + bits & ~OOC_LOCKED_SLOT_BIT); + } + } + } + + void OnPlayerCompleteQuest(Player* player, + Quest const* /*quest*/) override + { + // Simplified: always enable for the completing player + s_testOocEnabled.insert( + player->GetGUID().GetCounter()); + + // Lock the slot (clear bit 0x20) + uint32 bits = player->GetUInt32Value( + PLAYER_GLYPHS_ENABLED); + player->SetUInt32Value(PLAYER_GLYPHS_ENABLED, + bits & ~OOC_LOCKED_SLOT_BIT); + } + + static void EnsureRegistered() + { + if (!Instance) + Instance = new TestOocPlayerScript(); + } + + inline static TestOocPlayerScript* Instance = nullptr; +}; + +// ------------------------------------------------------- +// Test fixture +// ------------------------------------------------------- +class OmenOfClarityGlyphLockTest : public ::testing::Test +{ +protected: + void SetUp() override + { + TestMap::EnsureDBC(); + TestOocPlayerScript::EnsureRegistered(); + + originalWorld = sWorld.release(); + worldMock = new NiceMock(); + sWorld.reset(worldMock); + + static std::string emptyString; + ON_CALL(*worldMock, GetDataPath()) + .WillByDefault(ReturnRef(emptyString)); + ON_CALL(*worldMock, GetRealmName()) + .WillByDefault(ReturnRef(emptyString)); + ON_CALL(*worldMock, GetDefaultDbcLocale()) + .WillByDefault(Return(LOCALE_enUS)); + ON_CALL(*worldMock, getRate(_)) + .WillByDefault(Return(1.0f)); + ON_CALL(*worldMock, getBoolConfig(_)) + .WillByDefault(Return(false)); + ON_CALL(*worldMock, getIntConfig(_)) + .WillByDefault(Return(0)); + ON_CALL(*worldMock, getFloatConfig(_)) + .WillByDefault(Return(0.0f)); + ON_CALL(*worldMock, GetPlayerSecurityLimit()) + .WillByDefault(Return(SEC_PLAYER)); + + session = new WorldSession( + 1, "test", 0, nullptr, SEC_PLAYER, + EXPANSION_WRATH_OF_THE_LICH_KING, + 0, LOCALE_enUS, 0, false, false, 0); + + player = new TestPlayer(session); + player->ForceInitValues(42); + session->SetPlayer(player); + player->SetSession(session); + + s_testOocEnabled.clear(); + } + + void TearDown() override + { + s_testOocEnabled.clear(); + + // Intentional leaks of session/player to avoid + // database access in destructors. + IWorld* currentWorld = sWorld.release(); + delete currentWorld; + worldMock = nullptr; + + sWorld.reset(originalWorld); + originalWorld = nullptr; + session = nullptr; + player = nullptr; + } + + /// Simulate what InitGlyphsForLevel() does for a given level. + void SimulateGlyphsForLevel(uint8 level) + { + uint32 value = 0; + if (level >= 15) + value |= (0x01 | 0x02); + if (level >= 30) + value |= 0x08; + if (level >= 50) + value |= 0x04; + if (level >= 70) + value |= 0x10; + if (level >= 80) + value |= 0x20; + player->SetUInt32Value(PLAYER_GLYPHS_ENABLED, value); + } + + IWorld* originalWorld = nullptr; + NiceMock* worldMock = nullptr; + WorldSession* session = nullptr; + TestPlayer* player = nullptr; +}; + +// ---------------------------------------------------------- +// TEST: Feature enabled → level to 80 → slot stays locked +// ---------------------------------------------------------- +TEST_F(OmenOfClarityGlyphLockTest, + GlyphSlotStaysLockedAfterLevelingTo80) +{ + // 1. Enable the empowered omen feature for this player + s_testOocEnabled.insert( + player->GetGUID().GetCounter()); + + // 2. Lock glyph slot 5 (clear bit 0x20) as the module + // does on quest completion + SimulateGlyphsForLevel(79); // slots for level 79 (no 0x20) + uint32 bitsAtLevel79 = + player->GetUInt32Value(PLAYER_GLYPHS_ENABLED); + EXPECT_EQ(bitsAtLevel79 & OOC_LOCKED_SLOT_BIT, 0u) + << "Slot 5 should not be enabled before level 80"; + + // 3. Simulate hitting level 80: + // The game calls InitGlyphsForLevel() which sets 0x20, + // THEN fires OnPlayerLevelChanged. + SimulateGlyphsForLevel(80); + uint32 bitsAfterInit = + player->GetUInt32Value(PLAYER_GLYPHS_ENABLED); + EXPECT_NE(bitsAfterInit & OOC_LOCKED_SLOT_BIT, 0u) + << "InitGlyphsForLevel should enable slot 5 at 80"; + + // 4. Fire the level-change hook (same as GiveLevel does) + sScriptMgr->OnPlayerLevelChanged(player, 79); + + // 5. Verify the hook cleared the bit + uint32 bitsAfterHook = + player->GetUInt32Value(PLAYER_GLYPHS_ENABLED); + EXPECT_EQ(bitsAfterHook & OOC_LOCKED_SLOT_BIT, 0u) + << "Module hook should re-lock slot 5 after level 80"; + + // Other glyph slots should be unaffected + EXPECT_NE(bitsAfterHook & 0x01, 0u); // slot 0 (level 15) + EXPECT_NE(bitsAfterHook & 0x02, 0u); // slot 1 (level 15) + EXPECT_NE(bitsAfterHook & 0x04, 0u); // slot 2 (level 50) + EXPECT_NE(bitsAfterHook & 0x08, 0u); // slot 3 (level 30) + EXPECT_NE(bitsAfterHook & 0x10, 0u); // slot 4 (level 70) +} + +// ---------------------------------------------------------- +// TEST: Feature NOT enabled → level to 80 → slot stays open +// ---------------------------------------------------------- +TEST_F(OmenOfClarityGlyphLockTest, + GlyphSlotOpensNormallyWhenFeatureDisabled) +{ + // Feature is NOT enabled (cache is empty) + + SimulateGlyphsForLevel(80); + sScriptMgr->OnPlayerLevelChanged(player, 79); + + uint32 bits = + player->GetUInt32Value(PLAYER_GLYPHS_ENABLED); + EXPECT_NE(bits & OOC_LOCKED_SLOT_BIT, 0u) + << "Without the feature, slot 5 should remain enabled"; +} + +// ---------------------------------------------------------- +// TEST: Enable at sub-80, level through multiple levels, +// slot stays locked throughout +// ---------------------------------------------------------- +TEST_F(OmenOfClarityGlyphLockTest, + GlyphSlotStaysLockedThroughMultipleLevelUps) +{ + s_testOocEnabled.insert( + player->GetGUID().GetCounter()); + + // Level from 78 → 79 → 80 → 81 (if somehow possible) + for (uint8 newLevel = 78; newLevel <= 80; ++newLevel) + { + SCOPED_TRACE("Level: " + std::to_string(newLevel)); + SimulateGlyphsForLevel(newLevel); + sScriptMgr->OnPlayerLevelChanged( + player, newLevel - 1); + + uint32 bits = + player->GetUInt32Value(PLAYER_GLYPHS_ENABLED); + EXPECT_EQ(bits & OOC_LOCKED_SLOT_BIT, 0u) + << "Slot 5 should remain locked at level " + << static_cast(newLevel); + } +} + +// ---------------------------------------------------------- +// TEST: Feature enabled mid-session then level up +// ---------------------------------------------------------- +TEST_F(OmenOfClarityGlyphLockTest, + QuestCompletionLocksSlotThenLevelUpKeepsItLocked) +{ + // Player is level 79 with no feature + SimulateGlyphsForLevel(79); + EXPECT_EQ( + player->GetUInt32Value(PLAYER_GLYPHS_ENABLED) & + OOC_LOCKED_SLOT_BIT, 0u); + + // Complete the quest → hook enables feature + locks slot + sScriptMgr->OnPlayerCompleteQuest(player, nullptr); + EXPECT_TRUE(s_testOocEnabled.count( + player->GetGUID().GetCounter()) > 0) + << "Feature should be enabled after quest completion"; + + // Now level to 80 → game re-enables all slots + SimulateGlyphsForLevel(80); + uint32 bitsPreHook = + player->GetUInt32Value(PLAYER_GLYPHS_ENABLED); + EXPECT_NE(bitsPreHook & OOC_LOCKED_SLOT_BIT, 0u) + << "InitGlyphsForLevel should have set 0x20"; + + // Level-change hook fires + sScriptMgr->OnPlayerLevelChanged(player, 79); + + uint32 bitsPostHook = + player->GetUInt32Value(PLAYER_GLYPHS_ENABLED); + EXPECT_EQ(bitsPostHook & OOC_LOCKED_SLOT_BIT, 0u) + << "Slot 5 should be re-locked after leveling to 80"; +} + +} // namespace diff --git a/src/test/server/game/Spells/FrostboltPvPTest.cpp b/src/test/server/game/Spells/FrostboltPvPTest.cpp new file mode 100644 index 0000000000..abb7828ac8 --- /dev/null +++ b/src/test/server/game/Spells/FrostboltPvPTest.cpp @@ -0,0 +1,254 @@ +/* + * 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 . + */ + +#include "IntegrationTestFixture.h" +#include "SpellInfoTestHelper.h" +#include "SpellAuras.h" +#include "SpellAuraEffects.h" +#include "SpellMgr.h" +#include "gtest/gtest.h" + +#ifndef TEST_F +#define TEST_F(fixture, name) void fixture##_##name() +#endif + +/* + * Integration test: Mage casts Frostbolt at a Warrior in PvP. + * + * What we test: + * 1. The Frostbolt slow aura (SPELL_AURA_MOD_DECREASE_SPEED) is applied + * 2. The warrior's run speed is reduced by the correct percentage + * 3. The spell damage matches expected base points + * + * What we simulate vs. what's real: + * ┌──────────────────────────────────────────────────────┐ + * │ REAL (actual game code) │ + * │ - SpellInfo built from SpellEntry (same as DBC) │ + * │ - Aura::TryRefreshStackOrCreate │ + * │ - AuraEffect with SPELL_AURA_MOD_DECREASE_SPEED │ + * │ - Unit::UpdateSpeed applies the slow modifier │ + * │ - SpellNonMeleeDamage struct for damage bookkeeping │ + * │ - Unit::GetSpeedRate / GetSpeed for verification │ + * ├──────────────────────────────────────────────────────┤ + * │ SIMULATED (bypassed for test isolation) │ + * │ - No full Spell::prepare → cast pipeline │ + * │ - No target selection / LoS / range checks │ + * │ - Aura applied directly via Unit::AddAura │ + * │ - Damage dealt directly, no resist/absorb rolls │ + * └──────────────────────────────────────────────────────┘ + */ + +namespace +{ + +// Frostbolt Rank 14 (level 79) reference values +static constexpr uint32 FROSTBOLT_SPELL_ID = 42842; +static constexpr int32 FROSTBOLT_BASE_DAMAGE = 799; // base points for effect 0 +static constexpr int32 FROSTBOLT_SLOW_PCT = -40; // 40% speed reduction +static constexpr int32 FROSTBOLT_DURATION_MS = 9000; // 9 seconds + +class FrostboltPvPTest : public IntegrationTestFixture +{ +protected: + void SetUp() override + { + IntegrationTestFixture::SetUp(); + + // Build a SpellInfo mimicking Frostbolt Rank 14: + // Effect[0]: SPELL_EFFECT_SCHOOL_DAMAGE — base 799 frost damage + // Effect[1]: SPELL_EFFECT_APPLY_AURA / SPELL_AURA_MOD_DECREASE_SPEED — -40% + _frostboltInfo = SpellInfoBuilder() + .WithId(FROSTBOLT_SPELL_ID) + .WithSpellFamilyName(SPELLFAMILY_MAGE) + .WithSpellFamilyFlags(0x20) // Frostbolt family flag + .WithSchoolMask(SPELL_SCHOOL_MASK_FROST) + .WithDmgClass(SPELL_DAMAGE_CLASS_MAGIC) + .WithEffect(0, SPELL_EFFECT_SCHOOL_DAMAGE) + .WithEffectBasePoints(0, FROSTBOLT_BASE_DAMAGE) + .WithEffect(1, SPELL_EFFECT_APPLY_AURA, SPELL_AURA_MOD_DECREASE_SPEED) + .WithEffectBasePoints(1, FROSTBOLT_SLOW_PCT) + .BuildUnique(); + + // Create a SpellDurationEntry so the aura has a real duration + _durationEntry = {}; + _durationEntry.ID = 1; + _durationEntry.Duration[0] = FROSTBOLT_DURATION_MS; + _durationEntry.Duration[1] = FROSTBOLT_DURATION_MS; + _durationEntry.Duration[2] = FROSTBOLT_DURATION_MS; + // Patch the SpellInfo's DurationEntry pointer + const_cast(_frostboltInfo.get())->DurationEntry = &_durationEntry; + } + + std::unique_ptr _frostboltInfo; + SpellDurationEntry _durationEntry; +}; + +// ───────────────────────────────────────────────────────── +// TEST: Frostbolt slow aura is applied and reduces speed +// ───────────────────────────────────────────────────────── +// +// ┌───────────┐ Frostbolt ┌───────────┐ +// │ Mage │ ────────────► │ Warrior │ +// │ (player) │ │ (player) │ +// └───────────┘ └───────────┘ +// │ +// ┌─────▼─────┐ +// │ Slow Aura │ +// │ -40% spd │ +// └───────────┘ +// +TEST_F(FrostboltPvPTest, SlowAuraAppliedAndReducesSpeed) +{ + auto* mage = CreateTestPlayer(1, "Frostmage"); + auto* warrior = CreateTestPlayer(2, "Berserker"); + + // Record the warrior's baseline run speed rate (should be 1.0) + float baseSpeedRate = warrior->GetSpeedRate(MOVE_RUN); + ASSERT_FLOAT_EQ(baseSpeedRate, 1.0f) + << "Warrior should start at 100% run speed"; + + // Mage applies the Frostbolt aura (effect index 1 = slow) on the warrior. + // effMask 0x2 = only effect[1] (the APPLY_AURA slow), skipping effect[0] (damage). + Aura* slowAura = mage->AddAura(_frostboltInfo.get(), 0x2, warrior); + ASSERT_NE(slowAura, nullptr) << "Frostbolt slow aura should be created"; + + // Verify the aura is on the warrior + EXPECT_TRUE(warrior->HasAura(FROSTBOLT_SPELL_ID)) + << "Warrior should have the Frostbolt aura"; + + // Verify aura duration + EXPECT_EQ(slowAura->GetMaxDuration(), FROSTBOLT_DURATION_MS); + + // Verify the slow amount on the aura effect + AuraEffect const* slowEffect = slowAura->GetEffect(1); + ASSERT_NE(slowEffect, nullptr); + EXPECT_EQ(slowEffect->GetAmount(), FROSTBOLT_SLOW_PCT) + << "Slow effect should be -40%"; + + // Verify the warrior's run speed is actually reduced. + // UpdateSpeed was called by HandleAuraModDecreaseSpeed. + // Formula: speed = max(non_stack_bonus, stack_bonus) → 1.0 + // then AddPct(speed, -40) → 1.0 * (1 + (-40/100)) = 0.60 + float expectedSpeedRate = 0.60f; + EXPECT_NEAR(warrior->GetSpeedRate(MOVE_RUN), expectedSpeedRate, 0.01f) + << "Warrior run speed should be 60% (slowed by 40%)"; + + // Walk speed should also be reduced + EXPECT_NEAR(warrior->GetSpeedRate(MOVE_WALK), expectedSpeedRate, 0.01f) + << "Warrior walk speed should also be reduced by 40%"; +} + +// ───────────────────────────────────────────────────────── +// TEST: Frostbolt damage matches expected base points +// ───────────────────────────────────────────────────────── +TEST_F(FrostboltPvPTest, DamageMatchesBasePoints) +{ + auto* mage = CreateTestPlayer(1, "Frostmage"); + auto* warrior = CreateTestPlayer(2, "Berserker"); + + // Set warrior health high enough to survive + warrior->SetMaxHealth(50000); + warrior->SetHealth(50000); + + // The SpellInfo's effect[0] base damage + int32 baseDamage = _frostboltInfo->Effects[0].CalcValue(mage); + EXPECT_EQ(baseDamage, FROSTBOLT_BASE_DAMAGE) + << "Base damage should match Frostbolt Rank 14"; + + // Simulate the damage using SpellNonMeleeDamage (same struct the real + // spell pipeline uses to track damage/absorb/resist per hit). + SpellNonMeleeDamage dmgInfo(mage, warrior, _frostboltInfo.get(), + SPELL_SCHOOL_MASK_FROST); + dmgInfo.damage = baseDamage; + + // Record health before + uint32 healthBefore = warrior->GetHealth(); + + // Deal the damage through the real DealDamage path + Unit::DealDamage(mage, warrior, dmgInfo.damage, nullptr, + SPELL_DIRECT_DAMAGE, SPELL_SCHOOL_MASK_FROST, _frostboltInfo.get()); + + uint32 healthAfter = warrior->GetHealth(); + uint32 actualDamage = healthBefore - healthAfter; + + // With no spell power, no resilience, no armor (frost is magic), + // and no absorb shields, the damage dealt equals base damage. + EXPECT_EQ(actualDamage, uint32(FROSTBOLT_BASE_DAMAGE)) + << "Damage dealt should equal base points with no modifiers"; +} + +// ───────────────────────────────────────────────────────── +// TEST: Removing the aura restores full speed +// ───────────────────────────────────────────────────────── +TEST_F(FrostboltPvPTest, RemovingSlowRestoresSpeed) +{ + auto* mage = CreateTestPlayer(1, "Frostmage"); + auto* warrior = CreateTestPlayer(2, "Berserker"); + + // Apply slow + mage->AddAura(_frostboltInfo.get(), 0x2, warrior); + ASSERT_NEAR(warrior->GetSpeedRate(MOVE_RUN), 0.60f, 0.01f); + + // Remove the aura (dispel / expiry) + warrior->RemoveAurasDueToSpell(FROSTBOLT_SPELL_ID); + + // Speed should be back to normal + EXPECT_FLOAT_EQ(warrior->GetSpeedRate(MOVE_RUN), 1.0f) + << "Speed should return to 100% after slow is removed"; +} + +// ───────────────────────────────────────────────────────── +// TEST: Full scenario — slow + damage in sequence +// ───────────────────────────────────────────────────────── +// +// Timeline: +// ┌─────────┬──────────────┬──────────────┬────────────┐ +// │ Start │ Apply slow │ Deal damage │ Verify │ +// │ 100%spd │ → 60% speed │ → HP reduced │ slow+dmg │ +// └─────────┴──────────────┴──────────────┴────────────┘ +// +TEST_F(FrostboltPvPTest, FullFrostboltScenario) +{ + auto* mage = CreateTestPlayer(1, "Frostmage"); + auto* warrior = CreateTestPlayer(2, "Berserker"); + + warrior->SetMaxHealth(50000); + warrior->SetHealth(50000); + + // Verify pre-conditions + ASSERT_FLOAT_EQ(warrior->GetSpeedRate(MOVE_RUN), 1.0f); + ASSERT_EQ(warrior->GetHealth(), 50000u); + + // Step 1: Apply the slow aura (effect[1] only) + Aura* aura = mage->AddAura(_frostboltInfo.get(), 0x2, warrior); + ASSERT_NE(aura, nullptr); + + // Step 2: Deal the damage (effect[0] simulated) + int32 damage = _frostboltInfo->Effects[0].CalcValue(mage); + Unit::DealDamage(mage, warrior, damage, nullptr, + SPELL_DIRECT_DAMAGE, SPELL_SCHOOL_MASK_FROST, _frostboltInfo.get()); + + // Verify both effects landed + EXPECT_NEAR(warrior->GetSpeedRate(MOVE_RUN), 0.60f, 0.01f) + << "Warrior should be slowed to 60%"; + EXPECT_EQ(warrior->GetHealth(), 50000u - FROSTBOLT_BASE_DAMAGE) + << "Warrior HP should be reduced by Frostbolt base damage"; + EXPECT_TRUE(warrior->HasAura(FROSTBOLT_SPELL_ID)) + << "Warrior should still have the Frostbolt debuff"; +} + +} // namespace diff --git a/src/test/server/game/Spells/SpellProcUnitIntegrationTest.cpp b/src/test/server/game/Spells/SpellProcUnitIntegrationTest.cpp new file mode 100644 index 0000000000..403e0507ee --- /dev/null +++ b/src/test/server/game/Spells/SpellProcUnitIntegrationTest.cpp @@ -0,0 +1,157 @@ +/* + * 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 . + */ + +#include "IntegrationTestFixture.h" +#include "ProcEventInfoHelper.h" +#include "SpellInfoTestHelper.h" +#include "SpellMgr.h" +#include "gtest/gtest.h" + +#ifndef TEST_F +#define TEST_F(fixture, name) void fixture##_##name() +#endif + +namespace +{ + +class ProcUnitIntegrationTest : public IntegrationTestFixture +{ +}; + +TEST_F(ProcUnitIntegrationTest, CreatureAndPlayerAreHostile) +{ + auto* player = CreateTestPlayer(1, "Hostile", SEC_PLAYER); + // Player faction defaults to 0, set it to our test faction + player->SetUInt32Value(UNIT_FIELD_FACTIONTEMPLATE, TEST_FACTION_HOSTILE_TO_MONSTERS); + + auto* creature = CreateTestCreature(100, 99001, TEST_FACTION_HOSTILE_TO_ALL); + + ReputationRank reaction = player->GetReactionTo(creature); + EXPECT_EQ(reaction, REP_HOSTILE); + + ReputationRank reverseReaction = creature->GetReactionTo(player); + EXPECT_EQ(reverseReaction, REP_HOSTILE); +} + +TEST_F(ProcUnitIntegrationTest, ProcEventInfoWithRealUnits) +{ + auto* attacker = CreateTestPlayer(1, "Attacker", SEC_PLAYER); + auto* victim = CreateTestCreature(100, 99001, TEST_FACTION_HOSTILE_TO_ALL); + + auto procInfo = ProcEventInfoBuilder() + .WithActor(attacker) + .WithActionTarget(victim) + .WithProcTarget(victim) + .WithTypeMask(PROC_FLAG_DONE_MELEE_AUTO_ATTACK) + .WithSpellTypeMask(PROC_SPELL_TYPE_DAMAGE) + .WithSpellPhaseMask(PROC_SPELL_PHASE_HIT) + .WithHitMask(PROC_HIT_NORMAL) + .Build(); + + EXPECT_EQ(procInfo.GetActor(), attacker); + EXPECT_EQ(procInfo.GetActionTarget(), victim); + EXPECT_EQ(procInfo.GetProcTarget(), victim); + EXPECT_EQ(procInfo.GetTypeMask(), uint32(PROC_FLAG_DONE_MELEE_AUTO_ATTACK)); +} + +TEST_F(ProcUnitIntegrationTest, ProcEntryMatchesSpellInfo) +{ + auto spellInfo = SpellInfoBuilder() + .WithId(99999) + .WithSpellFamilyName(SPELLFAMILY_GENERIC) + .WithProcFlags(PROC_FLAG_DONE_MELEE_AUTO_ATTACK) + .WithProcChance(100) + .WithEffect(0, SPELL_EFFECT_APPLY_AURA, SPELL_AURA_PROC_TRIGGER_SPELL) + .BuildUnique(); + + auto procEntry = SpellProcEntryBuilder() + .WithProcFlags(PROC_FLAG_DONE_MELEE_AUTO_ATTACK) + .WithSpellTypeMask(PROC_SPELL_TYPE_DAMAGE) + .WithSpellPhaseMask(PROC_SPELL_PHASE_HIT) + .WithHitMask(PROC_HIT_NORMAL) + .WithChance(100.0f) + .Build(); + + EXPECT_EQ(spellInfo->Id, 99999u); + EXPECT_EQ(spellInfo->ProcFlags, uint32(PROC_FLAG_DONE_MELEE_AUTO_ATTACK)); + EXPECT_EQ(procEntry.ProcFlags, uint32(PROC_FLAG_DONE_MELEE_AUTO_ATTACK)); + EXPECT_FLOAT_EQ(procEntry.Chance, 100.0f); +} + +TEST_F(ProcUnitIntegrationTest, MultipleCreaturesCanBeCreated) +{ + auto* player = CreateTestPlayer(1, "Attacker", SEC_PLAYER); + auto* creature1 = CreateTestCreature(100, 99001, TEST_FACTION_HOSTILE_TO_ALL); + auto* creature2 = CreateTestCreature(101, 99002, TEST_FACTION_HOSTILE_TO_ALL); + + EXPECT_NE(creature1, creature2); + EXPECT_NE(creature1->GetGUID(), creature2->GetGUID()); + EXPECT_EQ(creature1->GetHealth(), 10000u); + EXPECT_EQ(creature2->GetHealth(), 10000u); + EXPECT_NE(player->GetGUID(), creature1->GetGUID()); +} + +TEST_F(ProcUnitIntegrationTest, ProcOnKillWithRealUnits) +{ + auto* attacker = CreateTestPlayer(1, "Killer", SEC_PLAYER); + auto* victim = CreateTestCreature(100, 99001, TEST_FACTION_HOSTILE_TO_ALL); + + // Build a kill-proc event + auto procInfo = ProcEventInfoBuilder() + .WithActor(attacker) + .WithActionTarget(victim) + .WithProcTarget(victim) + .WithTypeMask(PROC_FLAG_KILL) + .WithSpellTypeMask(PROC_SPELL_TYPE_NONE) + .WithSpellPhaseMask(PROC_SPELL_PHASE_NONE) + .WithHitMask(PROC_HIT_NORMAL) + .Build(); + + EXPECT_EQ(procInfo.GetTypeMask(), uint32(PROC_FLAG_KILL)); + EXPECT_EQ(procInfo.GetActor(), attacker); + EXPECT_EQ(procInfo.GetActionTarget(), victim); +} + +TEST_F(ProcUnitIntegrationTest, CreatureHealthCanBeManipulated) +{ + auto* creature = CreateTestCreature(100, 99001, TEST_FACTION_HOSTILE_TO_ALL); + + EXPECT_EQ(creature->GetHealth(), 10000u); + EXPECT_EQ(creature->GetMaxHealth(), 10000u); + + creature->SetHealth(1); + EXPECT_EQ(creature->GetHealth(), 1u); + + creature->SetHealth(0); + EXPECT_EQ(creature->GetHealth(), 0u); +} + +TEST_F(ProcUnitIntegrationTest, FactionHostilityIsSymmetric) +{ + auto* c1 = CreateTestCreature(100, 99001, TEST_FACTION_HOSTILE_TO_MONSTERS); + auto* c2 = CreateTestCreature(101, 99002, TEST_FACTION_HOSTILE_TO_ALL); + + // c2 (monster mask=8) hostile to c1's mask (player mask=1): yes + // c1 (player mask=1) hostile to c2's mask (monster mask=8): yes + ReputationRank r1 = c1->GetReactionTo(c2); + ReputationRank r2 = c2->GetReactionTo(c1); + + EXPECT_EQ(r1, REP_HOSTILE); + EXPECT_EQ(r2, REP_HOSTILE); +} + +} // namespace