mod-gold-rush/src/GoldRush.cpp
Claude fc282fda54 fix: null-session crash when broadcasting event start/end messages
StartEvent() and EndEvent() both used ChatHandler(nullptr).SendWorldText()
to announce Gold Rush events server-wide. ChatHandler::SendWorldText()
(core Chat.cpp) unconditionally calls GetSession()->SendPacket() on the
handler's session -- with a null session, that's an immediate null-pointer
dereference. This crashed the live server twice within ~24 hours
(confirmed via a symbolized gdb backtrace against a captured coredump):

  WorldSession::GetPlayer (this=0x0)
  WorldSession::SendPacket (this=0x0, ...)
  ChatHandler::SendWorldText (...)
  GoldRushManager::StartEvent (...) at GoldRush.cpp:880

ChatHandler is meant to wrap a real player/GM session; it was only being
constructed here to reuse SendWorldText's string formatting, with no
actual session behind it.

Fix: use sWorldSessionMgr->SendServerMessage(SERVER_MSG_STRING, text)
instead -- the core-provided, session-free broadcast path (the same one
the built-in .announce GM command uses in cs_message.cpp). No session
required, so no crash regardless of whether the event is triggered
automatically or by a player.

Fixed all three call sites using this broken pattern: the event-start
announcement (two locations -- StartEvent has what looks like an earlier
duplicate/preview announcement plus the main one) and the event-end
announcement in EndEvent(). The EndEvent() site had not crashed yet but
is the exact same bug and would have crashed the first time any event
completed naturally.
2026-09-04 13:43:49 +00:00

1290 lines
44 KiB
C++

