mirror of
https://gitcode.com/GitHub_Trending/az/azerothcore-wotlk.git
synced 2026-10-10 07:06:38 +08:00
fix(Scripts/Ulduar): do not let Thorim's evade despawn trigger his defeat (#27672)
This commit is contained in:
+1
-1
@@ -164,7 +164,7 @@ go test -tags=e2e ./local/... -count=1 -v -timeout 30m -parallel 1
|
||||
| guild/charter_bank | charter buy+turn-in | P2 | covered | — |
|
||||
| instances/bind_reset | party tele; ritual summon | P2 | covered; post-reset summon `blocked-harness` (AcceptSummon after reset) | #10708 |
|
||||
| instances/classic/stratholme | Timmy remains hidden while a relevant Square Scarlet lives, then emerges after the area is clear | P2 | covered (`TestAC_26363_TimmyEmergesAfterSquareCleared`) | #26363 |
|
||||
| instances/ulduar | named tele; Freya wave interval; a Laughing Skull's Lunatic Gaze stops at the brain room's geometry instead of draining sanity through it; Elder Brightleaf's Unstable Sun Beams despawn on their own timer instead of outliving him, one lands under the player rather than all on the elder, and a wave stays capped however many players stand in range; Psychosis and Malady of the Mind stop picking a player once they are at 40 Sanity or less; Algalon holds still for 3s once his Big Bang cast lands instead of resuming melee and Quantum Strike on the next tick; a Writhing Lasher stays up past 62947's own 10s summon duration, because the despawn type its AI sets in `IsSummonedBy` is no longer overwritten on the way out of the summon call | P2 | covered (`TestAC_27095_*`, `TestAC_27602_*`, `TestAC_27590_*`, `TestAC_27539_*`, `TestUlduar_BrightleafSunBeams*`, `TestUlduar_FreyaWardLasherOutlivesSummonDuration`); Kologarn Charge `blocked-harness` (bridge Z after Charge) | #26266 #27095 #27539 #27590 #27602 |
|
||||
| instances/ulduar | named tele; Freya wave interval; a Laughing Skull's Lunatic Gaze stops at the brain room's geometry instead of draining sanity through it; Elder Brightleaf's Unstable Sun Beams despawn on their own timer instead of outliving him, one lands under the player rather than all on the elder, and a wave stays capped however many players stand in range; Psychosis and Malady of the Mind stop picking a player once they are at 40 Sanity or less; Algalon holds still for 3s once his Big Bang cast lands instead of resuming melee and Quantum Strike on the next tick; a Writhing Lasher stays up past 62947's own 10s summon duration, because the despawn type its AI sets in `IsSummonedBy` is no longer overwritten on the way out of the summon call; a wipe to Thorim's melee with a death-persistent damage shield on the last victim spawns no Cache of Storms while his hard-reset despawn runs, and a lethal hit on the respawned Thorim still yields | P2 | covered (`TestAC_27095_*`, `TestAC_27602_*`, `TestAC_27590_*`, `TestAC_27539_*`, `TestUlduar_BrightleafSunBeams*`, `TestUlduar_FreyaWardLasherOutlivesSummonDuration`, `TestUlduar_ThorimEvadeDespawnDoesNotYield`); Kologarn Charge `blocked-harness` (bridge Z after Charge) | #26266 #27095 #27539 #27590 #27602 |
|
||||
| world/gameevents | Call to Arms banners at the Dalaran portals belong to the side they stand on, and the already-correct Warsong set is unchanged. **Wants an exclusive realm**: starting a holiday re-anchors its schedule in the running worldserver until restart; holidays already running are left alone | P2 | covered (`TestAC_24380_*`); Shattrath's 23 positions `gap` | #24380 |
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//go:build e2e
|
||||
|
||||
package ulduar_test
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
|
||||
"github.com/azerothcore/AzerothGhost/client"
|
||||
"github.com/azerothcore/AzerothGhost/e2e/e2eharness"
|
||||
"github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta"
|
||||
)
|
||||
|
||||
// Thorim despawns on evade (HARD_RESET, PR #27059). When his own melee kills the last player,
|
||||
// the evade runs inside that damage call, and the damage-shield pass that follows still calls
|
||||
// DealDamage on the now-dead Thorim with 0 damage because Retribution Aura persists through
|
||||
// death. His defeat check (damage >= health) then held on 0 health: the Cache of Storms spawned,
|
||||
// the encounter credit fired and DONE was written on an object already queued for removal, while
|
||||
// the scheduled respawn brought back a fresh Thorim whose Reset cleared the state again. The
|
||||
// encounter reopened and a second defeat produced a second chest.
|
||||
//
|
||||
// Drive: a paladin bait with Retribution Aura stands in the arena, a second bot on the balcony
|
||||
// unlocks the ring phase and pulls Thorim down onto the bait, whose death is the wipe.
|
||||
// Oracle: no Cache of Storms after the evade, Thorim respawns hostile, and a real lethal hit on the
|
||||
// respawned Thorim still yields (chest spawns, he turns friendly and unattackable).
|
||||
func TestUlduar_ThorimEvadeDespawnDoesNotYield(t *testing.T) {
|
||||
meta.Begin(t, meta.TestMeta{
|
||||
Tags: []string{"long", "instances", "multi_bot"},
|
||||
Runtime: "long",
|
||||
Category: "instances/northrend/ulduar",
|
||||
})
|
||||
|
||||
const (
|
||||
npcThorim = uint32(32865)
|
||||
npcRunicColossus = uint32(32872)
|
||||
npcAncientRuneGiant = uint32(32873)
|
||||
|
||||
goCacheOfStorms10 = uint32(194312)
|
||||
goCacheOfStorms10Hard = uint32(194313)
|
||||
|
||||
spellRetributionAura = uint32(54043)
|
||||
|
||||
// SMSG_SPELLNONMELEEDAMAGELOG (3.3.5a); the harness has no constant for it.
|
||||
opSpellNonMeleeDamageLog = uint16(0x0250)
|
||||
|
||||
unitFlagNonAttackable = uint32(0x00000002)
|
||||
factionFriendly = uint32(35)
|
||||
|
||||
// Thorim's DB spawn on the balcony; the jump-down needs the hitter above z 430.
|
||||
balconyX, balconyY, balconyZ = float32(2131.02), float32(-297.65), float32(438.331)
|
||||
// Arena floor (game_tele BossThorim), inside GetArenaPlayer()'s box.
|
||||
arenaX, arenaY, arenaZ = float32(2135.35), float32(-251.086), float32(419.743)
|
||||
// In front of the Ancient Rune Giant; the Runic Colossus is ~100y from here, in cache range.
|
||||
gauntletX, gauntletY, gauntletZ = float32(2134.57), float32(-430.0), float32(438.331)
|
||||
|
||||
// A chest spawned by the bug is created in the same tick as the death.
|
||||
chestWindow = 6 * time.Second
|
||||
// DespawnOnEvade's default respawn delay is 20s.
|
||||
respawnWindow = 45 * time.Second
|
||||
lethalHit = uint32(50_000_000)
|
||||
)
|
||||
|
||||
bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{
|
||||
Prefix: "ThEvd",
|
||||
Bots: []e2eharness.BotSpec{
|
||||
{Role: "driver", Race: e2eharness.RaceHuman, Class: e2eharness.ClassWarrior, Level: 80, LearnAllClass: true},
|
||||
{Role: "bait", Race: e2eharness.RaceHuman, Class: e2eharness.ClassPaladin, Level: 80, LearnAllClass: true},
|
||||
},
|
||||
})
|
||||
driver := e2eharness.ByRole(t, bots, "driver")
|
||||
bait := e2eharness.ByRole(t, bots, "bait")
|
||||
|
||||
// A plain tele would hand each bot its own instance copy; .summon pulls the bait into the
|
||||
// driver's. Stay GM through the raid enter.
|
||||
e2eharness.FormParty(t, driver, bait)
|
||||
// Reset any Ulduar the leader is bound to so a re-run does not enter a still-loaded instance
|
||||
// left mid-fight by a previous run (harmless no-op on a fresh account).
|
||||
driver.LeaderResetInstances(t, 5*time.Second)
|
||||
driver.Teleport(t, balconyX, balconyY, balconyZ, e2eharness.MapUlduar)
|
||||
if _, _, _, m := driver.Pos(); m != e2eharness.MapUlduar {
|
||||
e2eharness.Preconditionf(t, "driver not in Ulduar after balcony tele map=%d", m)
|
||||
}
|
||||
driverGUID := driver.World.CharGUID()
|
||||
before := bait.World.TeleportSeq()
|
||||
driver.GM(t, ".summon "+bait.Name)
|
||||
if err := bait.World.WaitForTeleportAfter(before, 15*time.Second); err != nil {
|
||||
e2eharness.Preconditionf(t, "bait .summon into Ulduar: %v", err)
|
||||
}
|
||||
colocated := time.Now().Add(15 * time.Second)
|
||||
for bait.World.GetObject(driverGUID) == nil {
|
||||
if !time.Now().Before(colocated) {
|
||||
e2eharness.Preconditionf(t, "bait never saw the driver after .summon: separate instance copies")
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
// The jump-down is gated on the Ancient Rune Giant's death (ACTION_ALLOW_HIT). Both gauntlet
|
||||
// golems are Thorim's summons, up since his Reset; nothing is engaged yet, so GM damage is fine.
|
||||
driver.Teleport(t, gauntletX, gauntletY, gauntletZ, e2eharness.MapUlduar)
|
||||
giant := driver.WaitUnit(t, npcAncientRuneGiant, 15*time.Second)
|
||||
colossus := driver.WaitUnit(t, npcRunicColossus, 15*time.Second)
|
||||
driver.DamageKill(t, []uint64{giant, colossus}, 10_000_000, 20*time.Second)
|
||||
|
||||
driver.Teleport(t, balconyX, balconyY, balconyZ, e2eharness.MapUlduar)
|
||||
bait.Teleport(t, arenaX, arenaY, arenaZ, e2eharness.MapUlduar)
|
||||
// Co-location is already proven (the bait saw the driver's own GUID); the bait never needs
|
||||
// Thorim in its cache, since the packet hook matches him by GUID from raw packets. Creature
|
||||
// GUIDs are shared across one instance, so the driver's Thorim GUID is the bait's too.
|
||||
thorim := driver.WaitUnit(t, npcThorim, 15*time.Second)
|
||||
driver.CombatReady(t)
|
||||
|
||||
// The bait must stay killable by Thorim's melee, so it is left out of god mode (CombatReady
|
||||
// would enable it). Retribution Aura is the death-persistent damage shield that lets the shield
|
||||
// pass reach Thorim after the bait is already dead.
|
||||
bait.GM(t, ".gm off")
|
||||
bait.GM(t, ".cheat god off")
|
||||
bait.FlushWorld(t)
|
||||
bait.CastMust(t, spellRetributionAura, bait.World.CharGUID(), 10*time.Second)
|
||||
if !bait.HasAura(spellRetributionAura) {
|
||||
e2eharness.Preconditionf(t, "bait has no Retribution Aura after the cast")
|
||||
}
|
||||
// The bait keeps its (small) full health; Thorim's melee kills it in a swing or two, and every
|
||||
// swing reflects Retribution Aura back at him.
|
||||
|
||||
// Only Thorim's own hits on the bait matter: his melee (SMSG_ATTACKERSTATEUPDATE, attacker then
|
||||
// target) runs the death-persistent shield pass that reproduces the bug, a spell
|
||||
// (SMSG_SPELLNONMELEEDAMAGELOG, target then caster) does not. Both opcodes are broadcast for
|
||||
// nearby combat too, so filter by GUID or the driver's own hits would misclassify the death.
|
||||
baitGUID := bait.World.CharGUID()
|
||||
var lastMelee, lastSpell atomic.Int64
|
||||
cancelHook := bait.World.AddPacketHook(func(opcode uint16, data []byte) {
|
||||
switch opcode {
|
||||
case client.SmsgAttackerStateUpdate:
|
||||
attacker, off := readPackedGUID(data, 4) // after uint32 HitInfo
|
||||
if target, _ := readPackedGUID(data, off); attacker == thorim && target == baitGUID {
|
||||
lastMelee.Store(time.Now().UnixNano())
|
||||
}
|
||||
case opSpellNonMeleeDamageLog:
|
||||
target, off := readPackedGUID(data, 0)
|
||||
if caster, _ := readPackedGUID(data, off); caster == thorim && target == baitGUID {
|
||||
lastSpell.Store(time.Now().UnixNano())
|
||||
}
|
||||
}
|
||||
})
|
||||
defer cancelHook()
|
||||
|
||||
// Killing the Ancient Rune Giant already set ACTION_ALLOW_HIT, so a player hit from the balcony
|
||||
// (z > 430) drops Thorim into the ring phase. He resets threat onto the arena player (the bait)
|
||||
// and chases her.
|
||||
dx, dy, dz, _ := driver.Pos()
|
||||
t.Logf("driver on balcony at (%.1f,%.1f,%.1f)", dx, dy, dz)
|
||||
if dz <= 430 {
|
||||
e2eharness.Preconditionf(t, "driver stands at z=%.1f, below the balcony threshold the jump needs", dz)
|
||||
}
|
||||
jumpDeadline := time.Now().Add(15 * time.Second)
|
||||
jumped := false
|
||||
for time.Now().Before(jumpDeadline) {
|
||||
driver.Damage(t, thorim, 1)
|
||||
obj := driver.World.GetObject(thorim)
|
||||
if obj != nil && obj.PosZ < 430 {
|
||||
jumped = true
|
||||
break
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
if !jumped {
|
||||
e2eharness.Preconditionf(t, "Thorim did not jump into the arena after balcony hits")
|
||||
}
|
||||
// The driver leaves combat so the bait's death is the wipe that ends Thorim's combat.
|
||||
driver.CombatStop(t)
|
||||
|
||||
bait.WaitDead(t, 25*time.Second)
|
||||
diedAt := time.Now()
|
||||
if lastMelee.Load() == 0 || lastSpell.Load() > lastMelee.Load() {
|
||||
e2eharness.Preconditionf(t, "bait died to a spell, not to Thorim's melee: no damage-shield pass to judge")
|
||||
}
|
||||
t.Logf("bait died to melee at %s", diedAt.Format(time.StampMilli))
|
||||
|
||||
// A chest here is the defeat block running on the despawning Thorim. This drive never summons
|
||||
// Sif, so Thorim lacks the Touch of Dominion trigger (62565) and the ring phase flags hard mode;
|
||||
// the defeat block would then spawn the hard-mode Cache (194313), so accept either entry.
|
||||
if g := anyCacheOfStorms(t, driver.World, goCacheOfStorms10, goCacheOfStorms10Hard, chestWindow); g != 0 {
|
||||
e2eharness.Assertf(t, "Cache of Storms 0x%X spawned from Thorim's evade despawn", g)
|
||||
}
|
||||
|
||||
// The hard reset brings back a fresh, hostile Thorim on the balcony. On the buggy core the same
|
||||
// evade also produced the chest above; here he is simply attackable again.
|
||||
respawned := waitHostileThorim(t, driver, npcThorim, unitFlagNonAttackable, factionFriendly, respawnWindow)
|
||||
if respawned == thorim {
|
||||
e2eharness.Preconditionf(t, "Thorim 0x%X was never despawned by the evade (HARD_RESET missing?)", thorim)
|
||||
}
|
||||
t.Logf("after evade: respawned hostile Thorim 0x%X (was 0x%X)", respawned, thorim)
|
||||
|
||||
// A real lethal hit on the respawned Thorim must still yield: chest spawns and he turns friendly
|
||||
// and unattackable. This proves the guard did not break the normal defeat.
|
||||
t.Run("LethalHitStillYields", func(t *testing.T) {
|
||||
driver.Damage(t, respawned, lethalHit)
|
||||
chest := anyCacheOfStorms(t, driver.World, goCacheOfStorms10, goCacheOfStorms10Hard, 10*time.Second)
|
||||
if chest == 0 {
|
||||
e2eharness.Assertf(t, "no Cache of Storms after a lethal hit on Thorim")
|
||||
}
|
||||
yieldDeadline := time.Now().Add(5 * time.Second)
|
||||
yielded := false
|
||||
for time.Now().Before(yieldDeadline) {
|
||||
obj := driver.World.GetObject(respawned)
|
||||
if obj != nil && obj.Value(client.UnitFieldFlags)&unitFlagNonAttackable != 0 && obj.Value(client.UnitFieldFaction) == factionFriendly {
|
||||
yielded = true
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if !yielded {
|
||||
e2eharness.Assertf(t, "Thorim not friendly and unattackable after his defeat")
|
||||
}
|
||||
t.Logf("PASS lethal hit yields: chest=0x%X", chest)
|
||||
})
|
||||
|
||||
t.Logf("PASS Thorim evade despawn does not yield: old=0x%X respawned=0x%X", thorim, respawned)
|
||||
}
|
||||
|
||||
// waitHostileThorim waits for a living Thorim that is attackable and not friendly.
|
||||
func waitHostileThorim(t *testing.T, b *e2eharness.ScenarioBot, entry, nonAttackable, friendly uint32, timeout time.Duration) uint64 {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
for _, g := range e2eharness.LivingByEntries(b.World, 120, entry) {
|
||||
obj := b.World.GetObject(g)
|
||||
if obj == nil {
|
||||
continue
|
||||
}
|
||||
if obj.Value(client.UnitFieldFlags)&nonAttackable == 0 && obj.Value(client.UnitFieldFaction) != friendly {
|
||||
return g
|
||||
}
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
e2eharness.Assertf(t, "no hostile Thorim within %s of the evade", timeout)
|
||||
return 0
|
||||
}
|
||||
|
||||
// anyCacheOfStorms returns the guid of the first Cache of Storms of either entry found in range,
|
||||
// or 0 if none appears within timeout.
|
||||
func anyCacheOfStorms(t *testing.T, w *client.WorldClient, normal, hard uint32, timeout time.Duration) uint64 {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
for _, entry := range []uint32{normal, hard} {
|
||||
if g := w.FindGameObjectByEntry(entry, 60); g != 0 {
|
||||
return g
|
||||
}
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return 0
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// readPackedGUID reads a WoW packed GUID starting at off and returns the guid and the next offset.
|
||||
// The first byte is a bitmask; each set bit i contributes byte i of the guid, low bit first.
|
||||
func readPackedGUID(b []byte, off int) (uint64, int) {
|
||||
if off >= len(b) {
|
||||
return 0, off
|
||||
}
|
||||
mask := b[off]
|
||||
off++
|
||||
var guid uint64
|
||||
for i := 0; i < 8; i++ {
|
||||
if mask&(1<<uint(i)) != 0 {
|
||||
if off >= len(b) {
|
||||
break
|
||||
}
|
||||
guid |= uint64(b[off]) << (8 * uint(i))
|
||||
off++
|
||||
}
|
||||
}
|
||||
return guid, off
|
||||
}
|
||||
@@ -552,7 +552,9 @@ struct boss_thorim : public BossAI
|
||||
me->AddThreat(player, 1000.0f);
|
||||
}
|
||||
|
||||
if (damage >= me->GetHealth()|| me->GetHealth()<2)
|
||||
// The hook also fires with zero damage on an already despawned Thorim (damage shields after
|
||||
// his own lethal melee during the HARD_RESET evade), where 0 >= 0 would count as a defeat
|
||||
if (me->IsAlive() && damage >= me->GetHealth())
|
||||
{
|
||||
damage = 0;
|
||||
if (!_encounterFinished)
|
||||
|
||||
Reference in New Issue
Block a user