fix(Scripts/Ulduar): stop Freya's Tidal Wave when its cast is kicked (#27636)

This commit is contained in:
Andrew
2026-09-18 19:06:44 -03:00
committed by GitHub
parent e72b221969
commit a53ddd813e
4 changed files with 342 additions and 8 deletions
@@ -0,0 +1,7 @@
--
-- Ancient Water Spirit: Tidal Wave damage and knockback now follow the cast, not a timer
DELETE FROM `spell_script_names` WHERE `spell_id` IN (62653, 62935) AND `ScriptName` = 'spell_freya_tidal_wave';
INSERT INTO `spell_script_names` (`spell_id`, `ScriptName`) VALUES
(62653, 'spell_freya_tidal_wave'),
(62935, 'spell_freya_tidal_wave');
+1 -1
View File
@@ -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; 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 |
| 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; an Ancient Water Spirit whose Tidal Wave is kicked casts no damage-and-knockback follow-up | P2 | covered (`TestAC_27095_*`, `TestAC_27602_*`, `TestAC_27590_*`, `TestAC_27539_*`, `TestAC_27455_*`, `TestUlduar_BrightleafSunBeams*`, `TestUlduar_FreyaWardLasherOutlivesSummonDuration`, `TestUlduar_ThorimEvadeDespawnDoesNotYield`); Kologarn Charge `blocked-harness` (bridge Z after Charge) | #26266 #27095 #27455 #27539 #27590 #27602 |
| world/dalaran | Dalaran's faction guards eject a hostile player from the Silver Enclave and Sunreaver's Sanctuary, and from nowhere else — public mailboxes, the street, the sewers and the neutral inn are left alone. Both directions are asserted: only checking public ground would pass a build where the guards never fire. One case parks a pet inside a quarter with its owner outside, since every other case is a lone player and would not notice the guard reading the owner's position instead of the trespasser's | P2 | covered (`TestAC_4467_*`) | #4467 |
| 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 |
@@ -3,8 +3,10 @@
package ulduar_test
import (
"encoding/binary"
"fmt"
"math"
"sync"
"testing"
"time"
@@ -468,6 +470,20 @@ type sunBeamSnap struct {
x, y, z float32
}
// unitFlags reads UNIT_FIELD_FLAGS off a unit in the object cache.
func unitFlags(bot *e2eharness.ScenarioBot, guid uint64) uint32 {
obj := bot.World.GetObject(guid)
if obj == nil {
return 0
}
return obj.Value(client.UnitFieldFlags)
}
// elderNotSelectable reports the flag boss_freya_elder_brightleaf::JustEngagedWith gates on.
func elderNotSelectable(bot *e2eharness.ScenarioBot, guid uint64) bool {
return unitFlags(bot, guid)&client.UnitFlagNotSelectable != 0
}
// sunBeamsInCache reports beams by presence, not liveness: the bug is the object still existing.
func sunBeamsInCache(bot *e2eharness.ScenarioBot, entry uint32, maxDist float32) []sunBeamSnap {
var out []sunBeamSnap
@@ -508,6 +524,35 @@ func waitLivingBrightleaf(t *testing.T, bot *e2eharness.ScenarioBot) uint64 {
if bot.UnitHasAura(elder, spellPurpleBanish) || bot.UnitHasAura(elder, spellBrightleafEssence) {
e2eharness.Preconditionf(t, "Elder Brightleaf is banished into Freya's hard mode in this instance, so he schedules no sun beams")
}
// Freya's hard-mode activation full-heals every living elder, flags it
// UNIT_FLAG_NOT_SELECTABLE, turns it REACT_PASSIVE and puts it in combat with the zone.
// Either leftover suppresses the beams: boss_freya_elder_brightleaf::JustEngagedWith returns
// on that flag without scheduling anything, and an elder still in combat never gets a second
// JustEngagedWith when this bot pulls it. The banish auras applied alongside are dropped when
// Freya resets, so the aura checks above see a healthy elder either way. Killing it and
// respawning runs the Reset that clears both.
if elderNotSelectable(bot, elder) || bot.UnitInCombat(elder) {
t.Logf("Elder Brightleaf is left over from a Freya pull in this instance (flags=0x%X inCombat=%v); respawning him",
unitFlags(bot, elder), bot.UnitInCombat(elder))
bot.DamageKill(t, []uint64{elder}, 10_000_000, 15*time.Second)
if err := bot.World.SetTarget(elder); err != nil {
e2eharness.Preconditionf(t, "select Elder Brightleaf to respawn: %v", err)
}
bot.GM(t, ".respawn")
respawned := time.Now().Add(30 * time.Second)
for {
elder = bot.WaitUnit(t, npcElderBrightleaf, 10*time.Second)
if hp, maxHP := bot.UnitHP(elder); maxHP > 0 && hp > 0 && !elderNotSelectable(bot, elder) && !bot.UnitInCombat(elder) {
break
}
if !time.Now().Before(respawned) {
e2eharness.Preconditionf(t, "Elder Brightleaf still unpullable after respawn (flags=0x%X inCombat=%v), so he schedules no sun beams",
unitFlags(bot, elder), bot.UnitInCombat(elder))
}
time.Sleep(250 * time.Millisecond)
}
}
return elder
}
@@ -525,8 +570,10 @@ func waitBrightleafWave(t *testing.T, bot *e2eharness.ScenarioBot, elder uint64)
}
if !time.Now().Before(deadline) {
hp, maxHP := bot.UnitHP(elder)
t.Logf("no wave: elder hp=%d/%d banished=%v essence=%v drained=%v 33050@500y=%d 33170@500y=%d",
t.Logf("no wave: elder hp=%d/%d flags=0x%X inCombat=%v banished=%v essence=%v drained=%v 33050@500y=%d 33170@500y=%d",
hp, maxHP,
unitFlags(bot, elder),
bot.UnitInCombat(elder),
bot.UnitHasAura(elder, spellPurpleBanish),
bot.UnitHasAura(elder, spellBrightleafEssence),
bot.UnitHasAura(elder, spellDrainedOfPower),
@@ -800,6 +847,270 @@ func TestAC_27590_PsychosisSkipsLowSanity(t *testing.T) {
t.Logf("PASS low sanity targeting: both spells landed down to %d sanity and neither reached the player at %d", lastValid, atFloor)
}
// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/27455
// An Ancient Water Spirit's Tidal Wave (62653, 25-man rank 62935) is a 2s cast that surges the
// spirit forward and knocks everything in its path off its feet with a second spell, 62654
// (25-man 62936). Kicking the cast used to stop the surge and nothing else: the script queued
// that second spell on a standalone 3s EventMap timer started at cast time, so the damage and
// knockback still went out on an empty cast bar.
//
// The oracle is whether the spirit casts 62654 at all, read off SMSG_SPELL_GO. That is the
// mechanism the bug is about, and it holds regardless of who the cone ends up covering. The
// knockback the player actually receives (SMSG_MOVE_KNOCK_BACK, which AC sends to the knocked
// player's own session out of Unit::KnockbackFrom) is logged next to it but is not the oracle:
// it depends on where the bot is standing when the wave goes off, and the harness never acks a
// knockback, which makes repeat knockbacks in one session unreliable.
//
// An uninterrupted wave is measured too. Without it a quiet window after a Kick proves nothing:
// it would pass just as happily against a spirit that never casts anything.
//
// A lone spawned spirit rather than Freya's Allies of Nature waves, because the trio is one of
// three wave kinds picked at random roughly every 60s and a real pull spends minutes rolling
// for the subject. The wave is scheduled from JustEngagedWith and needs nothing from Freya.
func TestAC_27455_TidalWaveInterruptStopsKnockback(t *testing.T) {
meta.Begin(t, meta.TestMeta{
Tags: []string{"med", "instances", "issue"},
Runtime: "med",
Issue: 27455,
Category: "instances/northrend/ulduar",
})
const (
npcWaterSpirit = uint32(33202)
// spelldifficulty_dbc swaps the 10-man ids for the 25-man ones on a 25-man instance.
spellTidalWave10 = uint32(62653)
spellTidalWave25 = uint32(62935)
spellTidalDmg10 = uint32(62654)
spellTidalDmg25 = uint32(62936)
spellKick = uint32(1766)
// game_tele BossRazorscale, not Freya's platform: the spirit only needs the Ulduar
// instance script (RegisterUlduarCreatureAI), and this is the emptiest ground in the
// raid, with nothing spawned inside 100y. Fighting it in the Conservatory pulls Freya
// herself, and her Allies of Nature then land Conservator's Grip (62532) on the bot,
// which is pacify-silence and blocks Kick outright.
padX, padY, padZ = float32(589.2), float32(-145.0), float32(391.5)
botHealth = 10000000
// JustEngagedWith schedules the first wave at 12s and repeats on that interval.
waveWindow = 40 * time.Second
// 2s cast, then the surge. Everything the cast does has happened inside this.
resolveWindow = 5 * time.Second
sampleEvery = 20 * time.Millisecond
// Kick is a melee ability and can be dodged or parried, which leaves the cast running.
// Take the next wave when that happens rather than calling the run unjudgeable.
kickAttempts = 6
)
bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{
Prefix: "TidWav", Race: e2eharness.RaceHuman,
Class: e2eharness.ClassRogue, Level: 80, LearnAllClass: true,
})
// Stay GM through the raid enter (.go xyz onto 603 is ignored after .gm off).
bot.Teleport(t, padX, padY, padZ, e2eharness.MapUlduar)
if _, _, _, m := bot.Pos(); m != e2eharness.MapUlduar {
e2eharness.Preconditionf(t, "not in Ulduar after pad tele map=%d", m)
}
spirit := bot.Spawn(t, npcWaterSpirit, 30*time.Second)
t.Logf("Ancient Water Spirit guid=0x%X", spirit)
// Only now drop GM: Spawn turns it on for `.npc add` and leaves it on, and the spirit picks
// its target through SelectTargetFromPlayerList, which skips game masters. Note the bare
// CombatReady helpers would also turn `.cheat god` on, leaving the bot invulnerable.
e2eharness.CombatReady(t, bot.World, e2eharness.CombatReadyOpts{})
if err := bot.World.SetTarget(bot.World.CharGUID()); err != nil {
e2eharness.Preconditionf(t, "select self: %v", err)
}
bot.GM(t, fmt.Sprintf(".modify hp %d", botHealth))
// A fresh level-80 character still carries its starting weapon skills, and an unarmed bot
// misses a level-81 elite with Kick most of the time.
bot.GM(t, ".maxskill")
bot.Engage(t, spirit, 15*time.Second)
var (
mu sync.Mutex
castStart time.Time
castGo time.Time
dmgGo time.Time
knockAt time.Time
)
isWave := func(id uint32) bool { return id == spellTidalWave10 || id == spellTidalWave25 }
isDamage := func(id uint32) bool { return id == spellTidalDmg10 || id == spellTidalDmg25 }
cancel := bot.World.AddPacketHook(func(opcode uint16, data []byte) {
switch opcode {
case client.SmsgSpellStart:
if id, ok := castSpellID(data); ok && isWave(id) {
mu.Lock()
castStart = time.Now()
mu.Unlock()
}
case client.SmsgSpellGo:
id, ok := castSpellID(data)
if !ok {
return
}
mu.Lock()
if isWave(id) {
castGo = time.Now()
} else if isDamage(id) {
dmgGo = time.Now()
}
mu.Unlock()
case client.SmsgMoveKnockBack:
mu.Lock()
knockAt = time.Now()
mu.Unlock()
}
})
defer cancel()
reset := func() {
mu.Lock()
castStart, castGo, dmgGo, knockAt = time.Time{}, time.Time{}, time.Time{}, time.Time{}
mu.Unlock()
}
snapshot := func() (start, done, dmg, knock time.Time) {
mu.Lock()
defer mu.Unlock()
return castStart, castGo, dmgGo, knockAt
}
// The creature's combat flag reaches the object cache a beat after Engage, so a spirit that
// evades is only called out once it has been seen fighting.
sawCombat := false
status := func() string {
hp, _ := bot.UnitHP(spirit)
self, _ := bot.UnitHP(bot.World.CharGUID())
return fmt.Sprintf("inCombat=%v target=0x%X spiritHP=%d botHP=%d",
bot.UnitInCombat(spirit), bot.UnitTarget(spirit), hp, self)
}
waitCast := func(what string) {
deadline := time.Now().Add(waveWindow)
for time.Now().Before(deadline) {
if start, _, _, _ := snapshot(); !start.IsZero() {
return
}
if bot.UnitInCombat(spirit) {
sawCombat = true
} else if sawCombat {
e2eharness.Preconditionf(t, "spirit left combat while waiting for the %s wave (%s)", what, status())
}
time.Sleep(sampleEvery)
}
e2eharness.Preconditionf(t, "no Tidal Wave cast within %s (%s wave, %s)", waveWindow, what, status())
}
// Target and facing are settled up front so the only thing between seeing a cast start and
// the Kick going out is one CMSG_CAST_SPELL.
if err := bot.World.SetTarget(spirit); err != nil {
e2eharness.Preconditionf(t, "select spirit: %v", err)
}
bot.Face(t, spirit)
var kickedDmg, kickedKnock, kickedStart time.Time
interrupted := false
var lastFail string
for attempt := 1; attempt <= kickAttempts && !interrupted; attempt++ {
reset()
waitCast("interrupted")
kicked := false
for deadline := time.Now().Add(1200 * time.Millisecond); time.Now().Before(deadline); {
// Re-faced every attempt: a wave that went off leaves the spirit 40y past the
// bot, and Kick on a target behind it is SPELL_FAILED_UNIT_NOT_INFRONT.
bot.Face(t, spirit)
res, err := bot.TryCast(t, spellKick, spirit, 500*time.Millisecond)
if err != nil {
lastFail = err.Error()
} else if res.Success {
kicked = true
break
} else {
lastFail = e2eharness.SpellFailReasonName(res.FailReason)
}
}
if !kicked {
t.Logf("attempt %d: Kick never went out (%s, casterFlags=0x%X, auras=%v)",
attempt, lastFail, casterFlags(bot), bot.World.SelfAuras())
continue
}
time.Sleep(resolveWindow)
start, done, dmg, knock := snapshot()
if !done.IsZero() {
// Kick went out but was dodged or parried; the cast ran to completion.
t.Logf("attempt %d: Kick did not land, wave completed %s after cast start",
attempt, done.Sub(start).Round(time.Millisecond))
continue
}
interrupted = true
kickedStart, kickedDmg, kickedKnock = start, dmg, knock
}
if !interrupted {
e2eharness.Preconditionf(t, "no Tidal Wave was interrupted in %d attempts (last Kick result: %s, %s)",
kickAttempts, lastFail, status())
}
t.Logf("kicked wave: damage cast=%v knockback=%v", !kickedDmg.IsZero(), !kickedKnock.IsZero())
// Control: let the next one through, so the quiet window above means something.
reset()
waitCast("uninterrupted")
time.Sleep(resolveWindow)
plainStart, plainGo, plainDmg, plainKnock := snapshot()
if plainGo.IsZero() {
e2eharness.Preconditionf(t, "Tidal Wave started but never completed without an interrupt")
}
if plainDmg.IsZero() {
e2eharness.Preconditionf(t,
"an uninterrupted Tidal Wave never cast its damage spell; the interrupt oracle would be vacuous")
}
t.Logf("uninterrupted wave: damage cast %s after cast start, knockback=%v",
plainDmg.Sub(plainStart).Round(time.Millisecond), !plainKnock.IsZero())
if !kickedDmg.IsZero() {
e2eharness.ConfirmedBugf(t, 27455,
"interrupted Tidal Wave still cast its damage and knockback %s after the cast started",
kickedDmg.Sub(kickedStart).Round(time.Millisecond))
}
t.Logf("PASS AC#27455 kicked Tidal Wave cast no damage spell within %s", resolveWindow)
}
// casterFlags reads UNIT_FIELD_FLAGS off the bot, so a refused cast names the state that
// refused it.
func casterFlags(bot *e2eharness.ScenarioBot) uint32 {
obj := bot.World.GetObject(bot.World.CharGUID())
if obj == nil {
return 0
}
return obj.Value(client.UnitFieldFlags)
}
// castSpellID pulls the spell id out of an SMSG_SPELL_START / SMSG_SPELL_GO header, which both
// open with the cast-item and caster packed GUIDs and a cast counter ahead of it. The harness
// parses SPELL_GO for its own waiters but exposes neither the header nor a packed-GUID reader.
func castSpellID(data []byte) (uint32, bool) {
off := 0
for i := 0; i < 2; i++ {
if off >= len(data) {
return 0, false
}
mask := data[off]
off++
for bit := 0; bit < 8; bit++ {
if mask&(1<<uint(bit)) != 0 {
off++
}
}
}
off++ // cast count
if off+4 > len(data) {
return 0, false
}
return binary.LittleEndian.Uint32(data[off : off+4]), true
}
// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/27539
// PR: https://github.com/azerothcore/azerothcore-wotlk/pull/27630
// Big Bang is an 8s cast the raid survives by hiding inside a black hole. When it ends
@@ -152,7 +152,6 @@ enum FreyaEvents
EVENT_ANCIENT_CONSERVATOR_NATURE_FURY = 40,
EVENT_ANCIENT_CONSERVATOR_GRIP = 41,
EVENT_WATER_SPIRIT_CHARGE = 45,
EVENT_WATER_SPIRIT_DAMAGE = 46,
EVENT_STORM_LASHER_LIGHTNING_LASH = 50,
EVENT_STORM_LASHER_STORMBOLT = 51,
EVENT_DETONATING_LASHER_FLAME_LASH = 55,
@@ -1226,13 +1225,8 @@ struct boss_freya_summons : public ScriptedAI
me->CastSpell(me, SPELL_CONSERVATOR_GRIP, true);
break;
case EVENT_WATER_SPIRIT_CHARGE:
me->CastSpell(me, SPELL_TIDAL_WAVE_AURA, true);
me->CastSpell(me->GetVictim(), SPELL_TIDAL_WAVE, false);
events.Repeat(12s);
events.ScheduleEvent(EVENT_WATER_SPIRIT_DAMAGE, 3s);
break;
case EVENT_WATER_SPIRIT_DAMAGE:
me->CastSpell(me, SPELL_TIDAL_WAVE_DAMAGE, false);
break;
case EVENT_STORM_LASHER_LIGHTNING_LASH:
if (Unit* target = SelectTarget(SelectTargetMethod::Random, 0))
@@ -1386,6 +1380,27 @@ class spell_freya_attuned_to_nature_dose_reduction : public SpellScript
}
};
// 62653, 62935 - Tidal Wave
class spell_freya_tidal_wave : public SpellScript
{
PrepareSpellScript(spell_freya_tidal_wave);
void HandleSurge(SpellEffIndex /*effIndex*/)
{
// The cone is caster-referenced: taken before the charge moves the spirit, it spans
// the same 40 yds the surge is about to cross.
Unit* caster = GetCaster();
caster->CastSpell(caster, SPELL_TIDAL_WAVE_AURA, true);
// Untriggered: no SpellVisual, so a triggered cast would lose its SMSG_SPELL_GO.
caster->CastSpell(caster, SPELL_TIDAL_WAVE_DAMAGE, false);
}
void Register() override
{
OnEffectLaunch += SpellEffectFn(spell_freya_tidal_wave::HandleSurge, EFFECT_1, SPELL_EFFECT_CHARGE_DEST);
}
};
// 62207 - Unstable Sun Beam
class spell_freya_brightleaf_unstable_sun_beam : public SpellScript
{
@@ -1432,6 +1447,7 @@ void AddSC_boss_freya()
RegisterUlduarCreatureAI(boss_freya_nature_bomb);
RegisterSpellScript(spell_freya_attuned_to_nature_dose_reduction);
RegisterSpellScript(spell_freya_tidal_wave);
RegisterSpellScript(spell_freya_brightleaf_unstable_sun_beam);
RegisterSpellScript(spell_freya_unstable_sun_beam);