From d1813fe4bb4af0b3e9ea8f13b2f1f0e49fcd9939 Mon Sep 17 00:00:00 2001 From: Andrew <47818697+Nyeriah@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:14:46 -0300 Subject: [PATCH] fix(Scripts/Ulduar): kill every Iron Construct when Ignis dies (#27751) Only constructs that were already in combat were killed, leaving the dormant ones alive after the boss died. Activate Construct is a slow missile, so a bolt still in flight then landed on one of the survivors and activated it, pulling the raid back into combat. Retail handles this with Kill All Constructs (65109), an instakill that hits every Iron Construct in range, so cast that instead of killing them from script. Its implicit target is entry-based and matches every unit in range without a condition, players included, so the targeting row ships with it. Adds e2e coverage: TestAC_27736_IgnisKillsAllConstructs. Closes #27736 --- .../rev_1790027989449660900.sql | 5 + e2e/README.md | 2 +- .../northrend/ulduar/ulduar_e2e_test.go | 135 ++++++++++++++++++ .../Northrend/Ulduar/Ulduar/boss_ignis.cpp | 23 ++- 4 files changed, 152 insertions(+), 13 deletions(-) create mode 100644 data/sql/updates/pending_db_world/rev_1790027989449660900.sql 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