From 41f475e9da33a2eeea243f5641d170cbfa11ab4a Mon Sep 17 00:00:00 2001 From: llupa <41073314+llupa@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:11:11 +0200 Subject: [PATCH] fix(Scripts/Dalaran): Eject only from the quarters (#27666) --- e2e/README.md | 1 + .../world/dalaran/trespasser_e2e_test.go | 203 ++++++++++++++++++ src/server/game/Maps/AreaDefines.h | 2 + src/server/scripts/Northrend/zone_dalaran.cpp | 88 +++++--- 4 files changed, 269 insertions(+), 25 deletions(-) create mode 100644 e2e/suites/world/dalaran/trespasser_e2e_test.go diff --git a/e2e/README.md b/e2e/README.md index fee8194f2a..ec4f16e00f 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -165,6 +165,7 @@ go test -tags=e2e ./local/... -count=1 -v -timeout 30m -parallel 1 | 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 | +| 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/world/dalaran/trespasser_e2e_test.go b/e2e/suites/world/dalaran/trespasser_e2e_test.go new file mode 100644 index 0000000000..f46cb83923 --- /dev/null +++ b/e2e/suites/world/dalaran/trespasser_e2e_test.go @@ -0,0 +1,203 @@ +//go:build e2e + +package dalaran_test + +import ( + "testing" + "time" + + _ "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" +) + +const ( + dalaranMap uint32 = 571 + spellSummonImp = 688 + settleWindow = 9 * time.Second +) + +// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/4467 +// +// Dalaran's faction guards used to decide "is this player trespassing" from how +// close a fruit vendor was and which way the guard happened to face, which ejected +// players standing on public ground — a mailbox, the street, the sewers — while +// leaving most of each sanctum unwatched. +// +// They now eject on where the player is: the sanctum area ids, plus the WMO groups +// that make up each quarter's buildings, since most of that ground reports the +// plain Dalaran area id and cannot be told from neutral space by area alone. +// +// Both directions are asserted, because each alone is easy to satisfy wrongly. +// Checking only that public ground is safe would pass a build where the guards +// never fire; checking only the sanctums would pass the original bug untouched. +type trespassCase struct { + name string + x, y, z float32 + wantEject bool + why string +} + +func TestAC_4467_DalaranGuardsOnlyEjectFromRestrictedAreas(t *testing.T) { + meta.Begin(t, meta.TestMeta{ + Tags: []string{"med", "world", "issue", "serial"}, + Runtime: "med", + Issue: 4467, + Category: "world/dalaran", + }) + + horde := []trespassCase{ + {"MailboxByHerosWelcome", 5740.3, 680.9, 644.7, false, "public mailbox outside the inn"}, + // Deliberately a few yards off the trespasser destination (5758.79, 678.359, + // 642.726): standing on it would make an ejection a zero-yard move that the + // displacement oracle below could never see. + {"StreetOutsideHerosWelcome", 5750.0, 678.8, 642.8, false, "the public street beside the spell's destination"}, + {"UnderbellySewers", 5759.2, 716.1, 618.6, false, "the public sewers"}, + {"SilverEnclaveThreshold", 5754.83, 718.36, 641.71, true, "past the guards into the Enclave"}, + {"SilverEnclaveCourtyard", 5740.3, 739.6, 641.9, true, "Alliance sanctum"}, + {"DeepSilverEnclave", 5671.43, 724.48, 653.41, true, "deep Alliance quarter"}, + {"InsideAHerosWelcome", 5725.674, 683.2177, 646.565, true, "the Alliance inn"}, + } + alliance := []trespassCase{ + {"MailboxBySunreaverSide", 5893.2, 528.2, 641.4, false, "public mailbox on the Horde side"}, + {"LegerdemainLoungeAlliance", 5847.97, 635.43, 647.57, false, "neutral inn, 35.4y from a guard so really evaluated"}, + {"SunreaversSanctuary", 5862.0, 520.3, 655.5, true, "Horde sanctum"}, + {"InsideTheFilthyAnimal", 5892.3, 505.75, 641.65, true, "the Horde inn"}, + } + + run := func(t *testing.T, race uint8, cases []trespassCase) { + // GM mode must be off: npc_mageguard_dalaran skips IsGameMaster(), so a GM + // is never ejected and every case would pass vacuously. + bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{ + Prefix: "Tres", + Race: race, + Class: e2eharness.ClassWarrior, + Level: 80, + }) + e2eharness.MustGM(t, bot.World, ".gm off") + // The whole oracle rests on GM mode being off - IsGameMaster() short-circuits + // the guard check - so ack it on the world thread rather than assume it took. + bot.FlushWorld(t) + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + e2eharness.TeleportGo(t, bot.World, c.x, c.y, c.z, dalaranMap) + + ejected := false + deadline := time.Now().Add(settleWindow) + for time.Now().Before(deadline) { + ax, ay, az, _ := bot.Pos() + if e2eharness.Distance3D(ax, ay, az, c.x, c.y, c.z) > 10 { + ejected = true + break + } + time.Sleep(250 * time.Millisecond) + } + + switch { + case c.wantEject && !ejected: + e2eharness.Assertf(t, "%s (%.2f, %.2f, %.2f): a hostile player was not ejected from %s; "+ + "the restricted ground is unguarded", c.name, c.x, c.y, c.z, c.why) + case !c.wantEject && ejected: + e2eharness.Assertf(t, "%s (%.2f, %.2f, %.2f): a player standing on %s was teleported away; "+ + "this is the AC#4467 regression — public ground is not the faction quarter", + c.name, c.x, c.y, c.z, c.why) + default: + t.Logf("PASS %-26s ejected=%-5v (%s)", c.name, ejected, c.why) + } + }) + } + } + + t.Run("Horde", func(t *testing.T) { run(t, e2eharness.RaceOrc, horde) }) + t.Run("Alliance", func(t *testing.T) { run(t, e2eharness.RaceHuman, alliance) }) + t.Run("PetInsideOwnerOutside", petInsideOwnerOutside) +} + +// The guard classifies and casts on who, not on who's owner, so a controlled unit +// inside a quarter is ejected on its own footing even while its owner stands on +// public ground. Every other case here is a lone player, where the two positions +// are identical - this is the only one that would catch a regression reading the +// owner instead. +func petInsideOwnerOutside(t *testing.T) { + bot := e2eharness.NewSolo(t, e2eharness.ScenarioOpts{ + Prefix: "TresP", + Race: e2eharness.RaceOrc, + Class: e2eharness.ClassWarlock, + Level: 80, + LearnAllClass: true, + }) + + bot.Learn(t, spellSummonImp) + _ = bot.CastOrGM(t, spellSummonImp, 0, 20*time.Second) + pet := bot.WaitPlayerPet(t, 25*time.Second) + + petPos := func() (float32, float32, float32, bool) { + for _, u := range bot.World.GetNearbyUnits(200) { + if u.GUID == pet { + return u.PosX, u.PosY, u.PosZ, true + } + } + return 0, 0, 0, false + } + + // Park owner and pet inside the Enclave under GM - the guard reads + // IsGameMaster() from the owner, so nothing fires while positioning. + e2eharness.MustGM(t, bot.World, ".gm on") + e2eharness.TeleportGo(t, bot.World, 5740.3, 739.6, 641.9, dalaranMap) + time.Sleep(3 * time.Second) + stay := client.MakePetActionButton(client.PetCommandStay, client.PetActCommand) + if err := bot.World.PetAction(pet, stay, 0); err != nil { + e2eharness.HarnessFailf(t, "pet stay: %v", err) + } + time.Sleep(2 * time.Second) + + // Owner out to the public mailbox; the pet stays behind inside the quarter. + e2eharness.TeleportGo(t, bot.World, 5740.3, 680.9, 644.7, dalaranMap) + time.Sleep(3 * time.Second) + px, py, pz, ok := petPos() + if !ok { + e2eharness.HarnessFailf(t, "pet is no longer tracked; it followed or despawned, so this case proves nothing") + } + if e2eharness.Distance3D(px, py, pz, 5740.3, 739.6, 641.9) > 10 { + e2eharness.HarnessFailf(t, "pet did not hold position inside the quarter (%.1f, %.1f, %.1f)", px, py, pz) + } + + e2eharness.MustGM(t, bot.World, ".gm off") + bot.FlushWorld(t) + + // A stationary unit is never re-evaluated - MoveInLineOfSight fires on + // relocation - so command Follow to make it move while still inside. + follow := client.MakePetActionButton(client.PetCommandFollow, client.PetActCommand) + if err := bot.World.PetAction(pet, follow, 0); err != nil { + e2eharness.HarnessFailf(t, "pet follow: %v", err) + } + + ejected := false + deadline := time.Now().Add(settleWindow) + for time.Now().Before(deadline) { + x, y, z, still := petPos() + if !still { + break + } + // Ejection lands on the trespasser destination, ~19y from the owner, so it + // is distinguishable from the pet simply walking back. + if e2eharness.Distance3D(x, y, z, 5758.79, 678.36, 642.73) < 5 { + ejected = true + break + } + time.Sleep(250 * time.Millisecond) + } + + ox, oy, oz, _ := bot.Pos() + if e2eharness.Distance3D(ox, oy, oz, 5740.3, 680.9, 644.7) > 10 { + e2eharness.Assertf(t, "the owner was teleported off public ground at the mailbox; only the pet was trespassing") + } + if !ejected { + e2eharness.Assertf(t, "a pet left inside the Silver Enclave was not ejected while its owner stood on public ground; "+ + "the guard is reading the owner's position instead of the trespasser's") + } + t.Logf("PASS PetInsideOwnerOutside pet ejected, owner untouched on public ground") +} diff --git a/src/server/game/Maps/AreaDefines.h b/src/server/game/Maps/AreaDefines.h index f722edd008..03cf4e5912 100644 --- a/src/server/game/Maps/AreaDefines.h +++ b/src/server/game/Maps/AreaDefines.h @@ -159,6 +159,7 @@ enum AreaTableIDs : uint32 AREA_THE_CHILLED_QUAGMIRE = 4589, AREA_WESTSPARK_WORKSHOP = 4611, AREA_EASTSPARK_WORKSHOP = 4612, + AREA_SUNREAVERS_SANCTUARY = 4616, AREA_VARGOTHS_RETREAT = 4637, AREA_FORMATION_GROUNDS = 4652, AREA_THE_CONSERVATORY_OF_LIFE = 4656, @@ -171,6 +172,7 @@ enum AreaTableIDs : uint32 AREA_SUNREAVER_PAVILION = 4676, AREA_SILVER_COVENANT_PAVILION = 4677, AREA_ISLE_OF_CONQUEST = 4710, + AREA_THE_SILVER_ENCLAVE = 4740, AREA_ICECROWN_CITADEL = 4812, AREA_HALLS_OF_REFLECTION = 4820, AREA_THE_FROZEN_THRONE = 4859, diff --git a/src/server/scripts/Northrend/zone_dalaran.cpp b/src/server/scripts/Northrend/zone_dalaran.cpp index 1e2f3132ff..4c43a794b9 100644 --- a/src/server/scripts/Northrend/zone_dalaran.cpp +++ b/src/server/scripts/Northrend/zone_dalaran.cpp @@ -17,12 +17,15 @@ #include "AreaDefines.h" #include "CreatureScript.h" +#include "Map.h" #include "MoveSplineInit.h" #include "Player.h" #include "ScriptedCreature.h" #include "ScriptedGossip.h" #include "TaskScheduler.h" #include "World.h" +#include +#include class npc_steam_powered_auctioneer : public CreatureScript { @@ -413,14 +416,52 @@ enum Spells SPELL_SILVER_COVENANT_DISGUISE_MALE = 70972, }; -enum NPCs // All outdoor guards are within 35.0f of these NPCs +enum NPCs { - NPC_APPLEBOUGH_A = 29547, - NPC_SWEETBERRY_H = 29715, NPC_SILVER_COVENANT_GUARDIAN_MAGE = 29254, NPC_SUNREAVER_GUARDIAN_MAGE = 29255, }; +// The guards must notice a trespasser anywhere inside the quarter they watch, not +// just beside themselves: no guard stands within interaction range of most of the +// floor area of either sanctum. +constexpr float GUARD_WATCH_RANGE = 40.0f; + +// The area id only covers part of each sanctum. Most of the restricted ground - +// the buildings, the inns, much of the interior - resolves to 4395 (Dalaran), the +// same value the neutral Legerdemain Lounge reports, so an area check alone leaves +// holes. WMOAreaTable.dbc does tell them apart, and it is what labels the building +// on screen as you walk in, so match the WMO group as well. +// +// From WMOAreaTable.dbc for the Dalaran model (WMOID 5164): every group named +// "The Silver Enclave" / "A Hero's Welcome", and "Sunreaver's Sanctuary" / "The +// Filthy Animal". The neutral buildings - the Legerdemain Lounge, Sisters +// Sorcerous, The Wonderworks, the Visitor Center - are in neither list, which is +// what keeps a hostile visitor welcome in them. 25768 is The Beer Garden, a named +// venue inside the Silver Enclave rather than a group named for the quarter, found +// by sweeping the quarter and reading back the groups the server actually resolves. +constexpr std::array WMO_GROUPS_SILVER_ENCLAVE = +{ + 24537, 24704, 24713, 25067, 25177, 25367, 25368, 25369, 25370, 25371, 25768 +}; +constexpr std::array WMO_GROUPS_SUNREAVERS_SANCTUARY = +{ + 24725, 25066, 25145, 25381, 25383, 25384, 25406 +}; + +template +bool IsInsideWMOGroups(WorldObject const* who, std::array const& groups) +{ + uint32 mogpFlags; + int32 adtId, rootId, groupId; + if (!who->GetMap()->GetAreaInfo(who->GetPhaseMask(), + who->GetPositionX(), who->GetPositionY(), who->GetPositionZ(), + mogpFlags, adtId, rootId, groupId)) + return false; + + return std::find(groups.begin(), groups.end(), groupId) != groups.end(); +} + class npc_mageguard_dalaran : public CreatureScript { public: @@ -443,10 +484,10 @@ public: void MoveInLineOfSight(Unit* who) override { - if (!who || !who->IsInWorld()|| who->GetZoneId() != AREA_DALARAN || who->GetAreaId() == AREA_SEWER_EXIT_PIPE) + if (!who || !who->IsInWorld() || who->GetZoneId() != AREA_DALARAN) return; - if (!me->IsWithinDist(who, 5.0f, false)) + if (!me->IsWithinDist(who, GUARD_WATCH_RANGE, false)) return; if (who->IsCreature() && who->GetCreatureType() == CREATURE_TYPE_NON_COMBAT_PET) @@ -459,31 +500,28 @@ public: player->HasAnyAuras(SPELL_SUNREAVER_DISGUISE_FEMALE, SPELL_SUNREAVER_DISGUISE_MALE, SPELL_SILVER_COVENANT_DISGUISE_FEMALE, SPELL_SILVER_COVENANT_DISGUISE_MALE)) return; + // Eject on where the trespasser actually is, not on how it is standing + // relative to the guard. Both halves are needed: the area ids cover each + // sanctum's open courtyard, the WMO groups cover its buildings, which + // report the plain zone id instead. Neutral streets, mailboxes, the sewer + // pipe and the neutral inns are in neither. + // + // Position comes from who rather than its owner, so that a pet sent into + // a quarter is ejected on its own footing - it is who that gets teleported. + uint32 const areaId = who->GetAreaId(); + switch (me->GetEntry()) { case NPC_SILVER_COVENANT_GUARDIAN_MAGE: - if (player->GetTeamId() == TEAM_HORDE) // Horde unit found in Alliance area - { - if (GetClosestCreatureWithEntry(me, NPC_APPLEBOUGH_A, 32.0f)) - { - if (me->isInBackInMap(who, 12.0f)) // In my line of sight, "outdoors", and behind me - DoCast(who, SPELL_TRESPASSER_A); // Teleport the Horde unit out - } - else // In my line of sight, and "indoors" - DoCast(who, SPELL_TRESPASSER_A); // Teleport the Horde unit out - } + if (player->GetTeamId() == TEAM_HORDE + && (areaId == AREA_THE_SILVER_ENCLAVE || IsInsideWMOGroups(who, WMO_GROUPS_SILVER_ENCLAVE))) + DoCast(who, SPELL_TRESPASSER_A); break; case NPC_SUNREAVER_GUARDIAN_MAGE: - if (player->GetTeamId() == TEAM_ALLIANCE) // Alliance unit found in Horde area - { - if (GetClosestCreatureWithEntry(me, NPC_SWEETBERRY_H, 32.0f)) - { - if (me->isInBackInMap(who, 12.0f)) // In my line of sight, "outdoors", and behind me - DoCast(who, SPELL_TRESPASSER_H); // Teleport the Alliance unit out - } - else // In my line of sight, and "indoors" - DoCast(who, SPELL_TRESPASSER_H); // Teleport the Alliance unit out - } + if (player->GetTeamId() == TEAM_ALLIANCE + && (areaId == AREA_SUNREAVERS_SANCTUARY + || IsInsideWMOGroups(who, WMO_GROUPS_SUNREAVERS_SANCTUARY))) + DoCast(who, SPELL_TRESPASSER_H); break; } me->SetOrientation(me->GetHomePosition().GetOrientation());