From 92f5ce9a7351c0abdeba485148275b3e8fffc86f Mon Sep 17 00:00:00 2001 From: Andrew <47818697+Nyeriah@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:59:43 -0300 Subject: [PATCH] fix(Scripts/Ulduar): give Algalon a stasis period after Big Bang (#27630) Co-authored-by: Shauren --- e2e/README.md | 2 +- .../northrend/ulduar/ulduar_e2e_test.go | 252 ++++++++++++++++++ .../Ulduar/boss_algalon_the_observer.cpp | 32 ++- 3 files changed, 281 insertions(+), 5 deletions(-) diff --git a/e2e/README.md b/e2e/README.md index cde1f08681..a8ea149126 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -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, and one lands under the player rather than all on the elder; Psychosis and Malady of the Mind stop picking a player once they are at 40 Sanity or less | P2 | covered (`TestAC_27095_*`, `TestAC_27602_*`, `TestAC_27590_*`, `TestUlduar_BrightleafSunBeams*`); Kologarn Charge `blocked-harness` (bridge Z after Charge) | #26266 #27095 #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, and one lands under the player rather than all on the elder; 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 | P2 | covered (`TestAC_27095_*`, `TestAC_27602_*`, `TestAC_27590_*`, `TestAC_27539_*`, `TestUlduar_BrightleafSunBeams*`); 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 | --- diff --git a/e2e/suites/instances/northrend/ulduar/ulduar_e2e_test.go b/e2e/suites/instances/northrend/ulduar/ulduar_e2e_test.go index 6757a61e7c..02854d24bb 100644 --- a/e2e/suites/instances/northrend/ulduar/ulduar_e2e_test.go +++ b/e2e/suites/instances/northrend/ulduar/ulduar_e2e_test.go @@ -10,6 +10,7 @@ import ( _ "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" ) @@ -686,3 +687,254 @@ 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/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 +// Algalon holds still for 3s before he picks the fight back up; AzerothCore resumed melee, +// Quantum Strike and the chase on the very next AI tick instead. +// +// The oracle is the bots' combined health, compared against a running peak because the pool +// regenerates while the fight runs: regeneration only raises the peak, so any decrease at all +// is Algalon landing something. Big Bang +// triggers 64445 on every player for exactly one second at the moment the cast lands, which +// is the protocol-visible marker the window is measured from. Reading the total rather than +// one bot's health keeps a tank swap mid-window from reading as a quiet boss. +// +// Two bots, because a solo one is not enough to hold the encounter up. Phase Punch's 5th +// stack applies 64417, a SPELL_AURA_PHASE that moves its target out of Algalon's phase for +// good (taking 64412 off afterwards does not remove it). With one player that leaves every +// threat reference offline via !CanSeeOrDetect, and Algalon sits flagged in combat unable to +// touch anyone. A second body is what a real raid's tank swap provides. +// +// Living Constellations are killed on sight because their Arcane Barrage is the one other +// thing that can reach a bot inside the window. Collapsing Stars are left alone: all four +// spawn points sit about 20y from where Algalon lands, well clear of a black hole's 6y reach. +func TestAC_27539_AlgalonBigBangStasis(t *testing.T) { + meta.Begin(t, meta.TestMeta{ + Tags: []string{"long", "instances", "issue", "multi_bot"}, + Runtime: "long", + Issue: 27539, + Category: "instances/northrend/ulduar", + }) + + const ( + npcAlgalon = uint32(32871) + npcLivingConstellation = uint32(33052) + // Enough to drop a constellation in one hit. + constellationHit = uint32(500000) + + goPlanetarium10 = uint32(194628) + goPlanetarium25 = uint32(194752) + itemKey10 = uint32(45797) + itemKey25 = uint32(45798) + + spellPhasePunch = uint32(64412) + // The 5th Phase Punch stack: SPELL_AURA_PHASE, misc 16, the same phase a black hole + // puts a player in. + spellPhasePunchShift = uint32(64417) + spellRemovePhase = uint32(64445) + + // Planetarium console, where Brann spawns and the summon roleplay starts. + consoleX, consoleY, consoleZ = float32(1646.2), float32(-174.7), float32(427.3) + // Off AlgalonLandPos, where he sets down. Standing on his exact coordinates leaves + // the facing check no direction and every swing returns SMSG_ATTACKSWING_BAD_FACING. + landX, landY, landZ = float32(1632.668), float32(-308.5), float32(417.321) + + botHealth = 10000000 + + // Brann's walk plus Algalon's descent before EVENT_INTRO_FINISH drops SetImmuneToPC. + introWindow = 4 * time.Minute + // 90s fight timer plus the intro delay (26s on a first pull), with slack. + bigBangWindow = 3 * time.Minute + + // Big Bang's damage and its 64445 marker come out of the same cast; give the health + // update from that cast time to arrive before the quiet window opens. + settle = 700 * time.Millisecond + // The script holds Algalon for 3s, but the server starts counting when it applies + // 64445 and this window starts when the client *sees* it, so the tail of a full 3s + // window runs past the moment he is allowed to move again. Stop short of the boundary + // by enough to cover that lag plus a world tick. A boss that never held still at all + // lands something around 1s, far inside what is still asserted. + stasisWindow = 2300 * time.Millisecond + // Quantum Strike repeats every 3-4.5s and melee is faster, so a boss that resumed + // lands something well inside this. + resumeWindow = 10 * time.Second + + sampleEvery = 100 * time.Millisecond + // Throttle for the sweep that keeps the bots targetable and the field clear. + sweepEvery = 3 * time.Second + ) + + bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{ + Prefix: "AlgBB", + Bots: []e2eharness.BotSpec{ + {Role: "tank", Race: e2eharness.RaceHuman, Class: e2eharness.ClassWarrior, Level: 80, LearnAllClass: true}, + {Role: "spare", Race: e2eharness.RaceHuman, Class: e2eharness.ClassWarrior, Level: 80, LearnAllClass: true}, + }, + }) + tank := e2eharness.ByRole(t, bots, "tank") + spare := e2eharness.ByRole(t, bots, "spare") + + // Stay GM through the raid enter (.go xyz onto 603 is ignored after .gm off). + for _, b := range bots { + b.Teleport(t, consoleX, consoleY, consoleZ, e2eharness.MapUlduar) + if _, _, _, m := b.Pos(); m != e2eharness.MapUlduar { + e2eharness.Preconditionf(t, "%s not in Ulduar after console pad tele map=%d", b.Name, m) + } + } + + // The console is a locked goober with one key entry per raid size, and which one spawned + // depends on the difficulty this instance came up at. Carry both. + tank.AddItem(t, itemKey10, 1) + tank.AddItem(t, itemKey25, 1) + + console := e2eharness.TryNearbyGameObjectByEntry(t, tank.World, goPlanetarium10, 15*time.Second) + if console == 0 { + console = e2eharness.TryNearbyGameObjectByEntry(t, tank.World, goPlanetarium25, 15*time.Second) + } + if console == 0 { + e2eharness.Preconditionf(t, "no Celestial Planetarium Access console (%d/%d) at the pad", goPlanetarium10, goPlanetarium25) + } + tank.GameObjectUse(t, console) + + // Brann walks the intro from the console end of the room; wait it out on the platform + // Algalon lands on. Instance grids are always loaded, so the roleplay runs regardless. + tank.Teleport(t, landX, landY, landZ, e2eharness.MapUlduar) + spare.Teleport(t, landX+4, landY-2, landZ, e2eharness.MapUlduar) + algalon := tank.WaitUnit(t, npcAlgalon, introWindow) + t.Logf("Algalon summoned guid=0x%X", algalon) + + // Pulling him mid-roleplay is not the same fight: JustEngagedWith calls events.Reset(), + // which drops the pending EVENT_INTRO_FINISH that clears SetImmuneToPC, and he then never + // reaches EVENT_INTRO_TIMER_DONE to turn REACT_AGGRESSIVE. Wait for the flag to clear. + immune := func() bool { + o := tank.World.GetObject(algalon) + return o == nil || o.Value(client.UnitFieldFlags)&client.UnitFlagImmuneToPC != 0 + } + introDeadline := time.Now().Add(introWindow) + for immune() && time.Now().Before(introDeadline) { + time.Sleep(500 * time.Millisecond) + } + if immune() { + e2eharness.Preconditionf(t, "Algalon 0x%X still UNIT_FLAG_IMMUNE_TO_PC after %s; intro roleplay never finished", algalon, introWindow) + } + t.Logf("intro roleplay done, Algalon attackable") + + // Drop GM so he fights back, then make both bots able to sit through a Big Bang. Note the + // bare CombatReady/CombatReadyFull helpers turn `.cheat god` on, which would leave the + // bots invulnerable and every quiet window vacuous: this oracle needs real damage. + for _, b := range bots { + e2eharness.CombatReady(t, b.World, e2eharness.CombatReadyOpts{}) + if err := b.World.SetTarget(b.World.CharGUID()); err != nil { + e2eharness.Preconditionf(t, "%s select self: %v", b.Name, err) + } + b.GM(t, fmt.Sprintf(".modify hp %d", botHealth)) + } + + tank.Engage(t, algalon, 60*time.Second) + t.Logf("engaged Algalon, waiting for Big Bang") + + totalHP := func() uint32 { + var sum uint32 + for _, b := range bots { + hp, _ := b.UnitHP(b.World.CharGUID()) + sum += hp + } + return sum + } + + sweep := func() bool { + // Stripped every pass rather than on a stack threshold: a missed read is + // unrecoverable, because once the 5th stack lands 64417 stays on and taking 64412 off + // does not remove it. This is the tank swap a real raid does, and Phase Punch only + // comes around every 15.5s. + for _, b := range bots { + if err := b.World.SetTarget(b.World.CharGUID()); err == nil { + b.GM(t, fmt.Sprintf(".unaura %d", spellPhasePunch)) + b.GM(t, fmt.Sprintf(".unaura %d", spellPhasePunchShift)) + } + } + if guids := e2eharness.LivingByEntries(tank.World, 300, npcLivingConstellation); len(guids) > 0 { + tank.DamageKill(t, guids, constellationHit, 10*time.Second) + t.Logf("cleared %d Living Constellation(s)", len(guids)) + } + bossHP, _ := tank.UnitHP(algalon) + t.Logf("sweep: total hp=%d boss hp=%d bossTarget=0x%X inCombat=%v", + totalHP(), bossHP, tank.UnitTarget(algalon), tank.UnitInCombat(algalon)) + tank.Attack(t, algalon) + return tank.UnitInCombat(algalon) + } + + startHP := totalHP() + var bigBangAt time.Time + lastSweep := time.Time{} + waitDeadline := time.Now().Add(bigBangWindow) + for time.Now().Before(waitDeadline) { + if tank.HasAura(spellRemovePhase) || spare.HasAura(spellRemovePhase) { + bigBangAt = time.Now() + break + } + if time.Since(lastSweep) >= sweepEvery { + if !sweep() { + e2eharness.Preconditionf(t, "Algalon left combat before Big Bang (total hp %d -> %d)", startHP, totalHP()) + } + lastSweep = time.Now() + } + time.Sleep(sampleEvery) + } + if bigBangAt.IsZero() { + e2eharness.Preconditionf(t, "no Big Bang (%d) within %s of the pull", spellRemovePhase, bigBangWindow) + } + + // Oracle liveness: if nothing has been hitting the bots up to here, a quiet window proves + // nothing at all. + preHP := totalHP() + if preHP >= startHP { + e2eharness.Preconditionf(t, "bots took no damage before Big Bang (total %d -> %d), the quiet window would be vacuous", startHP, preHP) + } + t.Logf("Big Bang landed, total hp %d -> %d", startHP, preHP) + + // The bots regenerate while this runs, so the total is not monotonic and a plain + // "below the value at window start" check would let regeneration mask a hit. Compare + // against a running peak instead: regeneration only raises it, and any decrease at all + // is Algalon landing something. + time.Sleep(settle) + quietHP := totalHP() + peakHP := quietHP + for time.Since(bigBangAt) < stasisWindow { + hp := totalHP() + if hp < peakHP { + e2eharness.ConfirmedBugf(t, 27539, + "Algalon acted %s after Big Bang, inside the 3s stasis: total hp %d -> %d", + time.Since(bigBangAt).Round(time.Millisecond), peakHP, hp) + } + if hp > peakHP { + peakHP = hp + } + time.Sleep(sampleEvery) + } + + // The window must be quiet because he is holding still, not because he evaded or the bots + // stopped being targets: he has to come back swinging right after it. + resumed := false + resumeDeadline := time.Now().Add(resumeWindow) + for time.Now().Before(resumeDeadline) { + hp := totalHP() + if hp < peakHP { + resumed = true + break + } + if hp > peakHP { + peakHP = hp + } + time.Sleep(sampleEvery) + } + if !resumed { + e2eharness.Preconditionf(t, + "Algalon never resumed within %s of the stasis ending (total hp still %d, in combat=%v) — the quiet window proves nothing", + resumeWindow, quietHP, tank.UnitInCombat(algalon)) + } + + t.Logf("PASS Big Bang stasis: no damage for %s after the cast, then Algalon resumed", stasisWindow) +} diff --git a/src/server/scripts/Northrend/Ulduar/Ulduar/boss_algalon_the_observer.cpp b/src/server/scripts/Northrend/Ulduar/Ulduar/boss_algalon_the_observer.cpp index 2c8c395977..4a0aa8c0e4 100644 --- a/src/server/scripts/Northrend/Ulduar/Ulduar/boss_algalon_the_observer.cpp +++ b/src/server/scripts/Northrend/Ulduar/Ulduar/boss_algalon_the_observer.cpp @@ -171,6 +171,11 @@ enum EncounterPhases PHASE_MASK_NO_CAST_CHECK = 1 << (PHASE_ROLE_PLAY - 1), }; +enum EventGroups +{ + GROUP_COMBAT = 1, +}; + enum Texts { SAY_BRANN_ALGALON_INTRO_1 = 0, @@ -395,6 +400,7 @@ struct boss_algalon_the_observer : public ScriptedAI events.Reset(); summons.DespawnAll(); me->SetReactState(REACT_PASSIVE); + me->SetCombatMovement(true); me->SetImmuneToPC(false); me->SetSheath(SHEATH_STATE_UNARMED); me->SetFaction(190); @@ -477,6 +483,7 @@ struct boss_algalon_the_observer : public ScriptedAI case ACTION_ASCEND: summons.DespawnAll(); events.SetPhase(PHASE_BIG_BANG); + events.CancelEvent(EVENT_RESUME_UPDATING); events.ScheduleEvent(EVENT_ASCEND_TO_THE_HEAVENS, 1500ms); break; case ACTION_FEEDS_ON_TEARS_FAILED: @@ -522,10 +529,10 @@ struct boss_algalon_the_observer : public ScriptedAI introDelay = 8500ms; events.ScheduleEvent(EVENT_INTRO_TIMER_DONE, introDelay); - events.ScheduleEvent(EVENT_QUANTUM_STRIKE, 3500ms + introDelay); - events.ScheduleEvent(EVENT_PHASE_PUNCH, 15500ms + introDelay); + events.ScheduleEvent(EVENT_QUANTUM_STRIKE, 3500ms + introDelay, GROUP_COMBAT); + events.ScheduleEvent(EVENT_PHASE_PUNCH, 15500ms + introDelay, GROUP_COMBAT); events.ScheduleEvent(EVENT_SUMMON_COLLAPSING_STAR, 16500ms + introDelay); - events.ScheduleEvent(EVENT_COSMIC_SMASH, 26s + introDelay); + events.ScheduleEvent(EVENT_COSMIC_SMASH, 26s + introDelay, GROUP_COMBAT); events.ScheduleEvent(EVENT_ACTIVATE_LIVING_CONSTELLATION, 60s + introDelay); events.ScheduleEvent(EVENT_BIG_BANG, 90s + introDelay); events.ScheduleEvent(EVENT_ASCEND_TO_THE_HEAVENS, 360s + introDelay); @@ -745,13 +752,29 @@ struct boss_algalon_the_observer : public ScriptedAI summons.DoAction(ACTION_BIG_BANG, pred); me->CastSpell((Unit*)nullptr, SPELL_BIG_BANG, false); + + // 8s cast, then Algalon holds still for 3s before resuming + events.SetPhase(PHASE_BIG_BANG); + events.ScheduleEvent(EVENT_RESUME_UPDATING, 11s); + events.DelayEventsToMax(11s, GROUP_COMBAT); events.Repeat(90s + 500ms); + + me->SetCombatMovement(false); + me->GetMotionMaster()->Clear(false); + me->GetMotionMaster()->MoveIdle(); break; } + case EVENT_RESUME_UPDATING: + events.SetPhase(PHASE_NORMAL); + me->SetCombatMovement(true); + me->ResumeChasingVictim(); + break; case EVENT_ASCEND_TO_THE_HEAVENS: Talk(SAY_ALGALON_ASCEND); me->CastSpell((Unit*)nullptr, SPELL_ASCEND_TO_THE_HEAVENS, false); events.ScheduleEvent(EVENT_EVADE, 2500ms); + events.CancelEvent(EVENT_RESUME_UPDATING); + me->SetCombatMovement(true); break; case EVENT_EVADE: events.Reset(); @@ -849,7 +872,8 @@ struct boss_algalon_the_observer : public ScriptedAI break; } - DoMeleeAttackIfReady(); + if (!(events.GetPhaseMask() & PHASE_MASK_NO_UPDATE)) + DoMeleeAttackIfReady(); } };