diff --git a/CHANGELOG.md b/CHANGELOG.md index dc8e312..b8c8298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,17 @@ All notable changes to `mod-gold-rush` will be documented in this file. ground height, rejects cliffs and lower floors, requires line of sight to the anchor, and retries a few times before giving up on a node. +### Added +- `GoldRush.RetryMinutes` (default 5), `GoldRush.GraceMinutes` (default 15) and + `GoldRush.MinPlayersOnline` (default 1). + ### Changed +- The scheduler no longer collapses to a fixed retry poll on an empty server. Previously + every failed attempt overwrote the rolled Min/MaxInterval with a hard-coded 5 minutes + and stayed there, so the first player to log in got an event announced on top of them + within one retry window. It now holds while the world is below `MinPlayersOnline`, and + waits `GraceMinutes` once the world repopulates before running. GM-forced starts + (`.goldrush start` / `teststart`) bypass both checks. - Node scatter uses a square-root radius so points spread evenly across the disc instead of bunching near the anchor. - `DespawnHotspotNodes` resolves the map once instead of per node, and warns if the diff --git a/conf/gold_rush.conf.dist b/conf/gold_rush.conf.dist index 35cacbc..71ebe93 100644 --- a/conf/gold_rush.conf.dist +++ b/conf/gold_rush.conf.dist @@ -46,6 +46,34 @@ GoldRush.MaxIntervalMinutes = 240 GoldRush.DurationMinutes = 20 +# +# GoldRush.RetryMinutes +# Description: How long to wait before trying again when a scheduled event cannot +# start (nobody online, no eligible zone, nowhere to place nodes). +# The rolled Min/MaxInterval is not consumed while holding. +# Default: 5 + +GoldRush.RetryMinutes = 5 + +# +# GoldRush.GraceMinutes +# Description: After the server has been sitting below MinPlayersOnline, wait this +# long once it repopulates before running an event. Stops the first +# person to log in from having a Gold Rush announced on top of them. +# Set to 0 to roll a fresh Min/MaxInterval instead. +# Default: 15 + +GoldRush.GraceMinutes = 15 + +# +# GoldRush.MinPlayersOnline +# Description: Minimum number of players standing in an event-eligible zone before +# a scheduled event may run. Playerbots count toward this. GM-forced +# starts ignore it. +# Default: 1 + +GoldRush.MinPlayersOnline = 1 + # # GoldRush.MinNodes # Description: Minimum number of temporary nodes to spawn in the hotspot diff --git a/src/GoldRush.cpp b/src/GoldRush.cpp index 3349957..ccc6048 100644 --- a/src/GoldRush.cpp +++ b/src/GoldRush.cpp @@ -80,6 +80,9 @@ public: 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; @@ -173,6 +176,9 @@ public: _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|The River's Heart"); @@ -191,6 +197,7 @@ public: _spawnedNodes.clear(); _routedBotIds.clear(); _telemetry = {}; + _worldWasEmpty = false; _timeUntilNextEventMs = 0; _eventTimeRemainingMs = 0; _nextBotPulseMs = _config.BotPulseMs; @@ -267,7 +274,7 @@ public: if (!site.ZoneId || !anchor || !anchor->IsInWorld() || !anchor->GetMap()) return false; - return StartEvent(anchor, site); + return StartEvent(anchor, site, false); } bool ForceTestStart(Player* anchor) @@ -724,11 +731,72 @@ private: return Acore::Containers::SelectRandomContainerElement(candidates); } - bool StartEvent(Player* anchorOverride = nullptr, GoldRushSite siteOverride = {}) + // 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); @@ -746,9 +814,11 @@ private: if (!siteOverride.ZoneId) { - _timeUntilNextEventMs = 5 * MINUTE * IN_MILLISECONDS; + _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 5 minutes."); + 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; } @@ -760,9 +830,11 @@ private: anchor = FindAnyEligibleWorldPlayer(); if (!anchor || !anchor->GetMap()) { - _timeUntilNextEventMs = 5 * MINUTE * IN_MILLISECONDS; + _worldWasEmpty = true; + _timeUntilNextEventMs = _config.RetryMs; if (_config.Debug) - LOG_INFO(GoldRushLogFilter, "Gold Rush hotspot in {} could not find an anchor player; retrying in 5 minutes.", BuildLocationText(_currentSite)); + 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; } } @@ -770,10 +842,10 @@ private: GoldRushSite anchorSite = BuildEligibleSiteFromPlayer(anchor); if (!anchorSite.ZoneId) { - _timeUntilNextEventMs = 5 * MINUTE * IN_MILLISECONDS; + _timeUntilNextEventMs = _config.RetryMs; if (_config.Debug) - LOG_INFO(GoldRushLogFilter, "Gold Rush rejected anchor {} because the zone is no longer eligible; retrying in 5 minutes.", - anchor->GetName()); + 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; } @@ -783,9 +855,10 @@ private: if (!SpawnHotspot(anchor)) { - _timeUntilNextEventMs = 5 * MINUTE * IN_MILLISECONDS; + _timeUntilNextEventMs = _config.RetryMs; if (_config.Debug) - LOG_INFO(GoldRushLogFilter, "Gold Rush hotspot in {} could not spawn nodes; retrying in 5 minutes.", BuildLocationText(_currentSite)); + LOG_INFO(GoldRushLogFilter, "Gold Rush hotspot in {} could not spawn nodes; retrying in {} minute(s).", + BuildLocationText(_currentSite), _config.RetryMs / (MINUTE * IN_MILLISECONDS)); return false; } @@ -1105,6 +1178,7 @@ private: uint32 _timeUntilNextEventMs = 0; uint32 _eventTimeRemainingMs = 0; uint32 _nextBotPulseMs = 0; + bool _worldWasEmpty = false; bool _sitesInitialized = false; bool _active = false; };