mirror of
https://gitcode.com/GitHub_Trending/az/azerothcore-wotlk.git
synced 2026-10-10 07:06:38 +08:00
fix(Core/Spells): stop forced casts from inheriting the caster as their destination (#27621)
Spell::EffectForceCast always 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 original caster instead of against the unit that was forced to cast it. Only pass the unit target when the triggered spell's explicit target mask accepts one, which is the same test InitExplicitTargets applies before discarding it. Twelve spells in the client data force-cast a spell that needs a destination but takes no unit target: 42073, 48759, 52187, 57838, 58566, 62207, 62301, 62921, 64088, 64598, 69839 and 70882. Seven of them place their summon somewhere new - 48759, 52187, 57838, 58566, 62207, 62921 and 64088, whose triggers summon at the destination itself or at a fixed offset from it. The rest do not move: 42073 force-casts on itself, 69839's force-cast effect is prevented by a spell script, and 70882's trigger overwrites the destination with TARGET_DEST_CASTER. 62301 and 64598 are the last two. Their trigger 62293 inherited the destination like the others, but a spell_info correction forcing its TargetB to TARGET_DEST_CASTER had SelectImplicitCasterDestTargets overwrite it with the forced caster afterwards, so Algalon's craters landed correctly in spite of the bug. With the root cause fixed that correction is dead code and goes with it; the fallback destination InitExplicitTargets now picks is the same position the correction used to write, so nothing moves in-game. TestEffects_ForceCastDestination covers both trigger shapes: 62221 and 62293 summoning at the destination itself, and 48757 summoning at an offset from it. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user