From 102c26d97194dc436cfb9a1c6660805444ee9624 Mon Sep 17 00:00:00 2001 From: moostigre Date: Mon, 5 Oct 2026 02:43:10 +0200 Subject: [PATCH] fix(Scripts/TrialOfTheCrusader): retain protected spike targets (#27733) --- e2e/README.md | 1 + .../instances/trial_of_the_crusader/spikes.md | 44 ++++ .../spikes_accounts_e2e_test.go | 218 ++++++++++++++++++ .../trial_of_the_crusader/spikes_e2e_test.go | 201 ++++++++++++++++ .../boss_anubarak_trial.cpp | 56 +++-- 5 files changed, 504 insertions(+), 16 deletions(-) create mode 100644 e2e/suites/instances/trial_of_the_crusader/spikes.md create mode 100644 e2e/suites/instances/trial_of_the_crusader/spikes_accounts_e2e_test.go create mode 100644 e2e/suites/instances/trial_of_the_crusader/spikes_e2e_test.go diff --git a/e2e/README.md b/e2e/README.md index f7692fa3a0..43fce5d0cb 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -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 | diff --git a/e2e/suites/instances/trial_of_the_crusader/spikes.md b/e2e/suites/instances/trial_of_the_crusader/spikes.md new file mode 100644 index 0000000000..6623fa8fcb --- /dev/null +++ b/e2e/suites/instances/trial_of_the_crusader/spikes.md @@ -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). diff --git a/e2e/suites/instances/trial_of_the_crusader/spikes_accounts_e2e_test.go b/e2e/suites/instances/trial_of_the_crusader/spikes_accounts_e2e_test.go new file mode 100644 index 0000000000..28e517cd41 --- /dev/null +++ b/e2e/suites/instances/trial_of_the_crusader/spikes_accounts_e2e_test.go @@ -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 +} diff --git a/e2e/suites/instances/trial_of_the_crusader/spikes_e2e_test.go b/e2e/suites/instances/trial_of_the_crusader/spikes_e2e_test.go new file mode 100644 index 0000000000..dfaa2a1185 --- /dev/null +++ b/e2e/suites/instances/trial_of_the_crusader/spikes_e2e_test.go @@ -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() +} diff --git a/src/server/scripts/Northrend/CrusadersColiseum/TrialOfTheCrusader/boss_anubarak_trial.cpp b/src/server/scripts/Northrend/CrusadersColiseum/TrialOfTheCrusader/boss_anubarak_trial.cpp index 552648cbb2..9068827030 100644 --- a/src/server/scripts/Northrend/CrusadersColiseum/TrialOfTheCrusader/boss_anubarak_trial.cpp +++ b/src/server/scripts/Northrend/CrusadersColiseum/TrialOfTheCrusader/boss_anubarak_trial.cpp @@ -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; } }