#include "mod_waygate_network.h"
#include "Chat.h"
#include "CommandScript.h"
#include "Config.h"
#include "Creature.h"
#include "DatabaseEnv.h"
#include "DataMap.h"
#include "GameObject.h"
#include "Log.h"
#include "ObjectAccessor.h"
#include "Opcodes.h"
#include "Player.h"
#include "ScriptedGossip.h"
#include "ScriptMgr.h"
#include "StringFormat.h"
#include "WorldPacket.h"
#include
#include
#include
#include
#ifdef MOD_PLAYERBOTS
#include "Playerbots.h"
#endif
using namespace Acore::ChatCommands;
namespace
{
// Two gossip "pages" share the same menu/text id but are told apart by sender: continent list
// first, then destinations within the chosen continent. Action 0 always means "close everything"
// regardless of which page sent it; GOSSIP_ACTION_BACK is a page-2-only sentinel picked well
// outside any real destination/continent id range.
constexpr uint32 GOSSIP_SENDER_WAYGATE_CONTINENT = 1;
constexpr uint32 GOSSIP_SENDER_WAYGATE_DESTINATION = 2;
constexpr uint32 GOSSIP_ACTION_CANCEL = 0;
constexpr uint32 GOSSIP_ACTION_BACK = std::numeric_limits::max();
// Fixed display order; a destination's `continent` column must match one of these exactly (the
// world-DB column is an ENUM constraining it to these 4 values). Action codes for continent
// picks are 1-based indices into this list, not a per-request dynamic index, so they stay stable
// regardless of which continents a given character actually has anything attuned in.
std::vector const WaygateContinents =
{
"Eastern Kingdoms", "Kalimdor", "Outland", "Northrend"
};
uint32 GetContinentAction(std::string const& continent)
{
for (size_t i = 0; i < WaygateContinents.size(); ++i)
if (WaygateContinents[i] == continent)
return static_cast(i + 1);
return GOSSIP_ACTION_CANCEL;
}
std::string const* GetContinentByAction(uint32 action)
{
if (!action || action > WaygateContinents.size())
return nullptr;
return &WaygateContinents[action - 1];
}
// Playerbots don't get a say in Waygate travel -- they don't need the QoL, and letting
// hundreds of random bots attune/teleport would spam mod_waygate_network_discovered and chat
// for no player-facing benefit.
bool IsPlayerbot([[maybe_unused]] Player* player)
{
#ifdef MOD_PLAYERBOTS
return GET_PLAYERBOT_AI(player) != nullptr;
#else
return false;
#endif
}
// Populated once at startup by WaygateNetwork_World::OnLoadCustomDatabaseTable, from
// mod_waygate_network_locations (world DB) -- no longer hardcoded, so admins can add
// destinations with a plain SQL insert instead of a code change + rebuild.
std::vector WaygateDestinationList;
void LoadWaygateDestinations()
{
WaygateDestinationList.clear();
QueryResult result = WorldDatabase.Query(
"SELECT id, name, continent, faction, map, zone, position_x, position_y, position_z, orientation "
"FROM mod_waygate_network_locations ORDER BY id");
if (!result)
{
LOG_WARN("server.loading", "mod-waygate-network: mod_waygate_network_locations is empty -- no travel destinations loaded.");
return;
}
do
{
Field* row = result->Fetch();
WaygateDestinationList.push_back(WaygateDestination
{
row[0].Get(),
row[1].Get(),
row[2].Get(),
row[3].Get(),
row[4].Get(),
row[5].Get(),
row[6].Get(),
row[7].Get(),
row[8].Get(),
row[9].Get()
});
} while (result->NextRow());
LOG_INFO("server.loading", "mod-waygate-network: loaded {} Waygate destination(s).", WaygateDestinationList.size());
}
// No Player::PlaySound helper exists in this core -- every use of SMSG_PLAY_SOUND (e.g.
// Map::PlayDirectSoundToMap) builds the packet by hand, so this does the same for one player.
void PlaySoundToPlayer(Player* player, uint32 soundId)
{
if (!soundId)
return;
WorldPacket data(SMSG_PLAY_SOUND, 4);
data << uint32(soundId);
player->SendDirectMessage(&data);
}
// Records a destination as attuned (mod_waygate_network_discovered), plays the unlock sound, and
// announces it. Called from go_waygate_standing_stone::OnGossipHello -- interacting with a
// destination's own Waygate Stone is the only way to attune it (see "Waygate Stones" in
// README.md for why this replaced the earlier walk-into-the-zone auto-attunement). A no-op if
// already attuned, so it's safe to call without a pre-check.
void AttuneWaygateDestination(Player* player, WaygateDestination const& dest)
{
QueryResult result = CharacterDatabase.Query(
"SELECT 1 FROM mod_waygate_network_discovered WHERE guid = {} AND waygate_id = {}",
player->GetGUID().GetCounter(), dest.id);
if (result)
return;
CharacterDatabase.Execute(
"INSERT INTO mod_waygate_network_discovered (guid, waygate_id, discovered_time) VALUES ({}, {}, UNIX_TIMESTAMP())",
player->GetGUID().GetCounter(), dest.id);
PlaySoundToPlayer(player, sConfigMgr->GetOption("WaygateNetwork.UnlockSoundId", 1519));
// One-shot cosmetic flash on the player -- GameObject has no SendPlaySpellVisual (that's a
// Unit-only method), so this plays on the player rather than the stone itself. Reuses the
// same real Teleport-family visual kit already relied on for the travel channel (kit 267
// there is the sustained casting-loop; this is that family's ImpactKit, the flash that plays
// when a real Teleport cast completes), keeping the two moments visually related without
// inventing a new effect from scratch.
if (uint32 visualId = sConfigMgr->GetOption("WaygateNetwork.AttuneVisualKitId", 3394))
player->SendPlaySpellVisual(visualId);
// One-shot animation to go with it -- EMOTE_ONESHOT_USE_STANDING is the real, generic
// "reach out and use a standing object" gesture, purely cosmetic like the visual/sound above
// (HandleEmoteCommand just broadcasts the animation, no gameplay side effect).
player->HandleEmoteCommand(EMOTE_ONESHOT_USE_STANDING);
ChatHandler chat(player->GetSession());
chat.PSendSysMessage("Waygate attuned: {}", dest.name);
QueryResult countResult = CharacterDatabase.Query(
"SELECT COUNT(*) FROM mod_waygate_network_discovered WHERE guid = {}",
player->GetGUID().GetCounter());
// The row we just inserted makes this 1 on a character's very first-ever attunement.
if (countResult && countResult->Fetch()[0].Get() == 1)
chat.PSendSysMessage("Tip: type .waygate to open your travel menu -- you can even bind it to a macro, e.g. '/say .waygate'.");
}
// Debug mode lists every known destination instead of just attuned ones, and skips the
// attunement check on teleport -- for reviewing/testing new mod_waygate_network_locations rows
// without needing to physically visit each one first. Gated on GM security regardless of the
// config flag, so flipping the config alone can't let a regular player skip attunement.
bool IsWaygateDebugFor(Player* player)
{
return sConfigMgr->GetOption("WaygateNetwork.Debug", false)
&& player->GetSession()->GetSecurity() >= SEC_GAMEMASTER;
}
// A "Neutral" destination (sanctuary cities like Shattrath/Dalaran) is open to everyone;
// "Alliance"/"Horde" destinations are only offered to/reachable by that faction's own players --
// landing an Alliance character in the middle of Orgrimmar just to get jumped by guards isn't a
// feature. Debug mode (IsWaygateDebugFor, GM-only) is the only override, matching the existing
// "GM sees/travels everywhere regardless" bypass already used for attunement.
bool WaygateFactionAllowed(Player* player, WaygateDestination const& dest)
{
if (dest.faction == "Neutral")
return true;
switch (player->GetTeamId())
{
case TEAM_ALLIANCE:
return dest.faction == "Alliance";
case TEAM_HORDE:
return dest.faction == "Horde";
default:
return true;
}
}
// Alternative to flight paths, not a replacement -- deliberately priced above a comparable
// flight. Real Blizzard flight prices (extracted from this client's TaxiPath.dbc, 2026-08-15;
// not derivable from a formula -- see PLAN.md) range ~50c (Stormwind <-> Ironforge) to ~12000c
// for the longest single-continent legs. Default WaygateNetwork.CostPerYard (0.02) puts
// Stormwind <-> Ironforge (~4217yd straight-line) at ~84c, roughly 1.7x that real 50c price --
// the one real anchor point available. Longer/shorter routes will drift from that exact ratio
// since straight-line distance isn't real flight-path distance and Blizzard's real prices
// aren't a clean function of distance either; tune the config values against what you actually
// see in-game rather than trusting these as precisely calibrated.
uint32 CalculateWaygateCost(Player* player, WaygateDestination const& dest)
{
uint32 cost;
if (player->GetMapId() != dest.mapId)
{
cost = sConfigMgr->GetOption("WaygateNetwork.CrossMapCostCopper", 20000);
}
else
{
float dx = player->GetPositionX() - dest.x;
float dy = player->GetPositionY() - dest.y;
float dz = player->GetPositionZ() - dest.z;
float distance = std::sqrt(dx * dx + dy * dy + dz * dz);
cost = static_cast(distance * sConfigMgr->GetOption("WaygateNetwork.CostPerYard", 0.02f));
}
return std::max(cost, sConfigMgr->GetOption("WaygateNetwork.MinCostCopper", 25));
}
std::string FormatWaygateCost(uint32 copper)
{
uint32 gold = copper / 10000;
uint32 silver = (copper % 10000) / 100;
copper %= 100;
std::string text;
if (gold)
text += Acore::StringFormat("{}|TInterface\\MoneyFrame\\UI-GoldIcon:0|t ", gold);
if (gold || silver)
text += Acore::StringFormat("{}|TInterface\\MoneyFrame\\UI-SilverIcon:0|t ", silver);
text += Acore::StringFormat("{}|TInterface\\MoneyFrame\\UI-CopperIcon:0|t", copper);
return text;
}
Creature* SummonWaygatePortal(Player* player)
{
// See the SQL comment on waygate_network_portal_npc.sql for why a real (if short-lived) NPC
// is needed here instead of sourcing gossip from the player directly.
return player->SummonCreature(NPC_WAYGATE_PORTAL, player->GetPositionX(), player->GetPositionY(),
player->GetPositionZ(), player->GetOrientation(), TEMPSUMMON_TIMED_DESPAWN, 15000);
}
// Validates, charges, and teleports -- called once the wait (see BeginWaygateChannel) actually
// completes without the player moving away or losing eligibility in the meantime.
void FinishWaygateChannel(Player* player, WaygateDestination const& dest,
float startX, float startY, float startZ, uint32 startMap)
{
// The channel is ending one way or another the moment this runs (success or one of the
// aborts below) -- clear the looping channel animation BeginWaygateChannel set, on every path.
player->ClearEmoteState();
float dx = player->GetPositionX() - startX;
float dy = player->GetPositionY() - startY;
float dz = player->GetPositionZ() - startZ;
float moved = std::sqrt(dx * dx + dy * dy + dz * dz);
if (player->GetMapId() != startMap || moved > 2.0f)
{
ChatHandler(player->GetSession()).PSendSysMessage("You moved and lost your focus on the waygate.");
return;
}
if (player->IsInCombat())
{
ChatHandler(player->GetSession()).PSendSysMessage("The waygate falters -- you're in combat.");
return;
}
// Re-check everything BeginWaygateChannel already checked -- the module could've been
// disabled, destinations reloaded, or funds spent during the wait.
if (!sConfigMgr->GetOption("WaygateNetwork.Enable", true))
return;
QueryResult result = CharacterDatabase.Query(
"SELECT 1 FROM mod_waygate_network_discovered WHERE guid = {} AND waygate_id = {}",
player->GetGUID().GetCounter(), dest.id);
if (!result)
return;
if (!WaygateFactionAllowed(player, dest))
return;
uint32 cost = CalculateWaygateCost(player, dest);
if (!player->HasEnoughMoney(cost))
{
ChatHandler(player->GetSession()).PSendSysMessage(
"You can't afford the {} it costs to travel to {}.", FormatWaygateCost(cost), dest.name);
return;
}
player->ModifyMoney(-static_cast(cost));
player->TeleportTo(dest.mapId, dest.x, dest.y, dest.z, dest.o);
}
// No real spell involved at all -- see PLAN.md's "v1.7"/"v1.8" for why the earlier "borrow a
// real spell's cast bar" approach (Teleport, then Hearthstone) kept misbehaving in ways that
// were hard to fully pin down or trust (missing cast bar, leaking the donor's real cooldown,
// and finally the real hearth-home effect still firing some of the time despite
// PreventHitEffect). This is a plain, fully server-controlled wait: a message, an optional
// one-shot cosmetic visual burst (SendPlaySpellVisual -- a bare SMSG_PLAY_SPELL_VISUAL packet
// with zero gameplay side effects: no aura, no cooldown, nothing to suppress or leak), a looping
// "channeling" animation (SetEmoteState -- same story, purely visual), and a timer via the
// player's own event scheduler. Cost is deliberately NOT charged here: only if the wait actually
// completes (see FinishWaygateChannel).
void BeginWaygateChannel(Player* player, WaygateDestination const& dest)
{
// Re-validate against the DB rather than trusting the gossip action alone -- the menu is
// rebuilt from discovered rows each time, but this guards against a stale/replayed packet.
QueryResult result = CharacterDatabase.Query(
"SELECT 1 FROM mod_waygate_network_discovered WHERE guid = {} AND waygate_id = {}",
player->GetGUID().GetCounter(), dest.id);
if (!result)
return;
if (!WaygateFactionAllowed(player, dest))
return;
if (player->IsInCombat())
{
ChatHandler(player->GetSession()).PSendSysMessage("You can't open a waygate while in combat.");
return;
}
uint32 cost = CalculateWaygateCost(player, dest);
if (!player->HasEnoughMoney(cost))
{
ChatHandler(player->GetSession()).PSendSysMessage(
"You can't afford the {} it costs to travel to {}.", FormatWaygateCost(cost), dest.name);
return;
}
ChatHandler(player->GetSession()).PSendSysMessage("Hold still... the waygate reaches for you.");
if (uint32 visualId = sConfigMgr->GetOption("WaygateNetwork.ChannelVisualKitId", 267))
player->SendPlaySpellVisual(visualId);
// Generic "channeling a spell" loop -- cleared by FinishWaygateChannel unconditionally, on
// every path (success, moved-away, combat, etc.), since the channel is always over by then.
player->SetEmoteState(EMOTE_STATE_SPELL_CHANNEL_OMNI);
// Captured by GUID and re-resolved via ObjectAccessor when the timer fires, not by raw
// Player* -- the player could log out, disconnect, or otherwise be destroyed during the
// wait. dest is captured by value for the same reason: WaygateDestinationList can be
// reloaded (.waygate reload) mid-wait, which would invalidate a pointer/reference into it.
ObjectGuid guid = player->GetGUID();
WaygateDestination destCopy = dest;
float startX = player->GetPositionX();
float startY = player->GetPositionY();
float startZ = player->GetPositionZ();
uint32 startMap = player->GetMapId();
uint32 channelMs = sConfigMgr->GetOption("WaygateNetwork.ChannelMs", 4000);
player->m_Events.AddEventAtOffset([guid, destCopy, startX, startY, startZ, startMap]()
{
if (Player* p = ObjectAccessor::FindPlayer(guid))
FinishWaygateChannel(p, destCopy, startX, startY, startZ, startMap);
}, Milliseconds(channelMs));
}
// Every destination the player could pick from at the destination page: attuned ones normally,
// every known destination in debug mode (see IsWaygateDebugFor).
std::vector GetSelectableDestinations(Player* player, bool debug)
{
std::vector destinations;
if (debug)
{
for (WaygateDestination const& dest : WaygateDestinationList)
destinations.push_back(&dest);
return destinations;
}
QueryResult result = CharacterDatabase.Query(
"SELECT waygate_id FROM mod_waygate_network_discovered WHERE guid = {} ORDER BY waygate_id",
player->GetGUID().GetCounter());
if (result)
{
do
{
Field* row = result->Fetch();
if (WaygateDestination const* dest = GetWaygateDestinationById(row[0].Get()))
if (WaygateFactionAllowed(player, *dest))
destinations.push_back(dest);
} while (result->NextRow());
}
return destinations;
}
// sender is a real Creature (the summoned portal), not the player -- see the SQL comment on
// waygate_network_portal_npc.sql for why a player-self-sourced gossip menu doesn't work.
void ShowWaygateContinentMenu(Player* player, Creature* sender)
{
ClearGossipMenuFor(player);
std::vector destinations = GetSelectableDestinations(player, IsWaygateDebugFor(player));
std::set present;
for (WaygateDestination const* dest : destinations)
present.insert(dest->continent);
if (present.empty())
{
AddGossipItemFor(player, GOSSIP_ICON_CHAT,
"You haven't attuned any Waygates yet. Visit a capital city to attune one.",
GOSSIP_SENDER_WAYGATE_CONTINENT, GOSSIP_ACTION_CANCEL);
}
else
{
for (std::string const& continent : WaygateContinents)
if (present.count(continent))
AddGossipItemFor(player, GOSSIP_ICON_TAXI, continent,
GOSSIP_SENDER_WAYGATE_CONTINENT, GetContinentAction(continent));
}
// Always present, even with just one continent -- keeps the menu at 2+ options so there's
// never a single-choice list to worry about, and gives an explicit way out that closes the
// menu and despawns the portal immediately instead of just walking away from it.
AddGossipItemFor(player, GOSSIP_ICON_CHAT, "I changed my mind...", GOSSIP_SENDER_WAYGATE_CONTINENT,
GOSSIP_ACTION_CANCEL);
SendGossipMenuFor(player, WAYGATE_NETWORK_GOSSIP_TEXT, sender->GetGUID());
}
void ShowWaygateDestinationMenu(Player* player, Creature* sender, std::string const& continent)
{
ClearGossipMenuFor(player);
bool debug = IsWaygateDebugFor(player);
for (WaygateDestination const* dest : GetSelectableDestinations(player, debug))
{
if (dest->continent != continent)
continue;
std::string text = debug
? dest->name + " |cffff0000[debug]|r"
: dest->name + " - " + FormatWaygateCost(CalculateWaygateCost(player, *dest));
AddGossipItemFor(player, GOSSIP_ICON_TAXI, text, GOSSIP_SENDER_WAYGATE_DESTINATION, dest->id);
}
// "I changed my mind..." only lives on the continent page -- "« Back" already covers
// backing out of this one, no need for two different-looking exits on the same page.
AddGossipItemFor(player, GOSSIP_ICON_CHAT, "« Back", GOSSIP_SENDER_WAYGATE_DESTINATION, GOSSIP_ACTION_BACK);
SendGossipMenuFor(player, WAYGATE_NETWORK_GOSSIP_TEXT, sender->GetGUID());
}
}
WaygateDestination const* FindWaygateDestinationByZone(uint32 zoneId)
{
for (WaygateDestination const& dest : WaygateDestinationList)
if (dest.zoneId == zoneId)
return &dest;
return nullptr;
}
WaygateDestination const* GetWaygateDestinationById(uint32 id)
{
for (WaygateDestination const& dest : WaygateDestinationList)
if (dest.id == id)
return &dest;
return nullptr;
}
class WaygateNetworkCommandScript : public CommandScript
{
public:
WaygateNetworkCommandScript() : CommandScript("WaygateNetworkCommandScript") { }
ChatCommandTable GetCommands() const override
{
static ChatCommandTable waygateCommandTable =
{
{ "reload", HandleWaygateReloadCommand, SEC_GAMEMASTER, Console::Yes },
{ "", HandleWaygateCommand, SEC_PLAYER, Console::No },
};
static ChatCommandTable commandTable =
{
{ "waygate", waygateCommandTable },
};
return commandTable;
}
static bool HandleWaygateReloadCommand(ChatHandler* handler)
{
LoadWaygateDestinations();
handler->PSendSysMessage("Waygate destinations reloaded ({} loaded).", WaygateDestinationList.size());
return true;
}
static bool HandleWaygateCommand(ChatHandler* handler)
{
Player* player = handler->GetPlayer();
if (!player || IsPlayerbot(player))
return true;
if (!sConfigMgr->GetOption("WaygateNetwork.Enable", true))
{
handler->PSendSysMessage("The Waygate network is currently disabled.");
return true;
}
// The intro quest chain ("The Ley Wardens") is a hard prerequisite for real use -- GM debug
// mode is the only override, matching every other bypass in this module. Passive attunement
// (OnPlayerUpdateZone) is untouched by this check, so finishing the chain doesn't leave a
// character staring at an empty menu.
if (sConfigMgr->GetOption("WaygateNetwork.RequireIntroQuest", true)
&& !IsWaygateDebugFor(player) && !player->GetQuestRewardStatus(QUEST_WAYGATE_REACTIVATION))
{
handler->PSendSysMessage("The waygate network lies dormant. Seek out Aemos in the Hillsbrad Foothills.");
return true;
}
Creature* portal = SummonWaygatePortal(player);
if (!portal)
{
handler->PSendSysMessage("The Waygate network is unavailable right now.");
return true;
}
ShowWaygateContinentMenu(player, portal);
return true;
}
};
class npc_waygate_portal : public CreatureScript
{
public:
npc_waygate_portal() : CreatureScript("npc_waygate_portal") { }
bool OnGossipHello(Player* player, Creature* creature) override
{
ShowWaygateContinentMenu(player, creature);
return true;
}
bool OnGossipSelect(Player* player, Creature* creature, uint32 sender, uint32 action) override
{
if (sender == GOSSIP_SENDER_WAYGATE_CONTINENT)
{
if (std::string const* continent = GetContinentByAction(action))
ShowWaygateDestinationMenu(player, creature, *continent);
else
{
CloseGossipMenuFor(player);
creature->DespawnOrUnsummon();
}
return true;
}
if (sender != GOSSIP_SENDER_WAYGATE_DESTINATION)
return false;
if (action == GOSSIP_ACTION_BACK)
{
ShowWaygateContinentMenu(player, creature);
return true;
}
CloseGossipMenuFor(player);
if (action && sConfigMgr->GetOption("WaygateNetwork.Enable", true))
{
if (WaygateDestination const* dest = GetWaygateDestinationById(action))
{
if (IsWaygateDebugFor(player))
{
// Debug mode stays instant and free -- it's for reviewing/testing locations
// quickly, not for simulating the real player experience. No attunement,
// combat, or cost checks; no cast.
player->TeleportTo(dest->mapId, dest->x, dest->y, dest->z, dest->o);
}
else
{
BeginWaygateChannel(player, *dest);
}
}
}
creature->DespawnOrUnsummon();
return true;
}
};
// One of these is spawned permanently at every Waygate destination (entry = WAYGATE_STONE_ENTRY_BASE
// + destination id, see data/sql/db-world/updates/2026_08_15_11.sql), replacing the earlier
// walk-into-the-zone auto-attunement -- see "Waygate Stones" in README.md for why. A single shared
// ScriptName across all 74 spawns; which destination a given stone represents is resolved from its
// own template entry, not its position or spawn guid (real gameobject.guid values in this client
// already run into the millions, too real a collision risk to use as a deterministic lookup key).
// Attuning requires the Ley Wardens chain to be complete, same gate as `.waygate` itself -- before
// that, interacting shows in-world flavor text (WAYGATE_STONE_LOCKED_GOSSIP_TEXT) instead.
class go_waygate_standing_stone : public GameObjectScript
{
public:
go_waygate_standing_stone() : GameObjectScript("go_waygate_standing_stone") { }
bool OnGossipHello(Player* player, GameObject* go) override
{
if (!sConfigMgr->GetOption("WaygateNetwork.Enable", true) || IsPlayerbot(player))
return true;
WaygateDestination const* dest = GetWaygateDestinationById(go->GetEntry() - WAYGATE_STONE_ENTRY_BASE);
if (!dest)
return true;
// The stones themselves are gated the same way `.waygate` is -- reactivating the network is
// what the Ley Wardens chain is *for*, so a stone shouldn't do anything until that's done
// (GM debug mode is the only bypass, matching every other gate in this module). Rather than a
// plain chat line, this opens a real gossip popup with in-world flavor text -- deliberately
// vague about *who* to seek out (the `.waygate` command's own message already names Aemos
// directly; this is often the first hint a character gets, before they've ever typed the
// command, so it stays a level more mysterious).
if (sConfigMgr->GetOption("WaygateNetwork.RequireIntroQuest", true)
&& !IsWaygateDebugFor(player) && !player->GetQuestRewardStatus(QUEST_WAYGATE_REACTIVATION))
{
ClearGossipMenuFor(player);
SendGossipMenuFor(player, WAYGATE_STONE_LOCKED_GOSSIP_TEXT, go->GetGUID());
return true;
}
AttuneWaygateDestination(player, *dest);
return true;
}
};
// Debounce state for the zone-entry hint below, held on Player::CustomData (the standard
// AzerothCore idiom for a module to attach per-player transient state without touching core
// headers) rather than anything persisted -- this only needs to survive the current session.
struct WaygateHintState : public DataMap::Base
{
uint32 lastHintedDestinationId = 0;
};
class WaygateNetwork_Player : public PlayerScript
{
public:
WaygateNetwork_Player() : PlayerScript("WaygateNetwork_Player") { }
// No longer attunes on its own -- entering the zone just hints that a Waygate Stone is
// somewhere nearby, so there's something to go on besides a blind search. Actual attunement
// only happens by interacting with the stone itself (go_waygate_standing_stone::OnGossipHello).
//
// Two guards keep this from spamming: skipped entirely while on a taxi (IsInFlight()) -- a
// single flight can cross several un-attuned destination zones back to back, which is what was
// actually producing the "3 messages in a row" reports, not a single zone re-triggering -- and
// deduped against the last destination actually hinted, so re-entering the *same* zone (zone
// line flicker while walking, or the hook firing more than once for one real entry) doesn't
// repeat the message. Still repeats across *different* un-attuned zones on foot, which is the
// intended nudge-not-once-only behavior.
void OnPlayerUpdateZone(Player* player, uint32 newZone, uint32 /*newArea*/) override
{
if (!sConfigMgr->GetOption("WaygateNetwork.Enable", true) || IsPlayerbot(player) || player->IsInFlight())
return;
WaygateDestination const* dest = FindWaygateDestinationByZone(newZone);
if (!dest)
return;
WaygateHintState* hintState = player->CustomData.GetDefault("WaygateNetworkHint");
if (hintState->lastHintedDestinationId == dest->id)
return;
QueryResult result = CharacterDatabase.Query(
"SELECT 1 FROM mod_waygate_network_discovered WHERE guid = {} AND waygate_id = {}",
player->GetGUID().GetCounter(), dest->id);
if (result)
return;
hintState->lastHintedDestinationId = dest->id;
ChatHandler(player->GetSession()).PSendSysMessage("You sense a waygate stone somewhere nearby...");
}
void OnPlayerDeleteFromDB(CharacterDatabaseTransaction trans, uint32 guid) override
{
// Otherwise a deleted character's guid can be recycled and the new character on that guid
// would inherit stale attunements it never earned.
trans->Append("DELETE FROM mod_waygate_network_discovered WHERE guid = {}", guid);
}
};
class WaygateNetwork_World : public WorldScript
{
public:
WaygateNetwork_World() : WorldScript("WaygateNetwork_World") { }
void OnLoadCustomDatabaseTable() override
{
LoadWaygateDestinations();
}
};
void AddSC_mod_waygate_network()
{
new WaygateNetwork_Player();
new WaygateNetworkCommandScript();
new npc_waygate_portal();
new go_waygate_standing_stone();
new WaygateNetwork_World();
}