diff --git a/e2e/README.md b/e2e/README.md index 2f88de6d10..892fc83a98 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 | P2 | covered (`TestAC_27095_*`, `TestAC_27602_*`, `TestUlduar_BrightleafSunBeams*`); Kologarn Charge `blocked-harness` (bridge Z after Charge) | #26266 #27095 #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 | P2 | covered (`TestAC_27095_*`, `TestAC_27602_*`, `TestAC_27590_*`, `TestUlduar_BrightleafSunBeams*`); Kologarn Charge `blocked-harness` (bridge Z after Charge) | #26266 #27095 #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 edbf57d4d6..6757a61e7c 100644 --- a/e2e/suites/instances/northrend/ulduar/ulduar_e2e_test.go +++ b/e2e/suites/instances/northrend/ulduar/ulduar_e2e_test.go @@ -525,3 +525,164 @@ func sunBeamGUIDs(beams []sunBeamSnap) []string { } return out } + +// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/27590 +// PR: https://github.com/azerothcore/azerothcore-wotlk/pull/27628 +// Psychosis (63795) and Malady of the Mind (63830) must skip players sitting at 40 Sanity +// or less. Both pick a random enemy through TARGET_UNIT_SRC_AREA_ENEMY over a 50000 yd +// radius and AzerothCore never filtered that list, so the players closest to going insane +// kept being the ones picked. +// +// Sara is faction 35 and can never own an enemy list, so she cannot be the fixture caster. +// A Laughing Skull is the stand-in: faction 14, NullCreatureAI, no threat list, and the +// Lunatic Gaze test above already proves its spells select the bot as an enemy. Its own +// gaze is stripped first, otherwise the 2 sanity a second it drains drowns the oracle. +func TestAC_27590_PsychosisSkipsLowSanity(t *testing.T) { + meta.Begin(t, meta.TestMeta{ + Tags: []string{"med", "instances", "issue"}, + Runtime: "med", + Issue: 27590, + Category: "instances/northrend/ulduar", + }) + + const ( + npcLaughingSkull = uint32(33990) + spellSanity = uint32(63050) + spellSanityWell = uint32(64169) + spellLunaticGaze = uint32(64167) + spellPsychosis = uint32(63795) + spellMalady = uint32(63830) + + // Icecrown illusion chamber floor, the pad the Lunatic Gaze test spawns on. + padX, padY, padZ = float32(1930.0), float32(-120.0), float32(240.07) + + // Threshold from the 2009-07-02 hotfix: at or below it neither spell may pick the player. + sanityFloor = 40 + + // Where the walk down starts. A Sanity Well tops up in steps of 20 from the single + // stack `.aura` creates, so 1 -> 21 -> 41 -> 61 lands here and Psychosis then steps + // 61 -> 52 -> 43 -> 34, straddling the threshold without overshooting it far. + sanityStart = 60 + + // 63795 is a 2.9s cast and target selection only runs once it completes. A cast that + // finds nobody expires quietly, so the no-drain half has to burn the whole window. + castWindow = 8 * time.Second + + // Well ticks every 2s; this only has to outlast the three steps up to sanityStart. + rampWindow = 20 * time.Second + + // Enough steps to walk sanityStart down past the threshold at 9 a hit, with room to spare. + maxPsychosisCasts = 20 + ) + + bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{ + Prefix: "YoggPs", Race: e2eharness.RaceHuman, Level: 80, + }) + + // 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 brain room tele map=%d", m) + } + skull := bot.Spawn(t, npcLaughingSkull, 30*time.Second) + + // GM casts and aura edits both act on the current selection, so every one of them says + // out loud which unit it means. + selectUnit := func(label string, guid uint64) { + t.Helper() + if err := bot.World.SetTarget(guid); err != nil { + e2eharness.Preconditionf(t, "select %s 0x%X: %v", label, guid, err) + } + } + selectSkull := func() { t.Helper(); selectUnit("skull", skull) } + selectSelf := func() { t.Helper(); selectUnit("self", bot.World.CharGUID()) } + + selectSkull() + bot.GM(t, fmt.Sprintf(".unaura %d", spellLunaticGaze)) + + // Drop GM so the spells can select the bot; god mode absorbs the damage half of Psychosis. + bot.CombatReady(t) + bot.CheatGod(t) + + // Sanity carries AURA_INTERRUPT_FLAG_CHANGE_MAP, so apply it after the tele. `.aura` + // creates it at one stack, which is already under the threshold, so a Sanity Well + // buff tops it up the same way Freya's wells do in the fight. + bot.ApplyAura(t, spellSanity) + bot.ApplyAura(t, spellSanityWell) + rampDeadline := time.Now().Add(rampWindow) + for bot.AuraStacks(spellSanity) < sanityStart && time.Now().Before(rampDeadline) { + time.Sleep(250 * time.Millisecond) + } + // CMSG_CANCEL_AURA does not take the well off, so strip it server side. + selectSelf() + bot.GM(t, fmt.Sprintf(".unaura %d", spellSanityWell)) + + start := bot.AuraStacks(spellSanity) + if start <= sanityFloor { + e2eharness.Preconditionf(t, "Sanity ramped to %d stacks, need more than %d to drive the threshold", start, sanityFloor) + } + + // Every later delta is attributed to the spell under test, so nothing else may be moving + // sanity now: this catches both a skull that still gazes and a well that never came off. + time.Sleep(3 * time.Second) + if quiet := bot.AuraStacks(spellSanity); quiet != start { + e2eharness.Preconditionf(t, "sanity still moving on its own before the first cast (%d -> %d), skull 0x%X", start, quiet, skull) + } + + // `.cast self` makes the *selected* unit cast on itself. Psychosis and Malady take their + // real targets from the area around the caster, which is the same selection path Sara + // drives in phase 2. Returns the stacks the bot lost, 0 if the cast never reached it. + castFromSkull := func(spellID uint32) int { + t.Helper() + before := bot.AuraStacks(spellSanity) + selectSkull() + bot.GM(t, fmt.Sprintf(".cast self %d", spellID)) + deadline := time.Now().Add(castWindow) + for time.Now().Before(deadline) { + if now := bot.AuraStacks(spellSanity); now != before { + return before - now + } + time.Sleep(200 * time.Millisecond) + } + return 0 + } + + // Fixture check: at full sanity the spell must reach the bot at all. Without this a + // mis-aimed `.cast self` (it falls back to the caster's own player when nothing is + // selected) would read as a clean pass on both of the assertions below. + if lost := castFromSkull(spellMalady); lost <= 0 { + e2eharness.Preconditionf(t, "fixture dead: Malady of the Mind drained nothing at %d sanity", bot.AuraStacks(spellSanity)) + } + + lastValid := 0 + for i := 0; i < maxPsychosisCasts; i++ { + before := bot.AuraStacks(spellSanity) + if before <= sanityFloor { + break + } + lost := castFromSkull(spellPsychosis) + if lost <= 0 { + e2eharness.ConfirmedBugf(t, 27590, + "Psychosis drained nothing at %d sanity, which is above the %d threshold", before, sanityFloor) + } + lastValid = before + t.Logf("Psychosis at %d sanity drained %d -> %d", before, lost, bot.AuraStacks(spellSanity)) + } + + atFloor := bot.AuraStacks(spellSanity) + if atFloor > sanityFloor { + e2eharness.Preconditionf(t, "never reached the threshold, stuck at %d sanity after %d casts", atFloor, maxPsychosisCasts) + } + + if lost := castFromSkull(spellPsychosis); lost > 0 { + e2eharness.ConfirmedBugf(t, 27590, + "Psychosis drained %d sanity from a player at %d, at or below the %d threshold", lost, atFloor, sanityFloor) + } + atFloor = bot.AuraStacks(spellSanity) + if lost := castFromSkull(spellMalady); lost > 0 { + e2eharness.ConfirmedBugf(t, 27590, + "Malady of the Mind drained %d sanity from a player at %d, at or below the %d threshold", lost, atFloor, sanityFloor) + } + + t.Logf("PASS low sanity targeting: both spells landed down to %d sanity and neither reached the player at %d", lastValid, atFloor) +} diff --git a/src/server/scripts/Northrend/Ulduar/Ulduar/boss_yoggsaron.cpp b/src/server/scripts/Northrend/Ulduar/Ulduar/boss_yoggsaron.cpp index 634bec4679..9da94d1c9a 100644 --- a/src/server/scripts/Northrend/Ulduar/Ulduar/boss_yoggsaron.cpp +++ b/src/server/scripts/Northrend/Ulduar/Ulduar/boss_yoggsaron.cpp @@ -2873,9 +2873,27 @@ class spell_yogg_saron_sanity_reduce : public SpellScript } } + // Psychosis and Malady of the Mind skip anyone at 40 Sanity or less, so that their random + // targeting evens out across the raid instead of finishing off the lowest players. + void FilterTargets(std::list& targets) + { + targets.remove_if([](WorldObject* target) + { + Unit* unit = target->ToUnit(); + if (!unit) + return true; + + Aura* sanity = unit->GetAura(SPELL_SANITY); + return !sanity || sanity->GetStackAmount() <= 40; + }); + } + void Register() override { OnEffectHitTarget += SpellEffectFn(spell_yogg_saron_sanity_reduce::HandleScriptEffect, EFFECT_FIRST_FOUND, SPELL_EFFECT_SCRIPT_EFFECT); + + if (m_scriptSpellId == SPELL_SARA_PSYCHOSIS_10 || m_scriptSpellId == SPELL_SARA_PSYCHOSIS_25 || m_scriptSpellId == SPELL_MALADY_OF_THE_MIND) + OnObjectAreaTargetSelect += SpellObjectAreaTargetSelectFn(spell_yogg_saron_sanity_reduce::FilterTargets, EFFECT_ALL, TARGET_UNIT_SRC_AREA_ENEMY); } };