fix(Scripts/TrialOfTheCrusader): retain protected spike targets (#27733)

This commit is contained in:
moostigre
2026-10-04 21:43:10 -03:00
committed by GitHub
parent 0b902c61a6
commit 102c26d971
5 changed files with 504 additions and 16 deletions
+1
View File
@@ -146,6 +146,7 @@ go test -tags=e2e ./local/... -count=1 -v -timeout 30m -parallel 1
| combat/death | die → ghost → release → reclaim | P1 | covered | — |
| combat/pets | summon / GUID / attack / dismiss | P1 | covered; dungeon Raise Dead `blocked-harness` (ready-check / instance summon) | #27081 |
| combat/threat | engage / taunt switch / kill clears combat | P1 | covered | — |
| instances/trial_of_the_crusader | spike retains protected target on arrival; full immunity releases target; reacquire without speed reset | P1 | covered (`TestAC_14076_PursuingSpikesImmunity`); [fixture and scenarios](suites/instances/trial_of_the_crusader/spikes.md) | #14076 |
| combat/vehicles | spellclick steed enter/exit | P2 | covered | — |
| spells/aura | apply/query; CC broken by damage; mount persist; paladin same-aura per-caster + Aura Mastery; Beacon applies its target's Improved Devotion Aura healing bonus exactly once for Holy Light, Flash of Light, Holy Shock and Lay on Hands | P1 | covered (`TestAC_26130_*`, `TestAC_25765_*`, `TestAC_27756_*`) | #26130 #25765 #27756 |
| spells/cast | Charge on dummy; fail path; stance; Raise Dead + ghoul | P1 | covered (`TestAC_27061_*`) | #27061 |
@@ -0,0 +1,44 @@
# Pursuing Spikes targeting (AC#14076)
Run with the normal `E2E_*` realm/database settings, from `e2e/`:
```sh
go test -tags=e2e ./suites/instances/trial_of_the_crusader -run TestAC_14076 -count=1 -p 1 -parallel 1 -v
```
Two level-80 paladins form a fresh raid and enter the empty ToC arena. A neutral
World Trigger fixture casts the encounter's real summon spell (66169), creating
Pursuing Spikes (34660) with the ToC instance AI. Characters receive extra health,
but no god mode or damage immunity. The immunity spells are cast by the clients.
The summon and persistent fixture are removed during cleanup. Each run owns two
new accounts: cleanup is registered before login, closes the bot sessions, deletes
their characters through the server, then deletes the accounts and GM permissions.
This also runs after assertion/setup failures. Cleanup errors fail the test and
trigger an attempt to ban remaining accounts; ban failures are reported too.
Existing accounts from earlier runs are untouched.
Character retention follows the server's configured deletion policy. A killed
test process cannot run Go cleanup callbacks and still requires manual cleanup.
Oracles:
- Hand of Protection (10278): retain Mark (67574) and the same target throughout
nine seconds, including reaching the stationary player and accelerating (65922).
- Divine Shield (642): release the fully immune player and mark the other player.
If both players become fully immune, clear the target, continue accelerating,
and reacquire the first player after they cancel their shield without resetting speed.
- Initially immune: summon while both players have Divine Shield, then cancel one
shield and verify acquisition resumes at the accelerated speed.
The [issue discussion](https://github.com/azerothcore/azerothcore-wotlk/issues/14076#issuecomment-1345338310)
distinguishes Hand of Protection from Divine Shield, Ice Block and threat-dropping
abilities. Mark has `SPELL_ATTR0_NO_IMMUNITIES`; full immunity does not itself
remove it. The spike AI must explicitly release and replace the mark.
This fixture exercises spike AI in an instance, not the complete boss fight.
Additional encounter scenarios are Ice Block, Feign Death, Vanish, target death
and disconnect; Permafrost collision's four-second pause and speed restart; and
boss submerge/emerge cleanup. No change to Permafrost/Hand of Freedom (#16496) is
included. PR #19684 was merged independently in August 2024.
For test results and environment, see the
[PR description](https://github.com/azerothcore/azerothcore-wotlk/pull/27733).
@@ -0,0 +1,218 @@
//go:build e2e
package trial_of_the_crusader_test
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/azerothcore/AzerothGhost/client"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
)
// NewScenario in AzerothGhost v1.0.8 closes sessions, but does not delete accounts.
// Own creation here so even a failure during login runs account cleanup. INSERT
// must not upsert: a name collision must never give us ownership of an old account.
func newSpikeBots(t *testing.T) []*e2eharness.ScenarioBot {
t.Helper()
authDB, charDB := e2eharness.OpenTestDBs(t)
idents := e2eharness.MakeBotIdentsRaceClass("Spike", 2, e2eharness.RaceHuman, e2eharness.ClassPaladin)
for _, ident := range idents {
username := strings.ToUpper(ident.Account)
salt, verifier := e2eharness.ComputeSRP6(username, strings.ToUpper(e2eharness.DefaultPassword))
result, err := authDB.Exec(`INSERT INTO account (username, salt, verifier, expansion) VALUES (?, ?, ?, 2)`, username, salt, verifier)
if err != nil {
e2eharness.HarnessFailf(t, "create owned account %s: %v", username, err)
}
accountID, err := result.LastInsertId()
if err != nil {
e2eharness.HarnessFailf(t, "read created account ID for %s: %v", username, err)
}
// Registered before permissions/login, after DB Close callbacks. Session
// and summon callbacks registered later run first (t.Cleanup is LIFO).
t.Cleanup(func() {
if err := cleanupSpikeAccount(authDB, charDB, accountID, username); err != nil {
t.Errorf("harness: cleanup account %s (id %d): %v", username, accountID, err)
} else {
t.Logf("removed test account %s (id %d)", username, accountID)
}
})
if err := e2eharness.SetGM(authDB, username, 3); err != nil {
e2eharness.HarnessFailf(t, "grant test permissions to %s: %v", username, err)
}
}
sessions := e2eharness.LoginBots(t, idents)
bots := make([]*e2eharness.ScenarioBot, len(sessions))
for i, session := range sessions {
bot := &e2eharness.ScenarioBot{Session: session, AuthDB: authDB, CharDB: charDB, Ident: idents[i]}
bot.Ident.CharName = session.Name
bots[i] = bot
t.Cleanup(func() { bot.CleanupOwnedSummons(t) })
e2eharness.EnableGM(t, session.World)
e2eharness.SetLevel(t, session.World, 80)
}
return bots
}
func cleanupSpikeAccount(authDB, charDB *sql.DB, accountID int64, username string) (cleanupErr error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
var actual string
if err := authDB.QueryRowContext(ctx, `SELECT username FROM account WHERE id = ?`, accountID).Scan(&actual); err != nil {
return err
}
if actual != username {
return fmt.Errorf("account ownership changed; refusing cleanup")
}
// Revoke GM even if character cleanup later fails. If deletion fails, try
// to ban the residue and report quarantine failures rather than hiding them.
defer func() {
if cleanupErr == nil {
return
}
// Fresh context: the cleanup timeout must not prevent quarantining residue.
lockCtx, lockCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer lockCancel()
if _, err := authDB.ExecContext(lockCtx, `INSERT INTO account_banned (id, bandate, unbandate, bannedby, banreason, active) SELECT id, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 'E2E cleanup', 'Incomplete Spike test cleanup', 1 FROM account WHERE id = ? AND username = ?`, accountID, username); err != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("quarantine failed: %w", err))
}
}()
if _, err := authDB.ExecContext(ctx, `DELETE FROM account_access WHERE id = ?`, accountID); err != nil {
return err
}
if err := waitSpikeDB(ctx, charDB, `SELECT COUNT(*) FROM characters WHERE account = ? AND online <> 0`, accountID); err != nil {
return fmt.Errorf("wait for character logout: %w", err)
}
var count int
if err := charDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM characters WHERE account = ?`, accountID).Scan(&count); err != nil {
return err
}
if count != 0 {
if err := deleteSpikeCharacters(ctx, username); err != nil {
return err
}
if err := waitSpikeDB(ctx, charDB, `SELECT COUNT(*) FROM characters WHERE account = ?`, accountID); err != nil {
return fmt.Errorf("wait for character deletion: %w", err)
}
}
if err := waitSpikeDB(ctx, authDB, `SELECT COUNT(*) FROM account WHERE id = ? AND online <> 0`, accountID); err != nil {
return fmt.Errorf("wait for cleanup session logout: %w", err)
}
// These are the account-scoped character rows AccountMgr::DeleteAccount
// removes in addition to Player::DeleteFromDB's character-scoped cleanup.
for _, query := range []string{
`DELETE FROM account_tutorial WHERE accountId = ?`,
`DELETE FROM account_data WHERE accountId = ?`,
} {
if _, err := charDB.ExecContext(ctx, query, accountID); err != nil {
return err
}
}
tx, err := authDB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, query := range []string{
`DELETE FROM account_access WHERE id = ?`,
`DELETE FROM realmcharacters WHERE acctid = ?`,
`DELETE FROM account_banned WHERE id = ?`,
`DELETE FROM account_muted WHERE guid = ?`,
`DELETE FROM account WHERE id = ?`,
} {
if _, err := tx.ExecContext(ctx, query, accountID); err != nil {
return err
}
}
return tx.Commit()
}
func waitSpikeDB(ctx context.Context, db *sql.DB, query string, accountID int64) error {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
var count int
if err := db.QueryRowContext(ctx, query, accountID).Scan(&count); err != nil {
return err
}
if count == 0 {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}
// Delete through the character-selection protocol, not ad-hoc character SQL:
// the server owns inventory, spells, pets, groups and its in-memory caches.
func deleteSpikeCharacters(ctx context.Context, username string) error {
auth := client.NewAuthClient(username, e2eharness.DefaultPassword)
realms, err := auth.Authenticate(e2eharness.AuthAddr)
if err != nil {
return fmt.Errorf("cleanup auth: %w", err)
}
if len(realms) == 0 {
return fmt.Errorf("cleanup auth returned no realms")
}
w := client.NewWorldClient(username, auth.SessionKey(), func(string, ...interface{}) {})
defer w.Close()
lists := make(chan []client.CharEnumEntry, 1)
w.OnCharList = func(chars []client.CharEnumEntry) {
select {
case lists <- chars:
default:
}
}
deleted := make(chan byte, 1)
stop := w.AddPacketHook(func(opcode uint16, data []byte) {
if opcode == 0x003C && len(data) > 0 { // SMSG_CHAR_DELETE
select {
case deleted <- data[0]:
default:
}
}
})
defer stop()
if err := w.Connect(realms[0].Address); err != nil {
return err
}
go func() { _ = w.Run() }()
if err := w.WaitForSessionPhase(client.PhaseAuthed, 20*time.Second); err != nil {
return err
}
for _, send := range []func() error{w.SendReadyForAccountDataTimes, w.SendRealmSplit, w.RequestCharList} {
if err := send(); err != nil {
return err
}
}
var chars []client.CharEnumEntry
select {
case chars = <-lists:
case <-ctx.Done():
return fmt.Errorf("cleanup character list: %w", ctx.Err())
}
for _, char := range chars {
if err := w.DeleteCharacter(char.GUID); err != nil {
return err
}
select {
case code := <-deleted:
if code != 0x47 { // CHAR_DELETE_SUCCESS
return fmt.Errorf("delete character %d: response %#x", char.GUID, code)
}
case <-ctx.Done():
return fmt.Errorf("delete character %d: %w", char.GUID, ctx.Err())
}
}
return nil
}
@@ -0,0 +1,201 @@
//go:build e2e
package trial_of_the_crusader_test
import (
"testing"
"time"
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
_ "github.com/go-sql-driver/mysql"
)
// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/14076
// Hand of Protection must retain the marked target after the spike reaches it.
// Divine Shield grants full immunity and must still cause retargeting:
// https://github.com/azerothcore/azerothcore-wotlk/issues/14076#issuecomment-1345338310
// Use the actual summon spell inside a fresh ToC instance, without starting the
// full encounter. GM setup gives health, not god mode or damage immunity.
func TestAC_14076_PursuingSpikesImmunity(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"med", "issue", "multi_bot"}, Runtime: "med",
Issue: 14076, Category: "instances/trial_of_the_crusader",
})
const (
mark = uint32(67574)
protection = uint32(10278)
shield = uint32(642)
speed2 = uint32(65922)
)
bots := newSpikeBots(t)
a, b := bots[0], bots[1]
e2eharness.FormPartyAtPad(t, e2eharness.PackagePad(t), a, b)
// CMSG_GROUP_RAID_CONVERT has no payload (GroupHandler.cpp).
// AzerothGhost v1.0.8 exposes raw packets but no raid conversion helper.
const groupRaidConvert = uint16(0x28e)
if err := a.World.SendPacketRaw(groupRaidConvert, nil); err != nil {
e2eharness.HarnessFailf(t, "convert party to raid: %v", err)
}
if !waitSpikeCondition(3*time.Second, func() bool { return a.GroupState().GroupType&2 != 0 }) {
e2eharness.Preconditionf(t, "raid conversion not acknowledged")
}
for _, bot := range bots {
bot.GM(t, ".gm on")
bot.FlushWorld(t)
}
// Empty arena above Anub'arak: no Frost Spheres or encounter waves.
a.Teleport(t, 563, 140, 394, 649)
b.Teleport(t, 563, 140, 394, 649)
caster, _ := a.SpawnPersistent(t, 12999, 10*time.Second)
a.Teleport(t, 550, 120, 394, 649)
b.Teleport(t, 580, 120, 394, 649)
for _, bot := range bots {
bot.Learn(t, protection)
bot.Learn(t, shield)
selectSpikeUnit(t, bot, bot.GUID)
bot.GM(t, ".modify hp 1000000")
e2eharness.CombatReady(t, bot.World, e2eharness.CombatReadyOpts{Power: true})
bot.FlushWorld(t)
}
a.WaitUnitGUID(t, b.GUID, 10*time.Second)
a.WaitUnitGUID(t, caster, 10*time.Second)
for _, tc := range []struct {
name string
spell uint32
}{
{"hand_of_protection", protection},
{"divine_shield", shield},
{"initially_immune", shield},
} {
t.Run(tc.name, func(t *testing.T) {
for _, bot := range bots {
selectSpikeUnit(t, bot, bot.GUID)
bot.GM(t, ".combatstop")
bot.GM(t, ".unaura all")
bot.GM(t, ".cooldown")
bot.GM(t, ".modify hp 1000000")
bot.FlushWorld(t)
}
if tc.name == "initially_immune" {
for _, bot := range bots {
bot.CastMust(t, shield, bot.GUID, 5*time.Second)
bot.WaitUnitAura(t, bot.GUID, shield, 2*time.Second)
}
}
selectSpikeUnit(t, a, caster)
// Same spell as EVENT_SPELL_SUMMON_SPIKE. No "triggered" suffix:
// the GM command's debug mask would ignore the summon effect.
a.GM(t, ".cast self 66169")
spike := a.WaitUnit(t, 34660, 10*time.Second)
t.Cleanup(func() {
// Delete only this summon, before its persistent caster cleanup.
selectSpikeUnit(t, a, spike)
a.GM(t, ".npc delete")
})
if tc.name == "initially_immune" {
a.WaitUnitAura(t, spike, speed2, 8*time.Second)
a.AssertHasAura(t, shield)
b.AssertHasAura(t, shield)
if a.UnitTarget(spike) != 0 || a.HasAura(mark) || b.HasAura(mark) {
e2eharness.ConfirmedBugf(t, 14076, "spike selected a fully immune player on initial acquisition")
}
a.CancelAura(t, shield)
if !waitSpikeCondition(3*time.Second, func() bool {
return a.HasAura(mark) && a.UnitTarget(spike) == a.GUID && a.UnitHasAura(spike, speed2)
}) {
e2eharness.ConfirmedBugf(t, 14076, "spike stalled after all players were immune on initial acquisition")
}
t.Log("PASS: initially all immune -> acquisition resumes without resetting acceleration")
return
}
if !waitSpikeCondition(3*time.Second, func() bool { return a.HasAura(mark) != b.HasAura(mark) }) {
e2eharness.Preconditionf(t, "expected exactly one marked player")
}
marked, other := a, b
if b.HasAura(mark) {
marked, other = b, a
}
a.WaitUnitTarget(t, spike, marked.GUID, 3*time.Second)
t.Logf("spike=0x%X marked=0x%X other=0x%X spell=%d", spike, marked.GUID, other.GUID, tc.spell)
marked.CastMust(t, tc.spell, marked.GUID, 5*time.Second)
marked.WaitUnitAura(t, marked.GUID, tc.spell, 2*time.Second)
if tc.spell == shield {
if !waitSpikeCondition(3*time.Second, func() bool {
return !marked.HasAura(mark) && other.HasAura(mark) && a.UnitTarget(spike) == other.GUID
}) {
e2eharness.ConfirmedBugf(t, 14076, "Divine Shield must allow retargeting: target=0x%X", a.UnitTarget(spike))
}
t.Log("PASS: Divine Shield causes the spike to mark and target the other player")
other.CastMust(t, shield, other.GUID, 5*time.Second)
if !waitSpikeCondition(3*time.Second, func() bool {
return a.UnitTarget(spike) == 0 && !a.HasAura(mark) && !b.HasAura(mark)
}) {
e2eharness.ConfirmedBugf(t, 14076, "spike must release its target when both players are fully immune")
}
// The existing 7s acceleration timer must keep running without a
// target, then pursuit must resume when one player becomes eligible.
a.WaitUnitAura(t, spike, speed2, 8*time.Second)
marked.AssertHasAura(t, shield)
other.AssertHasAura(t, shield)
marked.CancelAura(t, shield)
if !waitSpikeCondition(3*time.Second, func() bool {
return marked.HasAura(mark) && a.UnitTarget(spike) == marked.GUID
}) {
e2eharness.ConfirmedBugf(t, 14076, "spike did not reacquire player after full immunity ended")
}
if !a.UnitHasAura(spike, speed2) {
e2eharness.ConfirmedBugf(t, 14076, "reacquiring a target reset the spike's speed")
}
t.Log("PASS: both immune -> no target; cancel immunity -> pursuit resumes at accelerated speed")
return
}
// Spike reaches these stationary players in about 7s at its first
// speed. Observe 9s of the 10s protection, including arrival and the
// 7s acceleration event. Polling must not return early on arrival.
reached := false
deadline := time.Now().Add(9 * time.Second)
for time.Now().Before(deadline) {
if !marked.HasAura(protection) {
e2eharness.Preconditionf(t, "Hand of Protection expired before observation completed")
}
if !marked.HasAura(mark) || other.HasAura(mark) || a.UnitTarget(spike) != marked.GUID {
e2eharness.ConfirmedBugf(t, 14076, "spike lost protected target on arrival: target=0x%X mark=%v otherMark=%v",
a.UnitTarget(spike), marked.HasAura(mark), other.HasAura(mark))
}
if obj := a.World.GetObject(spike); obj != nil && obj.HasKnownPosition() {
x, y, z := obj.InterpolatedPosition()
px, py, pz, _ := marked.Pos()
reached = reached || e2eharness.Distance3D(x, y, z, px, py, pz) < 4
}
time.Sleep(25 * time.Millisecond)
}
if !reached {
e2eharness.Preconditionf(t, "spike never reached the protected player; cannot judge arrival behavior")
}
if !a.UnitHasAura(spike, speed2) {
e2eharness.ConfirmedBugf(t, 14076, "spike failed to accelerate while retaining the protected target")
}
t.Log("PASS: spike reaches protected player, keeps the mark and target, and accelerates")
})
}
}
func selectSpikeUnit(t *testing.T, bot *e2eharness.ScenarioBot, guid uint64) {
t.Helper()
if err := bot.World.SetTarget(guid); err != nil {
e2eharness.HarnessFailf(t, "select 0x%X: %v", guid, err)
}
}
func waitSpikeCondition(timeout time.Duration, condition func() bool) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if condition() {
return true
}
time.Sleep(25 * time.Millisecond)
}
return condition()
}
@@ -739,6 +739,14 @@ public:
me->SetCorpseDelay(0);
}
enum SpikeEvents
{
EVENT_SPIKE_SPEED_2 = 1,
EVENT_SPIKE_SPEED_3,
EVENT_SPIKE_RESUME,
EVENT_SPIKE_REACQUIRE,
};
EventMap events;
ObjectGuid TargetGUID;
@@ -752,37 +760,51 @@ public:
me->RemoveAllAuras();
me->GetMotionMaster()->MoveIdle();
events.Reset();
events.RescheduleEvent(3, 4s);
events.RescheduleEvent(EVENT_SPIKE_RESUME, 4s);
}
}
bool CanPursue(Unit* target) const
{
// Physical immunity (Hand of Protection) does not break pursuit, but full immunity does.
return target && me->IsValidAttackTarget(target) && !target->HasAuraType(SPELL_AURA_FEIGN_DEATH)
&& !target->HasStealthAura()
&& !target->HasSchoolImmunityForMask(SPELL_SCHOOL_MASK_ALL, me, nullptr);
}
void SelectNewTarget(bool next)
{
events.CancelEvent(EVENT_SPIKE_REACQUIRE);
if (TargetGUID)
if (Unit* target = ObjectAccessor::GetPlayer(*me, TargetGUID))
target->RemoveAura(SPELL_MARK);
TargetGUID.Clear();
me->AttackStop();
me->GetMotionMaster()->MoveIdle();
if (!next)
{
events.Reset();
me->RemoveAllAuras();
// Start the pursuit even if everyone is currently immune, so acquisition can retry.
DoCastSelf(SPELL_SPIKE_SPEED1, true);
DoCastSelf(SPELL_SPIKE_TRAIL, true);
events.RescheduleEvent(EVENT_SPIKE_SPEED_2, 7s);
}
DoZoneInCombat();
DoResetThreatList();
if (Unit* target = SelectTarget(SelectTargetMethod::Random, 0, 250.0f, true))
if (Unit* target = SelectTarget(SelectTargetMethod::Random, 0, [this](Unit* candidate)
{
return DefaultTargetSelector(me, 250.0f, true, true, 0)(candidate) && CanPursue(candidate);
}))
{
if (!next)
{
me->CastSpell(me, SPELL_SPIKE_SPEED1, true);
me->CastSpell(me, SPELL_SPIKE_TRAIL, true);
events.RescheduleEvent(1, 7s);
}
TargetGUID = target->GetGUID();
me->CastSpell(target, SPELL_MARK, true);
Talk(EMOTE_SPIKE, target);
AttackStart(target);
me->GetMotionMaster()->MoveChase(target);
}
else
events.RescheduleEvent(EVENT_SPIKE_REACQUIRE, 300ms);
}
void Reset() override
@@ -795,11 +817,10 @@ public:
if (TargetGUID)
{
Unit* target = ObjectAccessor::GetPlayer(*me, TargetGUID);
if (!target || !target->HasAura(SPELL_MARK) || !me->IsValidAttackTarget(target) || me->GetMotionMaster()->GetCurrentMovementGeneratorType() != CHASE_MOTION_TYPE || !me->HasUnitState(UNIT_STATE_CHASE_MOVE))
{
// Reaching the marked player clears CHASE_MOVE; that is not a reason to abandon pursuit.
if (!CanPursue(target) || !target->HasAura(SPELL_MARK) || me->GetVictim() != target
|| me->GetMotionMaster()->GetCurrentMovementGeneratorType() != CHASE_MOTION_TYPE)
SelectNewTarget(true);
return;
}
}
events.Update(diff);
@@ -808,18 +829,21 @@ public:
{
case 0:
break;
case 1:
case EVENT_SPIKE_SPEED_2:
me->CastSpell(me, SPELL_SPIKE_SPEED2, true);
events.RescheduleEvent(2, 7s);
events.RescheduleEvent(EVENT_SPIKE_SPEED_3, 7s);
break;
case 2:
case EVENT_SPIKE_SPEED_3:
me->CastSpell(me, SPELL_SPIKE_SPEED3, true);
break;
case 3:
case EVENT_SPIKE_RESUME:
Reset();
break;
case EVENT_SPIKE_REACQUIRE:
SelectNewTarget(true);
break;
}
}