#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 #include #include #include #include #include #include #include #include #include #include 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("GoldRush.Enable", true); _config.VerboseLogging = sConfigMgr->GetOption("GoldRush.VerboseLogging", false); _config.MinIntervalMs = sConfigMgr->GetOption("GoldRush.MinIntervalMinutes", 120) * MINUTE * IN_MILLISECONDS; _config.MaxIntervalMs = sConfigMgr->GetOption("GoldRush.MaxIntervalMinutes", 240) * MINUTE * IN_MILLISECONDS; _config.DurationMs = sConfigMgr->GetOption("GoldRush.DurationMinutes", 20) * MINUTE * IN_MILLISECONDS; _config.MinNodeCount = sConfigMgr->GetOption("GoldRush.MinNodes", 15); _config.MaxNodeCount = sConfigMgr->GetOption("GoldRush.MaxNodes", 25); _config.BotsPerFaction = sConfigMgr->GetOption("GoldRush.BotsPerFaction", 6); _config.BotPulseMs = sConfigMgr->GetOption("GoldRush.BotPulseSeconds", 30) * IN_MILLISECONDS; _config.RetryMs = std::max(1, sConfigMgr->GetOption("GoldRush.RetryMinutes", 5)) * MINUTE * IN_MILLISECONDS; _config.GraceMs = sConfigMgr->GetOption("GoldRush.GraceMinutes", 15) * MINUTE * IN_MILLISECONDS; _config.MinPlayersOnline = std::max(1, sConfigMgr->GetOption("GoldRush.MinPlayersOnline", 1)); _config.SpawnRadius = sConfigMgr->GetOption("GoldRush.SpawnRadiusYards", 25.0f); _config.NodeEntries = sConfigMgr->GetOption("GoldRush.NodeEntries", "191133;190176;190171;190172;189973"); _config.ZonePool = sConfigMgr->GetOption("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("GoldRush.Blacklist", "Stormwind City; Orgrimmar; Dalaran"); _config.StartMessage = sConfigMgr->GetOption("GoldRush.StartMessage", "A seismic anomaly has exposed a massive vein of rich minerals in {}!"); _config.EndMessage = sConfigMgr->GetOption("GoldRush.EndMessage", "The Gold Rush in {} has been exhausted."); _config.Debug = sConfigMgr->GetOption("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::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 BuildBlockedZones(std::string const& blacklist) { std::unordered_set 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 BuildSites(std::string const& zones) const { std::vector sites; for (std::string_view zoneToken : Acore::Tokenize(zones, ';', true)) { std::string token = Trim(std::string(zoneToken)); if (token.empty()) continue; std::vector 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 BuildNodeEntries(std::string const& entries) { std::vector nodeEntries; for (std::string_view token : Acore::Tokenize(entries, ';', true)) { std::string trimmed = Trim(std::string(token)); if (trimmed.empty()) continue; if (Optional entry = Acore::StringTo(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 distribution(minValue, maxValue); return distribution(rng); } static float RandomFloat(float minValue, float maxValue) { static std::mt19937 rng{ std::random_device{}() }; std::uniform_real_distribution 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 SplitByCategory(std::vector const& entries, bool herbs) { std::vector 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::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::GetLock()); std::vector 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::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(1, nodeCount); std::vector oreEntries = SplitByCategory(_nodeEntries, false); std::vector herbEntries = SplitByCategory(_nodeEntries, true); std::vector 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(1, _config.DurationMs / IN_MILLISECONDS); constexpr float twoPi = 6.28318530717958647692f; float const anchorZ = anchor->GetPositionZ(); for (uint32 i = 0; i < nodeCount; ++i) { std::vector 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 candidates = sRandomPlayerbotMgr.GetPlayers(); std::vector alliance; std::vector 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& bots, TeamId team) { if (bots.empty()) return; std::shuffle(bots.begin(), bots.end(), std::mt19937{ std::random_device{}() }); uint32 targetCount = std::min(_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(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(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 const& GetSites() const { return _sites; } GoldRushConfig _config; GoldRushTelemetry _telemetry; std::unordered_set _blockedZones; std::vector _sites; std::vector _nodeEntries; GoldRushSite _currentSite; WorldPosition _hotspot; std::vector _spawnedNodes; std::unordered_set _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(); }