mirror of
https://gitcode.com/GitHub_Trending/az/azerothcore-wotlk.git
synced 2026-10-10 07:06:38 +08:00
fix(Scripts/TrialOfTheCrusader): retain protected spike targets (#27733)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
+40
-16
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user