diff --git a/data/sql/updates/pending_db_world/rev_1790027989449660900.sql b/data/sql/updates/pending_db_world/rev_1790027989449660900.sql new file mode 100644 index 0000000000..2624cc5400 --- /dev/null +++ b/data/sql/updates/pending_db_world/rev_1790027989449660900.sql @@ -0,0 +1,5 @@ +-- Ignis casts Kill All Constructs on death. Its implicit target is entry-based, which without a +-- condition matches every unit in range, players included. +DELETE FROM `conditions` WHERE (`SourceTypeOrReferenceId` = 13) AND (`SourceGroup` = 1) AND (`SourceEntry` = 65109) AND (`SourceId` = 0); +INSERT INTO `conditions` (`SourceTypeOrReferenceId`, `SourceGroup`, `SourceEntry`, `SourceId`, `ElseGroup`, `ConditionTypeOrReference`, `ConditionTarget`, `ConditionValue1`, `ConditionValue2`, `ConditionValue3`, `NegativeCondition`, `ErrorType`, `ErrorTextId`, `ScriptName`, `Comment`) VALUES +(13, 1, 65109, 0, 0, 31, 0, 3, 33121, 0, 0, 0, 0, '', 'Kill All Constructs - Target Iron Construct'); diff --git a/e2e/README.md b/e2e/README.md index c802f6da9e..6e55697211 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; a wave of Elder Brightleaf's Unstable Sun Beams 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; one Elder left alive spawns the one-Elder Freya's Gift, which is the chest whose emblem count the fix corrects | P2 | covered (`TestAC_27095_*`, `TestAC_27602_*`, `TestAC_27590_*`, `TestAC_27539_*`, `TestAC_27455_*`, `TestUlduar_BrightleafSunBeamsCappedPerWave`, `TestUlduar_FreyaWardLasherOutlivesSummonDuration`, `TestUlduar_ThorimEvadeDespawnDoesNotYield`, `TestUlduar_FreyaGiftMatchesElderCount`); Kologarn Charge `blocked-harness` (bridge Z after Charge); Freya's Gift emblem counts `blocked-harness` (a chest's loot opens only through SPELL_EFFECT_OPEN_LOCK, and CMSG_CAST_SPELL carries no TARGET_FLAG_GAMEOBJECT) | #26266 #27095 #27455 #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; a wave of Elder Brightleaf's Unstable Sun Beams 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; one Elder left alive spawns the one-Elder Freya's Gift, which is the chest whose emblem count the fix corrects; Ignis dying kills every Iron Construct including the dormant ones, and his Kill All Constructs instakill spares the player and every other creature in range | P2 | covered (`TestAC_27095_*`, `TestAC_27602_*`, `TestAC_27590_*`, `TestAC_27539_*`, `TestAC_27455_*`, `TestUlduar_BrightleafSunBeamsCappedPerWave`, `TestUlduar_FreyaWardLasherOutlivesSummonDuration`, `TestUlduar_ThorimEvadeDespawnDoesNotYield`, `TestUlduar_FreyaGiftMatchesElderCount`, `TestAC_27736_IgnisKillsAllConstructs`); Kologarn Charge `blocked-harness` (bridge Z after Charge); Freya's Gift emblem counts `blocked-harness` (a chest's loot opens only through SPELL_EFFECT_OPEN_LOCK, and CMSG_CAST_SPELL carries no TARGET_FLAG_GAMEOBJECT); Ignis constructs on 25-man `blocked-harness` (no way to set a bot's raid difficulty: CMSG_SET_RAID_DIFFICULTY is unexported and a `characters`.`instance_mode_mask` write is overwritten by the save on Relog's logout) | #26266 #27095 #27455 #27539 #27590 #27602 #27736 | | professions/trainers | every Engineering trainer group offers a visitor exactly the twelve TBC master-tier recipes its class can learn — and none of the others, so a broken class filter fails too. Eleven are class-restricted goggles: four classes cover all twelve across the eight groups, two characters per class cover the Horde/Alliance and Aldor/Scryer interaction splits, and the remaining five classes pin the armour-type sharing at Technician Mihila. Why a hidden recipe is hidden is pinned too: either the class cannot wear the goggle at all — the refusal read from SMSG_INVENTORY_CHANGE_FAILURE, which must be EQUIP_ERR_NO_REQUIRED_PROFICIENCY — or the goggle is another class's within the same armour type, which is why armour proficiency would be the wrong filter to gate on | P2 | covered (`TestAC_27146_*`) | #27146 | | 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 | diff --git a/e2e/suites/instances/northrend/ulduar/ulduar_e2e_test.go b/e2e/suites/instances/northrend/ulduar/ulduar_e2e_test.go index 03d00b4600..d1587d0497 100644 --- a/e2e/suites/instances/northrend/ulduar/ulduar_e2e_test.go +++ b/e2e/suites/instances/northrend/ulduar/ulduar_e2e_test.go @@ -1448,3 +1448,138 @@ func TestUlduar_FreyaGiftMatchesElderCount(t *testing.T) { t.Logf("PASS Freya's Gift %d spawned with one Elder alive (guid=0x%X)", goGiftOneElder, chest) bot.AssertWorldAlive(t) } + +// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/27736 +// PR: https://github.com/azerothcore/azerothcore-wotlk/pull/27751 +// Ignis dying casts Kill All Constructs (65109). Every Iron Construct must die with him, +// dormant ones included, and the instakill must reach nothing else: its implicit target is +// entry-based, so a missing `conditions` row would let it hit the player and every other +// creature on the map. +// +// 10-man only: the harness cannot switch a bot to 25-man, inventoried as blocked-harness +// on the instances/ulduar row of e2e/README.md. The construct entry is 33121 in both +// difficulties because Creature::InitEntry keeps the normal entry and only swaps the +// template, so the targeting row this asserts on is the same row 25-man uses. +func TestAC_27736_IgnisKillsAllConstructs(t *testing.T) { + meta.Begin(t, meta.TestMeta{ + Tags: []string{"long", "instances", "issue"}, + Runtime: "long", + Issue: 27736, + Category: "instances/northrend/ulduar", + }) + + const ( + npcIgnis = uint32(33118) + npcIronConstruct = uint32(33121) + ) + + bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{ + Prefix: "Ignis", + Level: 80, + }) + + // Stay GM through the raid enter (.go xyz onto 603 is ignored after .gm off). + bot.Teleport(t, 586.542, 378.798, 360.923, e2eharness.MapUlduar) + if _, _, _, m := bot.Pos(); m != e2eharness.MapUlduar { + e2eharness.Preconditionf(t, "not in Ulduar after Ignis pad tele map=%d", m) + } + bot.GoCreatureID(t, npcIgnis) + bot.CombatReady(t) + + // FlushWorld beside a boss can evade him out of cache; re-acquire a living GUID. + var ignisGUID uint64 + deadline := time.Now().Add(30 * time.Second) + for { + ignisGUID = bot.WaitUnit(t, npcIgnis, 10*time.Second) + if hp, maxHP := bot.UnitHP(ignisGUID); maxHP > 0 && hp > 0 && bot.World.GetObject(ignisGUID) != nil { + break + } + if !time.Now().Before(deadline) { + e2eharness.Preconditionf(t, "no living Ignis in cache after GoCreatureID (last=0x%X)", ignisGUID) + } + time.Sleep(50 * time.Millisecond) + } + bot.Engage(t, ignisGUID, 15*time.Second) + + // Snapshot immediately before the killing blow. Activate Construct is 40s out on 10-man, + // so the constructs are still dormant: exactly the case the old IsInCombat filter missed. + constructs := e2eharness.UnitsByEntry(bot.World, 0, npcIronConstruct) + if len(constructs) == 0 { + e2eharness.Preconditionf(t, "no living Iron Construct in cache beside Ignis") + } + dormant := 0 + for _, c := range constructs { + if !c.InCombat { + dormant++ + } + } + if dormant == 0 { + e2eharness.Preconditionf(t, "all %d Iron Constructs are already in combat; the dormant case is not exercised", len(constructs)) + } + + // Anything alive and not a construct must survive the instakill. + bystanders := make(map[uint64]uint32) + for _, u := range bot.World.GetNearbyUnits(200) { + // Entry 0 is a create the cache has not resolved yet. It may be a construct, which + // UnitsByEntry already judges by GUID, so it must not be filed as a bystander too. + if u.GUID == ignisGUID || u.Entry == npcIronConstruct || u.Entry == 0 || u.Health() == 0 { + continue + } + bystanders[u.GUID] = u.Entry + } + t.Logf("before kill: %d Iron Constructs (%d dormant), %d other living creatures in cache", + len(constructs), dormant, len(bystanders)) + + bot.DamageKill(t, []uint64{ignisGUID}, 1_000_000, 60*time.Second) + bot.WaitUnitDead(t, ignisGUID, 30*time.Second) + + // The constructs die in the same object update as the boss; poll briefly for it to land. + var alive, judged []uint64 + dl := time.Now().Add(15 * time.Second) + for { + alive, judged = alive[:0], judged[:0] + for _, c := range constructs { + obj := bot.World.GetObject(c.GUID) + if obj == nil { + continue // left the cache: cannot judge this one + } + judged = append(judged, c.GUID) + if obj.Health() > 0 { + alive = append(alive, c.GUID) + } + } + if len(alive) == 0 || !time.Now().Before(dl) { + break + } + time.Sleep(100 * time.Millisecond) + } + if len(judged) == 0 { + e2eharness.Preconditionf(t, "every Iron Construct left the object cache after Ignis died; nothing to judge") + } + if len(alive) > 0 { + e2eharness.ConfirmedBugf(t, 27736, "%d of %d judged Iron Constructs still alive after Ignis died (first=0x%X)", + len(alive), len(judged), alive[0]) + } + + for guid, entry := range bystanders { + obj := bot.World.GetObject(guid) + if obj == nil { + continue // left the cache: cannot judge this one + } + if obj.Health() == 0 { + e2eharness.Assertf(t, "Kill All Constructs killed a bystander creature guid=0x%X entry=%d: spell 65109 is not restricted to entry %d", + guid, entry, npcIronConstruct) + } + } + self := bot.World.GetObject(bot.GUID) + if self == nil { + e2eharness.HarnessFailf(t, "own player object missing from the cache after the kill") + } + if self.Health() == 0 { + e2eharness.Assertf(t, "Kill All Constructs killed the player: spell 65109 is missing its targeting conditions") + } + + t.Logf("PASS Ignis death killed %d/%d judged Iron Constructs (%d dormant); %d bystander creatures and the player survived", + len(judged), len(constructs), dormant, len(bystanders)) + bot.AssertWorldAlive(t) +} diff --git a/src/server/scripts/Northrend/Ulduar/Ulduar/boss_ignis.cpp b/src/server/scripts/Northrend/Ulduar/Ulduar/boss_ignis.cpp index 1ac32a60c6..c0141d04fc 100644 --- a/src/server/scripts/Northrend/Ulduar/Ulduar/boss_ignis.cpp +++ b/src/server/scripts/Northrend/Ulduar/Ulduar/boss_ignis.cpp @@ -37,6 +37,7 @@ enum IgnisSpellData SPELL_GRAB = 62707, SPELL_GRAB_TRIGGERED = 62708, SPELL_GRAB_CONTROL_2 = 62711, + SPELL_KILL_ALL_CONSTRUCTS = 65109, SPELL_SCORCHED_GROUND = 62548, SPELL_HEAT_AREA = 62343, @@ -112,16 +113,18 @@ struct npc_ulduar_iron_construct : public ScriptedAI { if (spell->Id == SPELL_ACTIVATE_CONSTRUCT) { + InstanceScript* instance = me->GetInstanceScript(); + Creature* ignis = instance ? instance->GetCreature(BOSS_IGNIS) : nullptr; + // the spell is a slow missile, so it can still land after Ignis evaded + if (!ignis || !ignis->IsEngaged()) + return; + me->RemoveAura(38757); me->RemoveUnitFlag(UNIT_FLAG_NOT_SELECTABLE); me->SetReactState(REACT_AGGRESSIVE); - if (InstanceScript* instance = me->GetInstanceScript()) - if (Creature* ignis = instance->GetCreature(BOSS_IGNIS)) - { - ignis->CastSpell(ignis, SPELL_STRENGTH_OF_THE_CREATOR, true); - AttackStart(ignis->GetVictim()); - DoZoneInCombat(); - } + ignis->CastSpell(ignis, SPELL_STRENGTH_OF_THE_CREATOR, true); + AttackStart(ignis->GetVictim()); + DoZoneInCombat(); } else if (spell->Id == SPELL_HEAT_BUFF) { @@ -266,11 +269,7 @@ struct boss_ignis : public BossAI Talk(SAY_DEATH); _JustDied(); - std::list icl; - me->GetCreaturesWithEntryInRange(icl, 300.0f, NPC_IRON_CONSTRUCT); - for (std::list::iterator itr = icl.begin(); itr != icl.end(); ++itr) - if ((*itr)->IsAlive() && (*itr)->IsInCombat()) - Unit::Kill(*itr, *itr); + DoCastAOE(SPELL_KILL_ALL_CONSTRUCTS, true); } void SpellHit(Unit* caster, SpellInfo const* spell) override