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/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/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 | — |
|
| 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 | — |
|
| 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/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 |
|
| 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()
|
||||||
|
}
|
||||||
+39
-15
@@ -739,6 +739,14 @@ public:
|
|||||||
me->SetCorpseDelay(0);
|
me->SetCorpseDelay(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SpikeEvents
|
||||||
|
{
|
||||||
|
EVENT_SPIKE_SPEED_2 = 1,
|
||||||
|
EVENT_SPIKE_SPEED_3,
|
||||||
|
EVENT_SPIKE_RESUME,
|
||||||
|
EVENT_SPIKE_REACQUIRE,
|
||||||
|
};
|
||||||
|
|
||||||
EventMap events;
|
EventMap events;
|
||||||
ObjectGuid TargetGUID;
|
ObjectGuid TargetGUID;
|
||||||
|
|
||||||
@@ -752,37 +760,51 @@ public:
|
|||||||
me->RemoveAllAuras();
|
me->RemoveAllAuras();
|
||||||
me->GetMotionMaster()->MoveIdle();
|
me->GetMotionMaster()->MoveIdle();
|
||||||
events.Reset();
|
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)
|
void SelectNewTarget(bool next)
|
||||||
{
|
{
|
||||||
|
events.CancelEvent(EVENT_SPIKE_REACQUIRE);
|
||||||
if (TargetGUID)
|
if (TargetGUID)
|
||||||
if (Unit* target = ObjectAccessor::GetPlayer(*me, TargetGUID))
|
if (Unit* target = ObjectAccessor::GetPlayer(*me, TargetGUID))
|
||||||
target->RemoveAura(SPELL_MARK);
|
target->RemoveAura(SPELL_MARK);
|
||||||
TargetGUID.Clear();
|
TargetGUID.Clear();
|
||||||
|
me->AttackStop();
|
||||||
|
me->GetMotionMaster()->MoveIdle();
|
||||||
if (!next)
|
if (!next)
|
||||||
{
|
{
|
||||||
events.Reset();
|
events.Reset();
|
||||||
me->RemoveAllAuras();
|
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();
|
DoZoneInCombat();
|
||||||
DoResetThreatList();
|
DoResetThreatList();
|
||||||
if (Unit* target = SelectTarget(SelectTargetMethod::Random, 0, 250.0f, true))
|
if (Unit* target = SelectTarget(SelectTargetMethod::Random, 0, [this](Unit* candidate)
|
||||||
{
|
{
|
||||||
if (!next)
|
return DefaultTargetSelector(me, 250.0f, true, true, 0)(candidate) && CanPursue(candidate);
|
||||||
|
}))
|
||||||
{
|
{
|
||||||
me->CastSpell(me, SPELL_SPIKE_SPEED1, true);
|
|
||||||
me->CastSpell(me, SPELL_SPIKE_TRAIL, true);
|
|
||||||
events.RescheduleEvent(1, 7s);
|
|
||||||
}
|
|
||||||
TargetGUID = target->GetGUID();
|
TargetGUID = target->GetGUID();
|
||||||
me->CastSpell(target, SPELL_MARK, true);
|
me->CastSpell(target, SPELL_MARK, true);
|
||||||
Talk(EMOTE_SPIKE, target);
|
Talk(EMOTE_SPIKE, target);
|
||||||
AttackStart(target);
|
AttackStart(target);
|
||||||
me->GetMotionMaster()->MoveChase(target);
|
me->GetMotionMaster()->MoveChase(target);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
events.RescheduleEvent(EVENT_SPIKE_REACQUIRE, 300ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Reset() override
|
void Reset() override
|
||||||
@@ -795,11 +817,10 @@ public:
|
|||||||
if (TargetGUID)
|
if (TargetGUID)
|
||||||
{
|
{
|
||||||
Unit* target = ObjectAccessor::GetPlayer(*me, 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);
|
SelectNewTarget(true);
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
events.Update(diff);
|
events.Update(diff);
|
||||||
@@ -808,18 +829,21 @@ public:
|
|||||||
{
|
{
|
||||||
case 0:
|
case 0:
|
||||||
break;
|
break;
|
||||||
case 1:
|
case EVENT_SPIKE_SPEED_2:
|
||||||
me->CastSpell(me, SPELL_SPIKE_SPEED2, true);
|
me->CastSpell(me, SPELL_SPIKE_SPEED2, true);
|
||||||
|
|
||||||
events.RescheduleEvent(2, 7s);
|
events.RescheduleEvent(EVENT_SPIKE_SPEED_3, 7s);
|
||||||
break;
|
break;
|
||||||
case 2:
|
case EVENT_SPIKE_SPEED_3:
|
||||||
me->CastSpell(me, SPELL_SPIKE_SPEED3, true);
|
me->CastSpell(me, SPELL_SPIKE_SPEED3, true);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case 3:
|
case EVENT_SPIKE_RESUME:
|
||||||
Reset();
|
Reset();
|
||||||
break;
|
break;
|
||||||
|
case EVENT_SPIKE_REACQUIRE:
|
||||||
|
SelectNewTarget(true);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user