diff --git a/e2e/README.md b/e2e/README.md index 7dcc4813b6..c4a2d34b4a 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -149,7 +149,7 @@ go test -tags=e2e ./local/... -count=1 -v -timeout 30m -parallel 1 | 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 | P1 | covered (`TestAC_26130_*`, `TestAC_25765_*`) | #26130 #25765 | | spells/cast | Charge on dummy; fail path; stance; Raise Dead + ghoul | P1 | covered (`TestAC_27061_*`) | #27061 | -| spells/effects | Charge / grounding totem / Sweeping Strikes Execute | P1 | covered (`TestAC_26997_*`); dummy-summon `blocked-harness` (engineering dummy lifetime) | #26774 #26997 | +| spells/effects | Charge / grounding totem / Sweeping Strikes Execute; forced cast summons at the forced caster, not at the unit that forced it | P1 | covered (`TestAC_26997_*`, `TestEffects_ForceCastDestination`); dummy-summon `blocked-harness` (engineering dummy lifetime) | #26774 #26997 #27621 | | social/group | form / leave / leader / loot method / disband | P2 | covered | — | | social/loot | need/greed / master loot; below-half kill | P1 | covered (`TestAC_26862_*`); chest mid-roll `blocked-harness` (GO 194821 UseGameObject); pass-on-loot delete `blocked-harness` (item-survive after ALL_PASSED) | #26894 #26862 #22000 | | social/trade | item+gold accept; cancel; walk-OOR TARGET_TO_FAR | P1 | covered | #25723 | diff --git a/e2e/suites/spells/effects/effects_e2e_test.go b/e2e/suites/spells/effects/effects_e2e_test.go index e65b181630..b4f02cb714 100644 --- a/e2e/suites/spells/effects/effects_e2e_test.go +++ b/e2e/suites/spells/effects/effects_e2e_test.go @@ -3,6 +3,10 @@ package effects_test import ( + "fmt" + "math" + "strings" + "sync" "testing" "time" @@ -231,3 +235,203 @@ func TestEffects_AddItemCreatePath(t *testing.T) { bot.AssertInventoryAtLeast(t, e2eharness.ItemCorpseDust, 3) t.Logf("PASS create-item seed path count>=3") } + +// PR: https://github.com/azerothcore/azerothcore-wotlk/pull/27621 +// Spell::EffectForceCast handed the forced cast the original caster as its unit target. When the +// triggered spell takes no unit target but does need a destination, Spell::InitExplicitTargets +// turns that unit target into the destination, so the forced cast resolves against the unit that +// forced the cast instead of against the unit that was forced to cast it. +// +// Both trigger shapes are covered: 62221 and 62293 summon at the destination itself, while 48757 +// summons at a fixed offset behind it (TARGET_DEST_DEST_BACK). +func TestEffects_ForceCastDestination(t *testing.T) { + meta.Begin(t, meta.TestMeta{Tags: []string{"med", "spells"}, Runtime: "med", Category: "spells/effects"}) + + const ( + // Northshire open strip (map 0): flat ground, and no ambient summons of these entries to + // confuse a placement oracle. + stripX float32 = -8904.0 + stripY float32 = -128.0 + stripZ float32 = 81.0 + stripMap uint32 = 0 + + // Unkillable Test Dummy 80: faction 7, so the bot is a valid TARGET_UNIT_SRC_AREA_ENEMY + // pick for the area force-casts, and no ScriptName to interfere. + driverEntry = uint32(32171) + + // The driver's own summon lands on the driver, so bot and driver must stand further apart + // than any oracle radius below. + driverStandOff = float32(15) + cacheRange = float32(90) + summonWait = 12 * time.Second + ) + + bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{ + Prefix: "FxFC", + Level: 80, + }) + + // GM command failures are reported as system chat only, so a drive that summons nothing would + // otherwise be indistinguishable from a placement bug. + var chatMu sync.Mutex + var chat []string + bot.World.OnChatMessage = func(_, msg string, _ uint8) { + chatMu.Lock() + chat = append(chat, msg) + chatMu.Unlock() + } + lastChat := func(n int) string { + chatMu.Lock() + defer chatMu.Unlock() + if len(chat) < n { + n = len(chat) + } + return strings.Join(chat[len(chat)-n:], " | ") + } + + bot.Teleport(t, stripX, stripY, stripZ, stripMap) + bot.CombatReady(t) + driver := bot.Spawn(t, driverEntry, 20*time.Second) + if driver == 0 { + e2eharness.Preconditionf(t, "no force-cast driver %d spawned", driverEntry) + } + // .npc add drops the driver on the bot, where both candidate destinations coincide. + bot.Teleport(t, stripX+driverStandOff, stripY, stripZ, stripMap) + bot.CombatReady(t) + bot.CombatStop(t) + driver = bot.WaitUnit(t, driverEntry, 20*time.Second) // the tele cleared the object cache + if driver == 0 { + e2eharness.Preconditionf(t, "driver %d not back in cache after stepping clear", driverEntry) + } + + for _, tc := range []struct { + name string + spellID uint32 + summonEntry uint32 + onCaster float32 + what string + }{ + // 62207 summons 33050 at the caster and force-casts 62221 on every player within 100 yd; + // 62221 summons at its own caster's position. + {"UnstableSunBeam", 62207, 33050, 4, "at the forced caster"}, + // 62301 force-casts 62293 on enemies within 100 yd; 62293 summons the crater marker at its + // own caster's position. This is the path the removed SpellInfoCorrections entry covered. + {"CosmicSmash", 62301, 33104, 4, "at the forced caster"}, + // 48759 force-casts 48757 on its explicit target; 48757 summons 3 yd behind the + // destination, so the offset shape is covered too. + {"SummonBehindForcedCaster", 48759, 27439, 6, "3 yd behind the forced caster"}, + } { + t.Run(tc.name, func(t *testing.T) { + known := summonGUIDs(summonsInCache(bot, tc.summonEntry, cacheRange)) + bot.Face(t, driver) + bot.FlushWorld(t) + // No "triggered": TRIGGERED_FULL_DEBUG_MASK carries TRIGGERED_IGNORE_EFFECTS, which + // would send the cast without running a single effect. + bot.GM(t, fmt.Sprintf(".cast back %d", tc.spellID)) + + fresh := waitNewSummons(bot, tc.summonEntry, cacheRange, known, summonWait) + if len(fresh) == 0 { + e2eharness.Preconditionf(t, "%d force-cast produced no new %d within %s (last chat: %s) (cache: %s)", + tc.spellID, tc.summonEntry, summonWait, lastChat(3), dumpNearby(bot, cacheRange)) + } + drv := bot.World.GetObject(driver) + if drv == nil { + e2eharness.Preconditionf(t, "driver 0x%X left the object cache before the summon landed", driver) + } + bx, by, bz, _ := bot.Pos() + for _, s := range fresh { + t.Logf("%d -> %d guid=0x%X at (%.1f,%.1f,%.1f) dist bot=%.1f driver=%.1f", + tc.spellID, tc.summonEntry, s.guid, s.x, s.y, s.z, + e2eharness.Distance3D(bx, by, bz, s.x, s.y, s.z), + e2eharness.Distance3D(drv.PosX, drv.PosY, drv.PosZ, s.x, s.y, s.z)) + } + near, toBot := nearestSummon(fresh, bx, by, bz) + toDriver := e2eharness.Distance3D(drv.PosX, drv.PosY, drv.PosZ, near.x, near.y, near.z) + if toBot > tc.onCaster { + e2eharness.Assertf(t, "%d: nearest of %d summoned %d sits %.1fy from the forced caster and %.1fy from the original caster, expected %s - the forced cast inherited the original caster as its destination", + tc.spellID, len(fresh), tc.summonEntry, toBot, toDriver, tc.what) + } + t.Logf("PASS %d summoned %d %.1fy from the forced caster (%.1fy from the original caster)", + tc.spellID, tc.summonEntry, toBot, toDriver) + }) + } +} + +type forceCastSummon struct { + guid uint64 + x, y, z float32 +} + +// summonsInCache snapshots every tracked unit of one template within maxDist. +func summonsInCache(bot *e2eharness.ScenarioBot, entry uint32, maxDist float32) []forceCastSummon { + var out []forceCastSummon + for _, u := range bot.World.GetNearbyUnits(maxDist) { + if u.Entry != entry { + continue + } + out = append(out, forceCastSummon{guid: u.GUID, x: u.PosX, y: u.PosY, z: u.PosZ}) + } + return out +} + +func summonGUIDs(summons []forceCastSummon) map[uint64]struct{} { + out := make(map[uint64]struct{}, len(summons)) + for _, s := range summons { + out[s.guid] = struct{}{} + } + return out +} + +func newSummons(bot *e2eharness.ScenarioBot, entry uint32, maxDist float32, known map[uint64]struct{}) []forceCastSummon { + var out []forceCastSummon + for _, s := range summonsInCache(bot, entry, maxDist) { + if _, seen := known[s.guid]; seen { + continue + } + out = append(out, s) + } + return out +} + +// waitNewSummons waits for a unit of entry that was not in known, then settles briefly so a second +// summon from the same cast (62207 also places one on its own caster) joins the same batch. +// Novelty by GUID is what keeps a permanent summon left by an earlier run out of the oracle. +func waitNewSummons(bot *e2eharness.ScenarioBot, entry uint32, maxDist float32, + known map[uint64]struct{}, timeout time.Duration) []forceCastSummon { + deadline := time.Now().Add(timeout) + for { + if fresh := newSummons(bot, entry, maxDist, known); len(fresh) > 0 { + time.Sleep(700 * time.Millisecond) + return newSummons(bot, entry, maxDist, known) + } + if !time.Now().Before(deadline) { + return nil + } + time.Sleep(250 * time.Millisecond) + } +} + +// dumpNearby lists the tracked units around the bot, so a drive that summoned nothing can be told +// apart from a summon the client never received. +func dumpNearby(bot *e2eharness.ScenarioBot, maxDist float32) string { + bx, by, bz, _ := bot.Pos() + var parts []string + for _, u := range bot.World.GetNearbyUnits(maxDist) { + parts = append(parts, fmt.Sprintf("%d@%.0fy", u.Entry, e2eharness.Distance3D(bx, by, bz, u.PosX, u.PosY, u.PosZ))) + } + if len(parts) == 0 { + return "no units tracked" + } + return strings.Join(parts, ",") +} + +func nearestSummon(summons []forceCastSummon, x, y, z float32) (forceCastSummon, float32) { + var best forceCastSummon + bestDist := float32(math.MaxFloat32) + for _, s := range summons { + if d := e2eharness.Distance3D(x, y, z, s.x, s.y, s.z); d < bestDist { + best, bestDist = s, d + } + } + return best, bestDist +} diff --git a/src/server/game/Spells/SpellEffects.cpp b/src/server/game/Spells/SpellEffects.cpp index 207bdd554f..837802af43 100644 --- a/src/server/game/Spells/SpellEffects.cpp +++ b/src/server/game/Spells/SpellEffects.cpp @@ -1043,7 +1043,11 @@ void Spell::EffectForceCast(SpellEffIndex effIndex) } SpellCastTargets targets; - targets.SetUnitTarget(m_caster); + // InitExplicitTargets turns a unit target the triggered spell cannot take into that spell's + // destination, which would anchor the forced cast to the original caster instead of to the + // forced one (e.g. Algalon's Cosmic Smash craters, Elder Brightleaf's Unstable Sun Beams). + if (spellInfo->GetExplicitTargetMask() & (TARGET_FLAG_UNIT_MASK | TARGET_FLAG_CORPSE_MASK)) + targets.SetUnitTarget(m_caster); unitTarget->CastSpell(targets, spellInfo, &values, TRIGGERED_FULL_MASK); } diff --git a/src/server/game/Spells/SpellInfoCorrections.cpp b/src/server/game/Spells/SpellInfoCorrections.cpp index 63d1a6348a..7202927a1d 100644 --- a/src/server/game/Spells/SpellInfoCorrections.cpp +++ b/src/server/game/Spells/SpellInfoCorrections.cpp @@ -2052,12 +2052,6 @@ void SpellMgr::LoadSpellInfoCorrections() spellInfo->AttributesEx4 |= SPELL_ATTR4_NO_CAST_LOG; }); - // Cosmic Smash (Algalon the Observer) - ApplySpellFix({ 62293 }, [](SpellInfo* spellInfo) - { - spellInfo->Effects[EFFECT_0].TargetB = SpellImplicitTargetInfo(TARGET_DEST_CASTER); - }); - // Cosmic Smash (Algalon the Observer) ApplySpellFix({ 62311, 64596 }, [](SpellInfo* spellInfo) {