#include "Common.h"
#include "Chat.h"
#include "Config.h"
#include "Containers.h"
#include "DBCStores.h"
#include "GameObject.h"
#include "Log.h"
#include "WorldSessionMgr.h"
#include "Map.h"
#include "MapMgr.h"
#include "ObjectAccessor.h"
#include "Tokenize.h"
#include "PlayerbotAI.h"
#include "RandomPlayerbotMgr.h"
#include "Playerbots.h"
#include "ScriptMgr.h"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <limits>
#include <random>
#include <set>
#include <sstream>
#include <string>
#include <string_view>
#include <unordered_set>
#include <vector>
using namespace Acore::ChatCommands;
namespace
{
static char const* const GoldRushLogFilter = "module.goldrush";
// Node placement tuning.
static constexpr uint8 MaxPlacementAttempts = 8; // candidate spots tried per node
static constexpr float HeightProbeUp = 5.0f; // start the ground probe above the anchor
static constexpr float HeightSearchDist = 50.0f; // how far down the probe may search
static constexpr float MaxVerticalDrift = 20.0f; // reject ground this far off the anchor's
// Mirrors INVALID_HEIGHT from GridTerrainData.h without taking a dependency on that header.
static constexpr float InvalidHeightSentinel = -99999.0f;
struct GoldRushSite
{
std::string ZoneLabel;
std::string AreaLabel;
uint32 ZoneId = 0;
uint32 AreaId = 0;
uint32 MapId = 0;
};
struct SpawnedNode
{
ObjectGuid Guid;
uint32 Entry = 0;
};
struct GoldRushTelemetry
{
uint32 EventsStarted = 0;
uint32 EventsCompleted = 0;
uint32 NodesSpawned = 0;
uint32 NodesRemoved = 0;
uint32 AllianceBotsRouted = 0;
uint32 HordeBotsRouted = 0;
uint32 LastZoneId = 0;
uint32 LastAreaId = 0;
std::string LastLocation;
};
class GoldRushConfig
{
public:
bool Enabled = true;
bool VerboseLogging = false;
uint32 MinIntervalMs = 120 * MINUTE * IN_MILLISECONDS;
uint32 MaxIntervalMs = 240 * MINUTE * IN_MILLISECONDS;
uint32 DurationMs = 20 * MINUTE * IN_MILLISECONDS;
uint32 MinNodeCount = 15;
uint32 MaxNodeCount = 25;
uint32 BotsPerFaction = 6;
uint32 BotPulseMs = 30 * IN_MILLISECONDS;
uint32 RetryMs = 5 * MINUTE * IN_MILLISECONDS;
uint32 GraceMs = 15 * MINUTE * IN_MILLISECONDS;
uint32 MinPlayersOnline = 1;
float SpawnRadius = 25.0f;
std::string NodeEntries;
std::string ZonePool;
std::string Blacklist;
std::string StartMessage = "A seismic anomaly has exposed a massive vein of rich minerals in {}!";
std::string EndMessage = "The Gold Rush in {} has been exhausted.";
bool Debug = true;
};
static std::string Trim(std::string value)
{
auto first = value.find_first_not_of(" \t\r\n");
if (first == std::string::npos)
return {};
auto last = value.find_last_not_of(" \t\r\n");
return value.substr(first, last - first + 1);
}
static std::string Normalize(std::string_view value)
{
std::string normalized(value);
normalized = Trim(normalized);
std::transform(normalized.begin(), normalized.end(), normalized.begin(), [](unsigned char c)
{
return std::tolower(c);
});
return normalized;
}
static bool ContainsWord(std::string const& text, std::string_view needle)
{
return Normalize(text).find(Normalize(needle)) != std::string::npos;
}
static AreaTableEntry const* ResolveAreaEntry(std::string const& areaName)
{
if (areaName.empty())
return nullptr;
std::string needle = Normalize(areaName);
LocaleConstant locale = sWorld->GetDefaultDbcLocale();
for (uint32 i = 0; i < sAreaTableStore.GetNumRows(); ++i)
{
AreaTableEntry const* area = sAreaTableStore.LookupEntry(i);
if (!area)
continue;
if (Normalize(area->area_name[locale]) == needle)
return area;
for (uint32 localeIndex = 0; localeIndex < TOTAL_LOCALES; ++localeIndex)
{
if (localeIndex == locale)
continue;
if (Normalize(area->area_name[localeIndex]) == needle)
return area;
}
}
return nullptr;
}
class GoldRushManager
{
public:
void LoadConfig()
{
// A config reload (or `.reload config`) used to wipe the tracked node list while an
// event was live, orphaning every temporary gameobject in the world with no owner
// left to despawn it. Tear the running event down first, then rebuild state.
if (_active)
{
LOG_INFO(GoldRushLogFilter, "Gold Rush config reload during an active event; despawning {} temporary node(s) and releasing routed bots first.",
_spawnedNodes.size());
DespawnHotspotNodes();
ClearBotRouting();
}
_config.Enabled = sConfigMgr->GetOption<bool>("GoldRush.Enable", true);
_config.VerboseLogging = sConfigMgr->GetOption<bool>("GoldRush.VerboseLogging", false);
_config.MinIntervalMs = sConfigMgr->GetOption<uint32>("GoldRush.MinIntervalMinutes", 120) * MINUTE * IN_MILLISECONDS;
_config.MaxIntervalMs = sConfigMgr->GetOption<uint32>("GoldRush.MaxIntervalMinutes", 240) * MINUTE * IN_MILLISECONDS;
_config.DurationMs = sConfigMgr->GetOption<uint32>("GoldRush.DurationMinutes", 20) * MINUTE * IN_MILLISECONDS;
_config.MinNodeCount = sConfigMgr->GetOption<uint32>("GoldRush.MinNodes", 15);
_config.MaxNodeCount = sConfigMgr->GetOption<uint32>("GoldRush.MaxNodes", 25);
_config.BotsPerFaction = sConfigMgr->GetOption<uint32>("GoldRush.BotsPerFaction", 6);
_config.BotPulseMs = sConfigMgr->GetOption<uint32>("GoldRush.BotPulseSeconds", 30) * IN_MILLISECONDS;
_config.RetryMs = std::max<uint32>(1, sConfigMgr->GetOption<uint32>("GoldRush.RetryMinutes", 5)) * MINUTE * IN_MILLISECONDS;
_config.GraceMs = sConfigMgr->GetOption<uint32>("GoldRush.GraceMinutes", 15) * MINUTE * IN_MILLISECONDS;
_config.MinPlayersOnline = std::max<uint32>(1, sConfigMgr->GetOption<uint32>("GoldRush.MinPlayersOnline", 1));
_config.SpawnRadius = sConfigMgr->GetOption<float>("GoldRush.SpawnRadiusYards", 25.0f);
_config.NodeEntries = sConfigMgr->GetOption<std::string>("GoldRush.NodeEntries", "191133;190176;190171;190172;189973");
_config.ZonePool = sConfigMgr->GetOption<std::string>("GoldRush.ZonePool", "Un'Goro Crater|Fire Plume Ridge; Winterspring|Frostfire Hot Springs; Eastern Plaguelands|Terrorweb Tunnel; Sholazar Basin|River's Heart");
_config.Blacklist = sConfigMgr->GetOption<std::string>("GoldRush.Blacklist", "Stormwind City; Orgrimmar; Dalaran");
_config.StartMessage = sConfigMgr->GetOption<std::string>("GoldRush.StartMessage", "A seismic anomaly has exposed a massive vein of rich minerals in {}!");
_config.EndMessage = sConfigMgr->GetOption<std::string>("GoldRush.EndMessage", "The Gold Rush in {} has been exhausted.");
_config.Debug = sConfigMgr->GetOption<bool>("GoldRush.Debug", true);
_nodeEntries = BuildNodeEntries(_config.NodeEntries);
_blockedZones = BuildBlockedZones(_config.Blacklist);
_sites.clear();
_sitesInitialized = false;
_active = false;
_currentSite = {};
_hotspot = {};
_spawnedNodes.clear();
_routedBotIds.clear();
_telemetry = {};
_worldWasEmpty = false;
_timeUntilNextEventMs = 0;
_eventTimeRemainingMs = 0;
_nextBotPulseMs = _config.BotPulseMs;
if (!_config.Enabled)
{
LOG_INFO(GoldRushLogFilter, "Gold Rush module disabled by configuration.");
return;
}
if (_nodeEntries.empty())
{
LOG_WARN(GoldRushLogFilter, "Gold Rush loaded but no valid node template entries were configured.");
return;
}
if (_config.VerboseLogging)
LOG_INFO(GoldRushLogFilter, "Gold Rush verbose logging enabled.");
}
void Update(uint32 diff)
{
if (!_config.Enabled || _nodeEntries.empty())
return;
if (!EnsureSitesInitialized())
return;
if (_active)
{
if (_eventTimeRemainingMs <= diff)
{
EndEvent();
return;
}
_eventTimeRemainingMs -= diff;
if (_nextBotPulseMs <= diff)
{
MaintainBotPressure();
_nextBotPulseMs = _config.BotPulseMs;
}
else
{
_nextBotPulseMs -= diff;
}
return;
}
if (_timeUntilNextEventMs <= diff)
{
StartEvent();
return;
}
_timeUntilNextEventMs -= diff;
}
bool ForceStart(Player* anchor, std::string const& locationFilter = {})
{
if (!_config.Enabled || _nodeEntries.empty())
return false;
if (!EnsureSitesInitialized())
return false;
GoldRushSite site = locationFilter.empty() ? BuildEligibleSiteFromPlayer(anchor) : ResolveSiteByName(locationFilter);
if (!site.ZoneId && anchor)
site = BuildEligibleSiteFromPlayer(anchor);
if (!site.ZoneId || !anchor || !anchor->IsInWorld() || !anchor->GetMap())
return false;
return StartEvent(anchor, site, false);
}
bool ForceTestStart(Player* anchor)
{
if (!_config.Enabled || _nodeEntries.empty())
{
LOG_WARN(GoldRushLogFilter, "Gold Rush test start rejected: module disabled or no node templates configured.");
return false;
}
if (!EnsureSitesInitialized())
return false;
if (!anchor || !anchor->IsInWorld() || !anchor->GetMap())
anchor = FindAnyWorldPlayer();
if (!anchor || !anchor->IsInWorld() || !anchor->GetMap())
{
LOG_WARN(GoldRushLogFilter, "Gold Rush test start rejected: no valid world player anchor was available.");
return false;
}
GoldRushSite site = BuildEligibleSiteFromPlayer(anchor);
if (!site.ZoneId)
{
LOG_WARN(GoldRushLogFilter, "Gold Rush test start rejected: {} is not in an eligible world zone.",
anchor->GetName());
return false;
}
_currentSite = site;
_hotspot = WorldPosition(anchor->GetMapId(), anchor->GetPositionX(), anchor->GetPositionY(), anchor->GetPositionZ(), anchor->GetOrientation());
bool spawnedNodes = SpawnHotspot(anchor);
_active = true;
_eventTimeRemainingMs = _config.DurationMs;
_nextBotPulseMs = 0;
++_telemetry.EventsStarted;
_telemetry.LastZoneId = _currentSite.ZoneId;
_telemetry.LastAreaId = _currentSite.AreaId;
_telemetry.LastLocation = BuildLocationText(_currentSite);
if (_config.VerboseLogging)
{
LOG_INFO(GoldRushLogFilter, "Gold Rush test start anchored at map {} zone {} area {} by {}.",
_currentSite.MapId, _currentSite.ZoneId, _currentSite.AreaId, anchor->GetName());
}
sWorldSessionMgr->SendServerMessage(SERVER_MSG_STRING, Acore::StringFormat(_config.StartMessage, FormatLocationForAnnouncement(_currentSite, anchor)));
if (_config.Debug)
{
LOG_INFO(GoldRushLogFilter, "Gold Rush test start triggered in {} for {} minute(s) with {} temporary node(s).",
_telemetry.LastLocation,
_config.DurationMs / (MINUTE * IN_MILLISECONDS),
_spawnedNodes.size());
}
if (!spawnedNodes && _config.VerboseLogging)
LOG_INFO(GoldRushLogFilter, "Gold Rush test start continued without spawned nodes; hotspot validation only.");
RouteBots();
MaintainBotPressure();
return true;
}
Player* FindAnyWorldPlayer() const
{
std::shared_lock lock(*HashMapHolder<Player>::GetLock());
for (auto const& [guid, player] : ObjectAccessor::GetPlayers())
{
if (player && player->IsInWorld() && player->GetMap())
return player;
}
return nullptr;
}
bool ForceStop()
{
if (!_active)
return false;
EndEvent(true);
return true;
}
std::string GetStatusText() const
{
std::ostringstream out;
out << "Gold Rush: ";
out << (_active ? "ACTIVE" : "IDLE");
if (_active)
{
out << " | Location: " << _telemetry.LastLocation;
out << " | Remaining: " << (_eventTimeRemainingMs / IN_MILLISECONDS) << "s";
out << " | Nodes: " << _spawnedNodes.size();
out << " | Routed A/H: " << _telemetry.AllianceBotsRouted << "/" << _telemetry.HordeBotsRouted;
}
out << " | Events started/completed: " << _telemetry.EventsStarted << "/" << _telemetry.EventsCompleted;
out << " | Nodes spawned/removed: " << _telemetry.NodesSpawned << "/" << _telemetry.NodesRemoved;
return out.str();
}
void RefreshBotPressure()
{
MaintainBotPressure();
}
private:
bool EnsureSitesInitialized()
{
if (_sitesInitialized)
return true;
_sites = BuildSites(_config.ZonePool);
_sitesInitialized = true;
_timeUntilNextEventMs = RollIntervalMs();
if (_sites.empty())
{
LOG_WARN(GoldRushLogFilter,
"Gold Rush loaded without configured hotspot zones; falling back to eligible live-player zones.");
return true;
}
LOG_INFO(GoldRushLogFilter, "Gold Rush loaded with {} hotspot(s), {} node template(s), interval {}-{} minute(s), duration {} minute(s).",
_sites.size(),
_nodeEntries.size(),
_config.MinIntervalMs / (MINUTE * IN_MILLISECONDS),
_config.MaxIntervalMs / (MINUTE * IN_MILLISECONDS),
_config.DurationMs / (MINUTE * IN_MILLISECONDS));
return true;
}
static std::unordered_set<std::string> BuildBlockedZones(std::string const& blacklist)
{
std::unordered_set<std::string> blocked;
for (std::string_view blockedZone : Acore::Tokenize(blacklist, ';', true))
{
std::string normalized = Normalize(blockedZone);
if (!normalized.empty())
blocked.insert(std::move(normalized));
}
return blocked;
}
static bool IsValidZoneArea(AreaTableEntry const* zoneArea)
{
if (!zoneArea)
return false;
if (zoneArea->IsSanctuary())
return false;
uint32 flags = zoneArea->flags;
return (flags & (AREA_FLAG_CAPITAL | AREA_FLAG_CITY | AREA_FLAG_SLAVE_CAPITAL)) == 0;
}
bool IsBlockedSite(GoldRushSite const& site) const
{
std::string zoneKey = Normalize(site.ZoneLabel.empty() ? GetAreaLabelById(site.ZoneId) : site.ZoneLabel);
std::string areaKey = Normalize(site.AreaLabel);
std::string fullKey = areaKey.empty() ? zoneKey : Acore::StringFormat("{} near {}", zoneKey, areaKey);
return _blockedZones.find(zoneKey) != _blockedZones.end() ||
(!areaKey.empty() && _blockedZones.find(fullKey) != _blockedZones.end());
}
bool IsEligibleAnchor(Player const* player) const
{
if (!player || !player->IsInWorld() || !player->GetMap())
return false;
MapEntry const* mapEntry = player->GetMap()->GetEntry();
if (!mapEntry || !mapEntry->IsWorldMap())
return false;
GoldRushSite site = BuildSiteFromAnchor(player);
if (!site.ZoneId)
return false;
AreaTableEntry const* zoneArea = sAreaTableStore.LookupEntry(site.ZoneId);
if (!IsValidZoneArea(zoneArea))
return false;
return !IsBlockedSite(site);
}
GoldRushSite BuildEligibleSiteFromPlayer(Player const* player) const
{
if (!IsEligibleAnchor(player))
return {};
return BuildSiteFromAnchor(player);
}
std::vector<GoldRushSite> BuildSites(std::string const& zones) const
{
std::vector<GoldRushSite> sites;
for (std::string_view zoneToken : Acore::Tokenize(zones, ';', true))
{
std::string token = Trim(std::string(zoneToken));
if (token.empty())
continue;
std::vector<std::string_view> parts = Acore::Tokenize(token, '|', true);
GoldRushSite site;
site.ZoneLabel = parts.empty() ? token : Trim(std::string(parts[0]));
if (parts.size() > 1)
site.AreaLabel = Trim(std::string(parts[1]));
if (site.ZoneLabel.empty())
continue;
AreaTableEntry const* zoneArea = ResolveAreaEntry(site.ZoneLabel);
AreaTableEntry const* areaEntry = site.AreaLabel.empty() ? zoneArea : ResolveAreaEntry(site.AreaLabel);
if (!zoneArea)
continue;
AreaTableEntry const* rootZone = zoneArea->zone ? sAreaTableStore.LookupEntry(zoneArea->zone) : zoneArea;
if (!rootZone)
continue;
if (!site.AreaLabel.empty() && !areaEntry)
continue;
site.ZoneId = rootZone->ID;
site.AreaId = !site.AreaLabel.empty() && areaEntry ? areaEntry->ID : 0;
site.MapId = rootZone->mapid;
if (!IsValidZoneArea(rootZone) || IsBlockedSite(site))
continue;
sites.push_back(std::move(site));
}
return sites;
}
static std::vector<uint32> BuildNodeEntries(std::string const& entries)
{
std::vector<uint32> nodeEntries;
for (std::string_view token : Acore::Tokenize(entries, ';', true))
{
std::string trimmed = Trim(std::string(token));
if (trimmed.empty())
continue;
if (Optional<uint32> entry = Acore::StringTo<uint32>(trimmed); entry && *entry)
nodeEntries.push_back(*entry);
}
return nodeEntries;
}
void LogVerbose(std::string const& message) const
{
if (_config.VerboseLogging)
LOG_INFO(GoldRushLogFilter, "{}", message);
}
static std::string BuildLocationText(GoldRushSite const& site)
{
if (site.AreaLabel.empty())
return site.ZoneLabel;
return Acore::StringFormat("{} near {}", site.ZoneLabel, site.AreaLabel);
}
static std::string FormatLocationForAnnouncement(GoldRushSite const& site, Player const* anchor)
{
if (anchor && anchor->GetZoneId() == site.ZoneId)
{
if (!site.AreaLabel.empty())
return Acore::StringFormat("{} near {}", site.ZoneLabel, site.AreaLabel);
return site.ZoneLabel;
}
return BuildLocationText(site);
}
uint32 RollIntervalMs() const
{
uint32 minMs = std::min(_config.MinIntervalMs, _config.MaxIntervalMs);
uint32 maxMs = std::max(_config.MinIntervalMs, _config.MaxIntervalMs);
if (maxMs <= minMs)
return minMs;
return RandomValue(minMs, maxMs);
}
static uint32 RandomValue(uint32 minValue, uint32 maxValue)
{
static std::mt19937 rng{ std::random_device{}() };
std::uniform_int_distribution<uint32> distribution(minValue, maxValue);
return distribution(rng);
}
static float RandomFloat(float minValue, float maxValue)
{
static std::mt19937 rng{ std::random_device{}() };
std::uniform_real_distribution<float> distribution(minValue, maxValue);
return distribution(rng);
}
static bool IsHotspotNodeEntry(uint32 entry)
{
GameObjectTemplate const* goinfo = sObjectMgr->GetGameObjectTemplate(entry);
if (!goinfo)
return false;
std::string name = Normalize(goinfo->name);
return ContainsWord(name, "vein") || ContainsWord(name, "lotus") ||
ContainsWord(name, "clover") || ContainsWord(name, "thorn") ||
ContainsWord(name, "bloom");
}
static std::vector<uint32> SplitByCategory(std::vector<uint32> const& entries, bool herbs)
{
std::vector<uint32> result;
for (uint32 entry : entries)
{
if (!entry)
continue;
GameObjectTemplate const* goinfo = sObjectMgr->GetGameObjectTemplate(entry);
if (!goinfo)
continue;
std::string name = Normalize(goinfo->name);
bool isHerb = ContainsWord(name, "lotus") || ContainsWord(name, "clover") || ContainsWord(name, "thorn") || ContainsWord(name, "bloom");
bool isOre = ContainsWord(name, "vein");
if (herbs && isHerb)
result.push_back(entry);
else if (!herbs && isOre)
result.push_back(entry);
}
return result;
}
GoldRushSite ResolveSiteByName(std::string const& name) const
{
std::string needle = Normalize(name);
for (GoldRushSite const& site : _sites)
{
if (Normalize(site.ZoneLabel) == needle || Normalize(site.AreaLabel) == needle ||
Normalize(BuildLocationText(site)) == needle)
{
return site;
}
}
return {};
}
static GoldRushSite BuildSiteFromAnchor(Player const* anchor)
{
GoldRushSite site;
if (!anchor)
return site;
site.ZoneId = anchor->GetZoneId();
site.AreaId = anchor->GetAreaId();
site.MapId = anchor->GetMapId();
site.ZoneLabel = GetAreaLabelById(site.ZoneId);
site.AreaLabel = site.AreaId ? GetAreaLabelById(site.AreaId) : std::string();
return site;
}
static std::string GetAreaLabelById(uint32 areaId)
{
if (!areaId)
return {};
LocaleConstant locale = sWorld->GetDefaultDbcLocale();
if (AreaTableEntry const* area = sAreaTableStore.LookupEntry(areaId))
return area->area_name[locale];
return {};
}
GoldRushSite SelectSiteForPlayer(Player const* player) const
{
GoldRushSite playerSite = BuildEligibleSiteFromPlayer(player);
if (playerSite.ZoneId)
return playerSite;
if (!_sites.empty())
return Acore::Containers::SelectRandomContainerElement(_sites);
if (Player* fallback = FindAnyEligibleWorldPlayer())
return BuildEligibleSiteFromPlayer(fallback);
return {};
}
Player* FindAnchorPlayer(GoldRushSite const& site) const
{
std::shared_lock lock(*HashMapHolder<Player>::GetLock());
Player* zoneFallback = nullptr;
for (auto const& [guid, player] : ObjectAccessor::GetPlayers())
{
if (!IsEligibleAnchor(player))
continue;
if (site.MapId && player->GetMapId() != site.MapId)
continue;
if (site.ZoneId && player->GetZoneId() != site.ZoneId)
continue;
if (site.AreaId)
{
if (player->GetAreaId() == site.AreaId)
return player;
if (!zoneFallback)
zoneFallback = player;
continue;
}
return player;
}
return zoneFallback;
}
Player* FindAnyEligibleWorldPlayer() const
{
std::shared_lock lock(*HashMapHolder<Player>::GetLock());
std::vector<Player*> candidates;
for (auto const& [guid, player] : ObjectAccessor::GetPlayers())
{
if (IsEligibleAnchor(player))
candidates.push_back(player);
}
if (candidates.empty())
return nullptr;
return Acore::Containers::SelectRandomContainerElement(candidates);
}
// Counts players (bots included) standing somewhere an event could legally anchor.
// Only called when the schedule expires, never per world tick.
uint32 CountEligibleAnchors() const
{
uint32 count = 0;
std::shared_lock lock(*HashMapHolder<Player>::GetLock());
for (auto const& [guid, player] : ObjectAccessor::GetPlayers())
{
if (IsEligibleAnchor(player))
++count;
}
return count;
}
bool StartEvent(Player* anchorOverride = nullptr, GoldRushSite siteOverride = {}, bool scheduled = true)
{
if (_nodeEntries.empty())
return false;
// On an empty (or near-empty) server the scheduler used to burn its rolled
// interval down to a flat retry and hold there, so the first person to log in
// got an event dropped on their head within one retry window. Hold the schedule
// while nobody is around, then give the world a grace period once it repopulates.
// GM-forced starts skip all of this.
if (scheduled)
{
uint32 const eligible = CountEligibleAnchors();
if (eligible < _config.MinPlayersOnline)
{
if (!_worldWasEmpty)
{
_worldWasEmpty = true;
if (_config.Debug)
{
LOG_INFO(GoldRushLogFilter, "Gold Rush is holding: {} eligible anchor(s) online, {} required. Rechecking every {} minute(s).",
eligible, _config.MinPlayersOnline, _config.RetryMs / (MINUTE * IN_MILLISECONDS));
}
}
else
{
LogVerbose("Gold Rush still holding; no eligible anchors online.");
}
_timeUntilNextEventMs = _config.RetryMs;
return false;
}
if (_worldWasEmpty)
{
_worldWasEmpty = false;
_timeUntilNextEventMs = _config.GraceMs ? _config.GraceMs : RollIntervalMs();
if (_config.Debug)
{
LOG_INFO(GoldRushLogFilter, "Gold Rush sees {} eligible anchor(s) after an idle period; waiting {} minute(s) before the next event.",
eligible, _timeUntilNextEventMs / (MINUTE * IN_MILLISECONDS));
}
return false;
}
}
if (!siteOverride.ZoneId)
siteOverride = anchorOverride ? BuildEligibleSiteFromPlayer(anchorOverride) : SelectSiteForPlayer(anchorOverride);
if (!siteOverride.ZoneId)
siteOverride = SelectSiteForPlayer(anchorOverride);
if (!siteOverride.ZoneId)
{
if (Player* dynamicAnchor = FindAnyEligibleWorldPlayer())
{
anchorOverride = dynamicAnchor;
siteOverride = BuildEligibleSiteFromPlayer(dynamicAnchor);
}
}
if (!siteOverride.ZoneId)
{
_worldWasEmpty = true;
_timeUntilNextEventMs = _config.RetryMs;
if (_config.Debug)
LOG_INFO(GoldRushLogFilter, "Gold Rush could not find any eligible world zone with a live player anchor; retrying in {} minute(s).",
_config.RetryMs / (MINUTE * IN_MILLISECONDS));
return false;
}
_currentSite = siteOverride;
Player* anchor = anchorOverride && IsEligibleAnchor(anchorOverride) ? anchorOverride : FindAnchorPlayer(_currentSite);
if (!anchor || !anchor->GetMap())
{
anchor = FindAnyEligibleWorldPlayer();
if (!anchor || !anchor->GetMap())
{
_worldWasEmpty = true;
_timeUntilNextEventMs = _config.RetryMs;
if (_config.Debug)
LOG_INFO(GoldRushLogFilter, "Gold Rush hotspot in {} could not find an anchor player; retrying in {} minute(s).",
BuildLocationText(_currentSite), _config.RetryMs / (MINUTE * IN_MILLISECONDS));
return false;
}
}
GoldRushSite anchorSite = BuildEligibleSiteFromPlayer(anchor);
if (!anchorSite.ZoneId)
{
_timeUntilNextEventMs = _config.RetryMs;
if (_config.Debug)
LOG_INFO(GoldRushLogFilter, "Gold Rush rejected anchor {} because the zone is no longer eligible; retrying in {} minute(s).",
anchor->GetName(), _config.RetryMs / (MINUTE * IN_MILLISECONDS));
return false;
}
_currentSite = anchorSite;
_hotspot = WorldPosition(anchor->GetMapId(), anchor->GetPositionX(), anchor->GetPositionY(), anchor->GetPositionZ(), anchor->GetOrientation());
if (!SpawnHotspot(anchor))
{
_timeUntilNextEventMs = _config.RetryMs;
if (_config.Debug)
LOG_INFO(GoldRushLogFilter, "Gold Rush hotspot in {} could not spawn nodes; retrying in {} minute(s).",
BuildLocationText(_currentSite), _config.RetryMs / (MINUTE * IN_MILLISECONDS));
return false;
}
_active = true;
_eventTimeRemainingMs = _config.DurationMs;
_nextBotPulseMs = 0;
++_telemetry.EventsStarted;
_telemetry.LastZoneId = _currentSite.ZoneId;
_telemetry.LastAreaId = _currentSite.AreaId;
_telemetry.LastLocation = BuildLocationText(_currentSite);
if (_config.VerboseLogging)
{
LOG_INFO(GoldRushLogFilter, "Gold Rush event anchored at map {} zone {} area {} by {}.",
_currentSite.MapId, _currentSite.ZoneId, _currentSite.AreaId, anchor->GetName());
}
std::string announcementLocation = FormatLocationForAnnouncement(_currentSite, anchor);
sWorldSessionMgr->SendServerMessage(SERVER_MSG_STRING, Acore::StringFormat(_config.StartMessage, announcementLocation));
if (_config.Debug)
{
LOG_INFO(GoldRushLogFilter, "Gold Rush started in {} for {} minute(s) with {} temporary node(s).",
_telemetry.LastLocation,
_config.DurationMs / (MINUTE * IN_MILLISECONDS),
_spawnedNodes.size());
}
RouteBots();
MaintainBotPressure();
return true;
}
void EndEvent(bool forced = false)
{
if (!_active)
return;
std::string location = BuildLocationText(_currentSite);
sWorldSessionMgr->SendServerMessage(SERVER_MSG_STRING, Acore::StringFormat(_config.EndMessage, location));
if (_config.Debug)
LOG_INFO(GoldRushLogFilter, "Gold Rush ended in {}{}.", location, forced ? " (forced)" : "");
DespawnHotspotNodes();
ClearBotRouting();
_active = false;
_currentSite = {};
_hotspot = WorldPosition();
_eventTimeRemainingMs = 0;
_timeUntilNextEventMs = RollIntervalMs();
_nextBotPulseMs = _config.BotPulseMs;
++_telemetry.EventsCompleted;
}
bool SpawnHotspot(Player* anchor)
{
if (!anchor || !anchor->GetMap())
return false;
Map* map = anchor->GetMap();
uint32 minNodes = std::min(_config.MinNodeCount, _config.MaxNodeCount);
uint32 maxNodes = std::max(_config.MinNodeCount, _config.MaxNodeCount);
uint32 nodeCount = RandomValue(minNodes, maxNodes);
nodeCount = std::max<uint32>(1, nodeCount);
std::vector<uint32> oreEntries = SplitByCategory(_nodeEntries, false);
std::vector<uint32> herbEntries = SplitByCategory(_nodeEntries, true);
std::vector<uint32> fallbackEntries = _nodeEntries;
bool useHerb = true;
if (_config.VerboseLogging)
{
LOG_INFO(GoldRushLogFilter, "Gold Rush spawning {} node(s) using {} ore entry(s), {} herb entry(s), and {} fallback entry(s).",
nodeCount, oreEntries.size(), herbEntries.size(), fallbackEntries.size());
}
// Map::SummonGameObject feeds this straight into GameObject::SetRespawnTime(int32),
// which is measured in SECONDS. Passing milliseconds made every node live ~14 days
// instead of the configured duration, so nothing ever expired on its own.
uint32 const nodeLifetimeSeconds = std::max<uint32>(1, _config.DurationMs / IN_MILLISECONDS);
constexpr float twoPi = 6.28318530717958647692f;
float const anchorZ = anchor->GetPositionZ();
for (uint32 i = 0; i < nodeCount; ++i)
{
std::vector<uint32> const* pool = nullptr;
if (useHerb && !herbEntries.empty())
pool = &herbEntries;
else if (!useHerb && !oreEntries.empty())
pool = &oreEntries;
else
pool = &fallbackEntries;
useHerb = !useHerb;
if (!pool || pool->empty())
continue;
uint32 entry = Acore::Containers::SelectRandomContainerElement(*pool);
// Try a handful of candidate spots; a node that cannot be placed on real ground
// within line of sight of the anchor is skipped rather than left floating.
for (uint8 attempt = 0; attempt < MaxPlacementAttempts; ++attempt)
{
float angle = RandomFloat(0.0f, twoPi);
// sqrt() spreads points evenly across the disc; a plain uniform radius piles
// most of them near the anchor.
float distance = _config.SpawnRadius * std::sqrt(RandomFloat(0.0f, 1.0f));
float x = anchor->GetPositionX() + (std::cos(angle) * distance);
float y = anchor->GetPositionY() + (std::sin(angle) * distance);
float z = map->GetHeight(x, y, anchorZ + HeightProbeUp, true, HeightSearchDist);
if (z <= InvalidHeightSentinel)
continue;
// Reject cliff faces, rooftops and lower floors picked up by the probe.
if (std::fabs(z - anchorZ) > MaxVerticalDrift)
continue;
if (!anchor->IsWithinLOS(x, y, z + 1.0f))
continue;
GameObject* go = map->SummonGameObject(entry, x, y, z, anchor->GetOrientation(), 0.0f, 0.0f, 0.0f, 0.0f, nodeLifetimeSeconds, true);
if (!go)
continue;
// Map::SummonGameObject already applied the respawn time and cleared
// spawned-by-default; re-doing it here is redundant.
_spawnedNodes.push_back({ go->GetGUID(), entry });
++_telemetry.NodesSpawned;
if (_config.VerboseLogging)
{
LOG_INFO(GoldRushLogFilter, "Gold Rush node spawned: entry {} at ({:.2f}, {:.2f}, {:.2f}) on map {} after {} attempt(s).",
entry, x, y, z, map->GetId(), attempt + 1);
}
break;
}
}
if (_config.VerboseLogging && _spawnedNodes.size() < nodeCount)
{
LOG_INFO(GoldRushLogFilter, "Gold Rush placed {} of {} requested node(s); the rest found no valid ground near the anchor.",
_spawnedNodes.size(), nodeCount);
}
return !_spawnedNodes.empty();
}
void DespawnHotspotNodes()
{
Map* map = sMapMgr->FindMap(_currentSite.MapId, 0);
if (!map && !_spawnedNodes.empty())
{
LOG_WARN(GoldRushLogFilter, "Gold Rush could not resolve map {} to despawn {} temporary node(s); they will expire on their own respawn timer.",
_currentSite.MapId, _spawnedNodes.size());
}
for (SpawnedNode const& node : _spawnedNodes)
{
if (!node.Guid || !map)
continue;
if (GameObject* go = map->GetGameObject(node.Guid))
{
go->DespawnOrUnsummon();
++_telemetry.NodesRemoved;
}
}
_spawnedNodes.clear();
}
void RouteBots()
{
if (!_active)
return;
std::vector<Player*> candidates = sRandomPlayerbotMgr.GetPlayers();
std::vector<Player*> alliance;
std::vector<Player*> horde;
for (Player* bot : candidates)
{
if (!bot || !bot->IsInWorld() || bot->GetLevel() < 80 || !sRandomPlayerbotMgr.IsRandomBot(bot))
continue;
if (bot->GetTeamId() == TEAM_ALLIANCE)
alliance.push_back(bot);
else
horde.push_back(bot);
}
RouteFactionBots(alliance, TEAM_ALLIANCE);
RouteFactionBots(horde, TEAM_HORDE);
if (_config.VerboseLogging)
{
LOG_INFO(GoldRushLogFilter, "Gold Rush bot routing scan complete: {} alliance candidate(s), {} horde candidate(s), {} routed bot(s) total.",
alliance.size(), horde.size(), _routedBotIds.size());
}
}
void RouteFactionBots(std::vector<Player*>& bots, TeamId team)
{
if (bots.empty())
return;
std::shuffle(bots.begin(), bots.end(), std::mt19937{ std::random_device{}() });
uint32 targetCount = std::min<uint32>(_config.BotsPerFaction, bots.size());
uint32 routedCount = 0;
for (Player* bot : bots)
{
if (routedCount >= targetCount)
break;
if (!bot || _routedBotIds.find(bot->GetGUID().GetCounter()) != _routedBotIds.end())
continue;
if (PlayerbotAI* botAI = GET_PLAYERBOT_AI(bot))
{
WorldPosition pos(_hotspot.GetMapId(), _hotspot.GetPositionX(), _hotspot.GetPositionY(), _hotspot.GetPositionZ());
botAI->rpgInfo.ChangeToGoGrind(pos);
// Engine::ChangeStrategy dispatches on the first character and ignores any
// name without a +/-/~/? prefix, so the old bare "new rpg" calls were silent
// no-ops. "new rpg" is a non-combat strategy (AiFactory only ever adds it to
// the non-combat engine), so the old BOT_STATE_COMBAT call was wrong as well.
botAI->ChangeStrategy("+new rpg", BOT_STATE_NON_COMBAT);
_routedBotIds.insert(bot->GetGUID().GetCounter());
++routedCount;
if (team == TEAM_ALLIANCE)
++_telemetry.AllianceBotsRouted;
else
++_telemetry.HordeBotsRouted;
if (_config.VerboseLogging)
{
LOG_INFO(GoldRushLogFilter, "Gold Rush routed {} bot {} toward {}.",
team == TEAM_ALLIANCE ? "Alliance" : "Horde",
bot->GetName(),
_telemetry.LastLocation);
}
}
}
}
void MaintainBotPressure()
{
if (!_active)
return;
uint32 allianceCount = 0;
uint32 hordeCount = 0;
for (ObjectGuid::LowType guid : _routedBotIds)
{
Player* bot = ObjectAccessor::FindPlayer(ObjectGuid::Create<HighGuid::Player>(guid));
if (!bot || !bot->IsInWorld())
continue;
if (bot->GetTeamId() == TEAM_ALLIANCE)
++allianceCount;
else
++hordeCount;
}
if (allianceCount < _config.BotsPerFaction || hordeCount < _config.BotsPerFaction)
{
if (_config.Debug)
LOG_INFO(GoldRushLogFilter, "Gold Rush bot pressure low: alliance {}, horde {}. Recruiting reinforcements.", allianceCount, hordeCount);
RouteBots();
}
}
void ClearBotRouting()
{
for (ObjectGuid::LowType guid : _routedBotIds)
{
Player* bot = ObjectAccessor::FindPlayer(ObjectGuid::Create<HighGuid::Player>(guid));
if (!bot)
continue;
// Only the rpg target is cleared. "new rpg" is part of the bots' default
// non-combat loadout when AiPlayerbot.EnableNewRpgStrategy is on, so removing
// it here would strip behaviour the module never granted.
if (PlayerbotAI* botAI = GET_PLAYERBOT_AI(bot))
botAI->rpgInfo.ChangeToIdle();
if (_config.VerboseLogging)
LOG_INFO(GoldRushLogFilter, "Gold Rush released routed bot {} back to idle.", bot->GetName());
}
_routedBotIds.clear();
}
std::vector<GoldRushSite> const& GetSites() const { return _sites; }
GoldRushConfig _config;
GoldRushTelemetry _telemetry;
std::unordered_set<std::string> _blockedZones;
std::vector<GoldRushSite> _sites;
std::vector<uint32> _nodeEntries;
GoldRushSite _currentSite;
WorldPosition _hotspot;
std::vector<SpawnedNode> _spawnedNodes;
std::unordered_set<ObjectGuid::LowType> _routedBotIds;
uint32 _timeUntilNextEventMs = 0;
uint32 _eventTimeRemainingMs = 0;
uint32 _nextBotPulseMs = 0;
bool _worldWasEmpty = false;
bool _sitesInitialized = false;
bool _active = false;
};
GoldRushManager g_GoldRushManager;
class GoldRushWorldScript : public WorldScript
{
public:
GoldRushWorldScript() : WorldScript("GoldRushWorldScript", { WORLDHOOK_ON_AFTER_CONFIG_LOAD, WORLDHOOK_ON_UPDATE }) { }
void OnAfterConfigLoad(bool /*reload*/) override
{
g_GoldRushManager.LoadConfig();
}
void OnUpdate(uint32 diff) override
{
g_GoldRushManager.Update(diff);
}
};
class GoldRushCommandScript : public CommandScript
{
public:
GoldRushCommandScript() : CommandScript("GoldRushCommandScript") { }
ChatCommandTable GetCommands() const override
{
static ChatCommandTable goldRushCommandTable = {
{ "status", HandleStatusCommand, SEC_GAMEMASTER, Console::Yes },
{ "start", HandleStartCommand, SEC_GAMEMASTER, Console::No },
{ "teststart", HandleTestStartCommand, SEC_GAMEMASTER, Console::No },
{ "stop", HandleStopCommand, SEC_GAMEMASTER, Console::Yes },
{ "reroute", HandleRerouteCommand, SEC_GAMEMASTER, Console::No },
};
static ChatCommandTable commandTable = {
{ "goldrush", goldRushCommandTable },
};
return commandTable;
}
static bool HandleStatusCommand(ChatHandler* handler, char const* /*args*/)
{
handler->PSendSysMessage("{}", g_GoldRushManager.GetStatusText());
return true;
}
static bool HandleStartCommand(ChatHandler* handler, char const* args)
{
Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr;
std::string location = args ? Trim(args) : std::string();
if (g_GoldRushManager.ForceStart(player, location))
{
handler->PSendSysMessage("Gold Rush started.");
return true;
}
handler->PSendSysMessage("Gold Rush could not be started. Make sure you are in an eligible world zone or name a configured hotspot.");
return false;
}
static bool HandleTestStartCommand(ChatHandler* handler, char const* /*args*/)
{
Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr;
LOG_INFO(GoldRushLogFilter, "Gold Rush teststart command received from {}.", player ? player->GetName() : "console");
if (g_GoldRushManager.ForceTestStart(player))
{
handler->PSendSysMessage("Gold Rush test start triggered at your current location.");
return true;
}
LOG_WARN(GoldRushLogFilter, "Gold Rush teststart command failed for {}.", player ? player->GetName() : "console");
handler->PSendSysMessage("Gold Rush test start could not be triggered. Make sure you are in an eligible world zone.");
return false;
}
static bool HandleStopCommand(ChatHandler* handler, char const* /*args*/)
{
if (g_GoldRushManager.ForceStop())
{
handler->PSendSysMessage("Gold Rush stopped.");
return true;
}
handler->PSendSysMessage("Gold Rush is not active.");
return false;
}
static bool HandleRerouteCommand(ChatHandler* handler, char const* /*args*/)
{
g_GoldRushManager.RefreshBotPressure();
handler->PSendSysMessage("Gold Rush bot routing refreshed.");
return true;
}
};
} // namespace
void AddSC_mod_gold_rush()
{
new GoldRushWorldScript();
new GoldRushCommandScript();
}