mirror of
https://gitcode.com/GitHub_Trending/az/azerothcore-wotlk.git
synced 2026-10-10 07:06:38 +08:00
fix(Core/Spells): respect hostile self-immunity (#27482)
Co-authored-by: sogladev <[email protected]> Co-authored-by: sogladev <[email protected]> Co-authored-by: sudlud <[email protected]>
This commit is contained in:
co-authored by
sogladev
sogladev
sudlud
parent
d7ce67dc18
commit
e89548f2f7
@@ -0,0 +1,160 @@
|
||||
//go:build e2e
|
||||
|
||||
package immunity_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
auraUpdateAll = uint16(0x495) // SMSG_AURA_UPDATE_ALL
|
||||
auraUpdate = uint16(0x496) // SMSG_AURA_UPDATE
|
||||
)
|
||||
|
||||
// decodeAuraSlots preserves duplicate spell IDs in separate slots. The pinned
|
||||
// AzerothGhost client exposes only unique IDs and maximum stacks, so neither
|
||||
// public accessor can observe Vashj's four separate Magic Barrier applications.
|
||||
// Layout: AuraApplication::BuildUpdatePacket and Player::GetAurasForTarget.
|
||||
func decodeAuraSlots(data []byte) (uint64, map[uint8]uint32, bool) {
|
||||
r := bytes.NewReader(data)
|
||||
readGUID := func() (uint64, error) {
|
||||
mask, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var guid uint64
|
||||
for i := uint(0); i < 8; i++ {
|
||||
if mask&(1<<i) != 0 {
|
||||
b, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
guid |= uint64(b) << (i * 8)
|
||||
}
|
||||
}
|
||||
return guid, nil
|
||||
}
|
||||
target, err := readGUID()
|
||||
if err != nil {
|
||||
return 0, nil, false
|
||||
}
|
||||
slots := make(map[uint8]uint32)
|
||||
for r.Len() > 0 {
|
||||
slot, _ := r.ReadByte()
|
||||
var spell uint32
|
||||
if binary.Read(r, binary.LittleEndian, &spell) != nil {
|
||||
return 0, nil, false
|
||||
}
|
||||
slots[slot] = spell
|
||||
if spell == 0 {
|
||||
continue // removal has no flags, caster or duration
|
||||
}
|
||||
var header [3]byte // flags, caster level, stack count/charges
|
||||
if _, err := io.ReadFull(r, header[:]); err != nil {
|
||||
return 0, nil, false
|
||||
}
|
||||
flags := header[0]
|
||||
if flags&0x08 == 0 { // AFLAG_CASTER: caster omitted for self-cast
|
||||
if _, err := readGUID(); err != nil {
|
||||
return 0, nil, false
|
||||
}
|
||||
}
|
||||
skip := 0
|
||||
if flags&0x20 != 0 { // AFLAG_DURATION
|
||||
skip += 8
|
||||
}
|
||||
if flags&0x40 != 0 { // AFLAG_ANY_EFFECT_AMOUNT_SENT
|
||||
for bit := byte(1); bit <= 4; bit <<= 1 {
|
||||
if flags&bit != 0 {
|
||||
skip += 4
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.Len() < skip {
|
||||
return 0, nil, false
|
||||
}
|
||||
r.Seek(int64(skip), io.SeekCurrent)
|
||||
}
|
||||
return target, slots, true
|
||||
}
|
||||
|
||||
func applyAuraSlots(current, updates map[uint8]uint32, replace bool) {
|
||||
if replace {
|
||||
clear(current)
|
||||
}
|
||||
for slot, spell := range updates {
|
||||
if spell == 0 {
|
||||
delete(current, slot)
|
||||
} else {
|
||||
current[slot] = spell
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuraSlotsPreserveSeparateApplications(t *testing.T) {
|
||||
var packet bytes.Buffer
|
||||
packet.Write([]byte{0x81, 0x34, 0x12}) // packed target 0x1200000000000034
|
||||
for slot := byte(0); slot < 4; slot++ {
|
||||
packet.WriteByte(slot)
|
||||
binary.Write(&packet, binary.LittleEndian, uint32(38112))
|
||||
packet.Write([]byte{0x20, 73, 0}) // duration, external caster, no stacks
|
||||
packet.Write([]byte{0x01, slot + 1})
|
||||
binary.Write(&packet, binary.LittleEndian, uint32(6000))
|
||||
binary.Write(&packet, binary.LittleEndian, uint32(5000))
|
||||
}
|
||||
target, slots, ok := decodeAuraSlots(packet.Bytes())
|
||||
if !ok || target != 0x1200000000000034 || len(slots) != 4 {
|
||||
t.Fatalf("decoded target=%X slots=%v ok=%v", target, slots, ok)
|
||||
}
|
||||
for slot := uint8(0); slot < 4; slot++ {
|
||||
if slots[slot] != 38112 {
|
||||
t.Fatalf("slot %d: got %d, want Magic Barrier", slot, slots[slot])
|
||||
}
|
||||
}
|
||||
// Updating an existing slot must not count it twice; removals and full
|
||||
// snapshots must discard stale applications.
|
||||
applyAuraSlots(slots, map[uint8]uint32{0: 38112}, false)
|
||||
if len(slots) != 4 {
|
||||
t.Fatal("repeated update changed the application count")
|
||||
}
|
||||
_, removal, ok := decodeAuraSlots([]byte{1, 1, 0, 0, 0, 0, 0})
|
||||
if !ok {
|
||||
t.Fatal("removal packet rejected")
|
||||
}
|
||||
applyAuraSlots(slots, removal, false)
|
||||
if len(slots) != 3 {
|
||||
t.Fatal("removal did not clear the slot")
|
||||
}
|
||||
applyAuraSlots(slots, map[uint8]uint32{7: 38112}, true)
|
||||
if len(slots) != 1 || slots[7] != 38112 {
|
||||
t.Fatal("full snapshot retained stale slots")
|
||||
}
|
||||
_, empty, ok := decodeAuraSlots([]byte{1, 1})
|
||||
if !ok {
|
||||
t.Fatal("empty snapshot rejected")
|
||||
}
|
||||
applyAuraSlots(slots, empty, true)
|
||||
if len(slots) != 0 {
|
||||
t.Fatal("empty snapshot did not clear slots")
|
||||
}
|
||||
// A partial nonempty record is rejected instead of partially updating state.
|
||||
for _, length := range []int{0, 1, 2, 4, 7, 9, packet.Len() - 1} {
|
||||
if _, _, ok := decodeAuraSlots(packet.Bytes()[:length]); ok {
|
||||
t.Fatalf("accepted truncated packet of length %d", length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuraSlotsSkipSelfCastEffectAmounts(t *testing.T) {
|
||||
// Self-cast aura with effect amounts 0 and 2, followed by a removal.
|
||||
packet := []byte{1, 1, 3, 0xe0, 0x94, 0, 0, 0x4d, 73, 1}
|
||||
packet = append(packet, make([]byte, 8)...)
|
||||
packet = append(packet, 4, 0, 0, 0, 0)
|
||||
_, slots, ok := decodeAuraSlots(packet)
|
||||
if !ok || len(slots) != 2 || slots[3] != 38112 || slots[4] != 0 {
|
||||
t.Fatalf("decoded slots=%v ok=%v", slots, ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//go:build e2e
|
||||
|
||||
package immunity_test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
|
||||
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
|
||||
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
|
||||
)
|
||||
|
||||
// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/27355
|
||||
// Cyclone makes its target immune to another Cyclone while active, so the Druid
|
||||
// cannot refresh it on the same target (only re-cast after it ends).
|
||||
func TestAC_27355_CycloneCannotReapplyWhileActive(t *testing.T) {
|
||||
meta.Begin(t, meta.TestMeta{
|
||||
Tags: []string{"med", "spells", "combat", "issue", "serial"},
|
||||
Runtime: "med",
|
||||
Issue: 27355,
|
||||
Category: "spells/immunity",
|
||||
})
|
||||
|
||||
const (
|
||||
spellCyclone = uint32(33786) // Druid Cyclone (WotLK)
|
||||
dummy = uint32(32171) // hostile dummy; no CC immunity
|
||||
cycloneDur = 6 * time.Second
|
||||
recastDelay = 4500 * time.Millisecond // leave time to observe an immune instant recast
|
||||
judgeSlack = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||
Prefix: "Cyc27", Race: e2eharness.RaceTauren,
|
||||
Class: e2eharness.ClassDruid, Level: 80, LearnAllClass: true,
|
||||
})
|
||||
bot.TeleportPad(t, e2eharness.PackagePad(t))
|
||||
// Northshire strip (map 0): open and spawn-safe.
|
||||
const (
|
||||
cx, cy, cz float32 = -8904.0, -128.0, 81.0
|
||||
cm uint32 = 0
|
||||
)
|
||||
bot.Teleport(t, cx, cy, cz, cm)
|
||||
bot.CombatReadyFull(t)
|
||||
bot.CombatStop(t)
|
||||
bot.FlushWorld(t)
|
||||
bot.Learn(t, spellCyclone)
|
||||
// Remove cast time so the second cast cannot land after the first aura expires.
|
||||
bot.GM(t, ".cheat casttime on")
|
||||
|
||||
guid := bot.Spawn(t, dummy, 30*time.Second)
|
||||
if guid == 0 {
|
||||
e2eharness.Preconditionf(t, "failed to spawn Cyclone target")
|
||||
}
|
||||
_ = bot.World.SetTarget(guid)
|
||||
bot.Face(t, guid)
|
||||
|
||||
// First Cyclone must land and hold the target.
|
||||
bot.CastMust(t, spellCyclone, guid, 15*time.Second)
|
||||
bot.WaitUnitAura(t, guid, spellCyclone, 3*time.Second)
|
||||
firstAppliedAt := time.Now()
|
||||
|
||||
// Recast while the first Cyclone is about to expire. A refresh (the bug) would
|
||||
// replace it with a fresh DR'd aura that outlasts the ~6s window; an immune recast
|
||||
// leaves the original to end on its own.
|
||||
recastAt := firstAppliedAt.Add(recastDelay)
|
||||
for time.Now().Before(recastAt) && bot.UnitHasAura(guid, spellCyclone) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if !bot.UnitHasAura(guid, spellCyclone) {
|
||||
e2eharness.Preconditionf(t, "Cyclone faded before the recast window")
|
||||
}
|
||||
bot.Face(t, guid)
|
||||
if result, err := bot.TryCast(t, spellCyclone, guid, 10*time.Second); err != nil {
|
||||
e2eharness.HarnessFailf(t, "second Cyclone produced no cast result: %v", err)
|
||||
} else if !result.Success {
|
||||
e2eharness.Preconditionf(t, "second Cyclone failed to cast: %s", e2eharness.SpellFailReasonName(result.FailReason))
|
||||
}
|
||||
|
||||
// After the recast the target must drop out within the time left of the first
|
||||
// Cyclone; still being cycloned past that means it was refreshed.
|
||||
deadline := firstAppliedAt.Add(cycloneDur + judgeSlack)
|
||||
if !time.Now().Before(deadline) {
|
||||
e2eharness.Preconditionf(t, "cast response arrived after the observation window")
|
||||
}
|
||||
for time.Now().Before(deadline) {
|
||||
if !bot.UnitHasAura(guid, spellCyclone) {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if bot.UnitHasAura(guid, spellCyclone) {
|
||||
e2eharness.ConfirmedBugf(t, 27355,
|
||||
"second Cyclone refreshed the active Cyclone; recast should be immune")
|
||||
}
|
||||
// The spell must become usable again once its immunity aura has ended.
|
||||
bot.Face(t, guid)
|
||||
bot.CastMust(t, spellCyclone, guid, 10*time.Second)
|
||||
bot.WaitUnitAura(t, guid, spellCyclone, 3*time.Second)
|
||||
bot.AssertWorldAlive(t)
|
||||
t.Logf("PASS second Cyclone did not refresh active Cyclone on dummy 0x%X", guid)
|
||||
}
|
||||
|
||||
// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/25481
|
||||
// PR: https://github.com/azerothcore/azerothcore-wotlk/pull/25984
|
||||
// Lady Vashj (21212) is guarded by four shield generators; entering phase 2 must
|
||||
// summon all four beams and apply one Magic Barrier (38112) per generator.
|
||||
func TestAC_25481_LadyVashjFourMagicBarriers(t *testing.T) {
|
||||
meta.Begin(t, meta.TestMeta{
|
||||
Tags: []string{"long", "spells", "instances", "issue", "serial"},
|
||||
Runtime: "long",
|
||||
Issue: 25481,
|
||||
Category: "spells/immunity",
|
||||
})
|
||||
|
||||
const (
|
||||
npcLadyVashj = uint32(21212)
|
||||
spellMagicBarrier = uint32(38112)
|
||||
)
|
||||
|
||||
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
|
||||
Prefix: "Vashj", Level: 80, LearnAllClass: true,
|
||||
})
|
||||
// Enter the raid instance while still GM (a solo, non-GM player cannot be placed
|
||||
// on a raid map). CombatReady below drops GM mode and enables god so Vashj
|
||||
// actually engages us instead of ignoring an invisible GM.
|
||||
bot.GoCreatureID(t, npcLadyVashj)
|
||||
vashj := bot.WaitUnit(t, npcLadyVashj, 30*time.Second)
|
||||
if vashj == 0 {
|
||||
e2eharness.Preconditionf(t, "Lady Vashj (21212) not found after .go creature id 21212")
|
||||
}
|
||||
// Observe slots before phase 2. GetActiveAuras deduplicates spell IDs, and
|
||||
// AuraStacks returns the maximum stack count, not the number of applications.
|
||||
var auraMu sync.Mutex
|
||||
auraSlots := make(map[uint8]uint32)
|
||||
cancel := bot.World.AddPacketHook(func(opcode uint16, data []byte) {
|
||||
if opcode != auraUpdate && opcode != auraUpdateAll {
|
||||
return
|
||||
}
|
||||
target, slots, ok := decodeAuraSlots(data)
|
||||
if !ok || target != vashj {
|
||||
return
|
||||
}
|
||||
auraMu.Lock()
|
||||
defer auraMu.Unlock()
|
||||
applyAuraSlots(auraSlots, slots, opcode == auraUpdateAll)
|
||||
})
|
||||
defer cancel()
|
||||
bot.CombatReadyFull(t)
|
||||
bot.Engage(t, vashj, 20*time.Second)
|
||||
// Phase 2 starts at 70% HP; drop her below that without killing her.
|
||||
bot.DamageToFraction(t, vashj, 0.5, 30*time.Second)
|
||||
|
||||
// The four phase-2 beams each apply their own Magic Barrier (one aura slot per
|
||||
// generator/caster), so count aura applications rather than stack count.
|
||||
barriers := func() int {
|
||||
auraMu.Lock()
|
||||
defer auraMu.Unlock()
|
||||
n := 0
|
||||
for _, id := range auraSlots {
|
||||
if id == spellMagicBarrier {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
deadline := time.Now().Add(8 * time.Second)
|
||||
seen := 0
|
||||
for time.Now().Before(deadline) {
|
||||
seen = barriers()
|
||||
if seen >= 4 {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Logf("Lady Vashj Magic Barrier applications=%d (want 4)", seen)
|
||||
if seen == 0 {
|
||||
e2eharness.Preconditionf(t, "no Magic Barrier applied in phase 2 (encounter/reach not driven)")
|
||||
}
|
||||
if seen < 4 {
|
||||
e2eharness.ConfirmedBugf(t, 25481,
|
||||
"Lady Vashj has %d Magic Barrier application(s) in phase 2, expected 4 (positive school-immunity stacking broke)",
|
||||
seen)
|
||||
}
|
||||
bot.AssertWorldAlive(t)
|
||||
t.Logf("PASS Lady Vashj has 4 Magic Barriers in phase 2")
|
||||
}
|
||||
@@ -9889,8 +9889,9 @@ bool Unit::HasSchoolImmunityForMask(SpellSchoolMask schoolMask, Unit const* cast
|
||||
uint32 accumulatedMask = 0;
|
||||
for (auto const& [immunitySchoolMask, immunityAuraId] : m_spellImmune[IMMUNITY_SCHOOL])
|
||||
{
|
||||
// Skip the spell's own immunity entry
|
||||
if (spellInfo && immunityAuraId == spellInfo->Id)
|
||||
// Beneficial immunities may refresh themselves. Hostile ones (Cyclone, Banish)
|
||||
// must still report immunity when cast again while their aura is active.
|
||||
if (spellInfo && spellInfo->IsPositive() && immunityAuraId == spellInfo->Id)
|
||||
continue;
|
||||
|
||||
SpellInfo const* immuneSpellInfo = sSpellMgr->GetSpellInfo(immunityAuraId);
|
||||
|
||||
Reference in New Issue
Block a user