fix(Scripts/Dalaran): Eject only from the quarters (#27666)

This commit is contained in:
llupa
2026-09-17 14:11:11 +02:00
committed by GitHub
parent 0e6a5d322c
commit 41f475e9da
4 changed files with 269 additions and 25 deletions
+1
View File
@@ -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 |
---
@@ -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")
}
+2
View File
@@ -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,
+63 -25
View File
@@ -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 <algorithm>
#include <array>
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<int32, 11> WMO_GROUPS_SILVER_ENCLAVE =
{
24537, 24704, 24713, 25067, 25177, 25367, 25368, 25369, 25370, 25371, 25768
};
constexpr std::array<int32, 7> WMO_GROUPS_SUNREAVERS_SANCTUARY =
{
24725, 25066, 25145, 25381, 25383, 25384, 25406
};
template <std::size_t N>
bool IsInsideWMOGroups(WorldObject const* who, std::array<int32, N> 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());