diff --git a/src/server/game/Entities/Unit/Unit.cpp b/src/server/game/Entities/Unit/Unit.cpp
index beea748b1..494d73976 100644
--- a/src/server/game/Entities/Unit/Unit.cpp
+++ b/src/server/game/Entities/Unit/Unit.cpp
@@ -751,8 +751,7 @@ void Unit::DisableSpline()
void Unit::resetAttackTimer(WeaponAttackType type)
{
- int32 time = int32(GetAttackTime(type) * m_modAttackSpeedPct[type]);
- m_attackTimer[type] = std::min(m_attackTimer[type] + time, time);
+ m_attackTimer[type] = int32(GetAttackTime(type) * m_modAttackSpeedPct[type]);
}
bool Unit::IsWithinCombatRange(Unit const* obj, float dist2compare) const
diff --git a/src/test/server/game/Entities/ResetAttackTimerTest.cpp b/src/test/server/game/Entities/ResetAttackTimerTest.cpp
new file mode 100644
index 000000000..49635b456
--- /dev/null
+++ b/src/test/server/game/Entities/ResetAttackTimerTest.cpp
@@ -0,0 +1,226 @@
+/*
+ * 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 "gtest/gtest.h"
+#include
+#include
+
+/**
+ * Tests for Unit::resetAttackTimer arithmetic.
+ *
+ * Unit::resetAttackTimer resets the swing timer after an attack lands.
+ * The timer value determines when the next attack can occur.
+ *
+ * Old (buggy) formula:
+ * int32 time = int32(GetAttackTime(type) * m_modAttackSpeedPct[type]);
+ * m_attackTimer[type] = std::min(m_attackTimer[type] + time, time);
+ *
+ * New (fixed) formula:
+ * m_attackTimer[type] = int32(GetAttackTime(type) * m_modAttackSpeedPct[type]);
+ *
+ * The old formula carried forward negative timer debt, allowing burst
+ * attacks after lag spikes or parry-haste timer reductions.
+ */
+
+namespace
+{
+ // Simulates the old (buggy) resetAttackTimer formula
+ int32_t OldResetFormula(int32_t currentTimer, int32_t fullTime)
+ {
+ return std::min(currentTimer + fullTime, fullTime);
+ }
+
+ // Simulates the new (fixed) resetAttackTimer formula
+ int32_t NewResetFormula(int32_t /*currentTimer*/, int32_t fullTime)
+ {
+ return fullTime;
+ }
+
+ // Calculate effective attack time: GetAttackTime(type) * m_modAttackSpeedPct[type]
+ int32_t CalcFullTime(uint32_t baseAttackTime, float modSpeedPct)
+ {
+ return static_cast(baseAttackTime * modSpeedPct);
+ }
+}
+
+// Normal case: timer at 0 (attack just became ready)
+TEST(ResetAttackTimerTest, NormalReset_TimerAtZero)
+{
+ int32_t fullTime = CalcFullTime(2000, 1.0f); // 2.0s base, no haste
+ int32_t currentTimer = 0;
+
+ // Both formulas produce the same result when timer is exactly 0
+ EXPECT_EQ(OldResetFormula(currentTimer, fullTime), 2000);
+ EXPECT_EQ(NewResetFormula(currentTimer, fullTime), 2000);
+}
+
+// Normal case: timer slightly negative (typical single-tick overshoot)
+TEST(ResetAttackTimerTest, NormalReset_SmallNegativeTimer)
+{
+ int32_t fullTime = CalcFullTime(2000, 1.0f);
+ int32_t currentTimer = -50; // 50ms overshoot
+
+ // Old formula: min(-50 + 2000, 2000) = min(1950, 2000) = 1950
+ // Carries 50ms debt — next attack 50ms sooner
+ EXPECT_EQ(OldResetFormula(currentTimer, fullTime), 1950);
+
+ // New formula: always 2000 — clean reset
+ EXPECT_EQ(NewResetFormula(currentTimer, fullTime), 2000);
+}
+
+// Bug scenario: large negative timer from lag spike
+TEST(ResetAttackTimerTest, LagSpike_LargeNegativeTimer)
+{
+ int32_t fullTime = CalcFullTime(2000, 1.0f);
+ int32_t currentTimer = -3000; // 3 second lag spike
+
+ // Old formula: min(-3000 + 2000, 2000) = min(-1000, 2000) = -1000
+ // Timer is STILL negative — attack fires immediately again!
+ EXPECT_LT(OldResetFormula(currentTimer, fullTime), 0);
+
+ // New formula: always 2000 — no burst
+ EXPECT_EQ(NewResetFormula(currentTimer, fullTime), 2000);
+}
+
+// Gluth scenario: parry-haste with enrage
+TEST(ResetAttackTimerTest, GluthParryHaste_BurstAttack)
+{
+ // Gluth: 1600ms base, 25% enrage haste
+ // GetAttackTime returns base (1600), modSpeedPct = 0.8 (25% haste)
+ int32_t fullTime = CalcFullTime(1600, 0.8f); // = 1280ms
+ EXPECT_EQ(fullTime, 1280);
+
+ // After parry-haste reduces timer to near-zero and a lag spike
+ // causes 2+ seconds of unprocessed time
+ int32_t currentTimer = -2000;
+
+ // Old formula: min(-2000 + 1280, 1280) = min(-720, 1280) = -720
+ // Timer deeply negative — immediate burst attack
+ EXPECT_LT(OldResetFormula(currentTimer, fullTime), 0);
+
+ // New formula: 1280ms — proper cooldown before next swing
+ EXPECT_EQ(NewResetFormula(currentTimer, fullTime), 1280);
+}
+
+// Parry-haste floor scenario: timer at minimum (20% of base)
+TEST(ResetAttackTimerTest, ParryHasteFloor_MultipleParries)
+{
+ // Creature with 1600ms base, no haste
+ int32_t fullTime = CalcFullTime(1600, 1.0f);
+
+ // Parry-haste can reduce timer to 20% of base = 320ms
+ // If server tick is 200ms and timer was at 320ms, after tick: 120ms
+ // Attack fires, resetAttackTimer called with timer = 120 - 200 = -80
+ // (timer went from 120 to -80 during the next tick)
+ int32_t currentTimer = -80;
+
+ // Old formula: min(-80 + 1600, 1600) = min(1520, 1600) = 1520
+ // Small debt is carried — minor issue
+ EXPECT_EQ(OldResetFormula(currentTimer, fullTime), 1520);
+
+ // Now consider two rapid parries reducing timer to 320ms,
+ // followed by a 500ms server hiccup
+ currentTimer = 320 - 500; // = -180
+ // Old formula: min(-180 + 1600, 1600) = min(1420, 1600) = 1420
+ EXPECT_EQ(OldResetFormula(currentTimer, fullTime), 1420);
+
+ // New formula: always full time
+ EXPECT_EQ(NewResetFormula(currentTimer, fullTime), 1600);
+}
+
+// Multiple consecutive burst attacks (worst case)
+TEST(ResetAttackTimerTest, ConsecutiveBursts_OldFormulaChainAttacks)
+{
+ int32_t fullTime = CalcFullTime(1600, 0.8f); // Gluth enraged: 1280ms
+ int32_t timer = -2500; // Large lag spike
+
+ int attackCount = 0;
+ // Simulate old formula: how many attacks fire before timer > 0?
+ while (timer <= 0)
+ {
+ attackCount++;
+ timer = OldResetFormula(timer, fullTime);
+ }
+
+ // Old formula allows multiple attacks in burst
+ EXPECT_GT(attackCount, 1);
+
+ // New formula: always exactly 1 attack then timer is positive
+ timer = -2500;
+ int newAttackCount = 0;
+ // Only one attack fires (the one that triggered the reset)
+ timer = NewResetFormula(timer, fullTime);
+ if (timer > 0)
+ newAttackCount = 1;
+
+ EXPECT_EQ(newAttackCount, 1);
+ EXPECT_GT(timer, 0);
+}
+
+// Haste modifier correctness
+TEST(ResetAttackTimerTest, HasteModifier_CorrectCalculation)
+{
+ // No haste: 2000ms base
+ EXPECT_EQ(CalcFullTime(2000, 1.0f), 2000);
+
+ // 25% haste (modSpeedPct = 0.8)
+ EXPECT_EQ(CalcFullTime(2000, 0.8f), 1600);
+
+ // 50% slow (modSpeedPct = 1.5)
+ EXPECT_EQ(CalcFullTime(2000, 1.5f), 3000);
+
+ // Enrage (25% haste on 1600ms base)
+ EXPECT_EQ(CalcFullTime(1600, 0.8f), 1280);
+}
+
+// Edge case: timer exactly equals negative of full time
+TEST(ResetAttackTimerTest, EdgeCase_TimerEqualsNegativeFullTime)
+{
+ int32_t fullTime = CalcFullTime(2000, 1.0f);
+ int32_t currentTimer = -2000;
+
+ // Old formula: min(-2000 + 2000, 2000) = min(0, 2000) = 0
+ // Timer exactly 0 — attack is immediately ready again
+ EXPECT_EQ(OldResetFormula(currentTimer, fullTime), 0);
+
+ // New formula: 2000 — proper delay
+ EXPECT_EQ(NewResetFormula(currentTimer, fullTime), 2000);
+}
+
+// Edge case: positive timer (reset called before attack was ready)
+TEST(ResetAttackTimerTest, EdgeCase_PositiveTimer)
+{
+ int32_t fullTime = CalcFullTime(2000, 1.0f);
+ int32_t currentTimer = 500; // 500ms remaining
+
+ // Old formula: min(500 + 2000, 2000) = min(2500, 2000) = 2000
+ // Capped at full time — correct behavior
+ EXPECT_EQ(OldResetFormula(currentTimer, fullTime), 2000);
+
+ // New formula: same result
+ EXPECT_EQ(NewResetFormula(currentTimer, fullTime), 2000);
+}
+
+// Invariant: new formula always returns exactly fullTime
+TEST(ResetAttackTimerTest, Invariant_AlwaysReturnsFullTime)
+{
+ int32_t fullTime = CalcFullTime(1600, 1.0f);
+
+ // Test a wide range of current timer values
+ for (int32_t timer = -10000; timer <= 10000; timer += 100)
+ EXPECT_EQ(NewResetFormula(timer, fullTime), fullTime);
+}