feat(Test): Add integration test framework for game systems (#25035)

Co-authored-by: blinkysc <blinkysc@users.noreply.github.com>
This commit is contained in:
blinkysc
2026-06-30 11:17:04 -05:00
committed by GitHub
parent 827a5a1d7e
commit 398c69e5aa
8 changed files with 964 additions and 33 deletions
+189
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#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 <vector>
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<WorldMock>();
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<WorldMock>* 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>* _worldMock = nullptr;
TestMap* _testMap = nullptr;
std::vector<TestPlayer*> _trackedPlayers;
std::vector<TestCreature*> _trackedCreatures;
std::vector<CreatureTemplate*> _ownedCreatureTemplates;
};
#endif //AZEROTHCORE_INTEGRATION_TEST_FIXTURE_H
+13
View File
@@ -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);
+6
View File
@@ -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<AllMapScript>::InitEnabledHooksIfNeeded(ALLMAPHOOK_END);
ScriptRegistry<AllSpellScript>::InitEnabledHooksIfNeeded(ALLSPELLHOOK_END);
ScriptRegistry<GlobalScript>::InitEnabledHooksIfNeeded(GLOBALHOOK_END);
ScriptRegistry<MiscScript>::InitEnabledHooksIfNeeded(MISCHOOK_END);
ScriptRegistry<PlayerScript>::InitEnabledHooksIfNeeded(PLAYERHOOK_END);
ScriptRegistry<UnitScript>::InitEnabledHooksIfNeeded(UNITHOOK_END);
ScriptRegistry<WorldObjectScript>::InitEnabledHooksIfNeeded(WORLDOBJECTHOOK_END);
ScriptRegistry<CommandSC>::InitEnabledHooksIfNeeded(ALLCOMMANDHOOK_END);
}
+42
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#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
@@ -15,16 +15,13 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#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<MiscScript>::InitEnabledHooksIfNeeded(MISCHOOK_END);
ScriptRegistry<WorldObjectScript>::InitEnabledHooksIfNeeded(WORLDOBJECTHOOK_END);
ScriptRegistry<UnitScript>::InitEnabledHooksIfNeeded(UNITHOOK_END);
ScriptRegistry<PlayerScript>::InitEnabledHooksIfNeeded(PLAYERHOOK_END);
ScriptRegistry<CommandSC>::InitEnabledHooksIfNeeded(ALLCOMMANDHOOK_END);
initialized = true;
}
}
IWorld* originalWorld = nullptr;
NiceMock<WorldMock>* worldMock = nullptr;
WorldSession* session = nullptr;
@@ -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 <unordered_set>
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<uint32> 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<WorldMock>();
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>* 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<int>(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
@@ -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 <http://www.gnu.org/licenses/>.
*/
#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<SpellInfo*>(_frostboltInfo.get())->DurationEntry = &_durationEntry;
}
std::unique_ptr<SpellInfo> _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
@@ -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 <http://www.gnu.org/licenses/>.
*/
#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