#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(); }