diff --git a/README.md b/README.md index 993f4f4..3617a07 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,15 @@ Battleground selection per bracket: already forming or in progress for that bracket with free slots, players are queued into it (not limited to the configured pool). - **Otherwise, a random pick** from `BgAutoQueue.Pool`, preferring candidates - whose minimum players per team the bracket can fill; if none qualify, the - smallest battleground is queued anyway. + whose minimum players per team the bracket can fill (a pool candidate that + already has uninvited queuers is reinforced first); if none qualify, the + smallest battleground is queued anyway to seed it. + +Note that while a seeded below-minimum queue waits to fill, the core blocks +those players from joining the Random Battleground queue, and, unless +`JoinBGAndLFG.Enable = 1` is set (core default is `0`), also from the Dungeon +Finder, until the queue pops or they leave it. See the note in +`conf/mod-bg-auto-queue.conf.dist`. 📖 **For the full, player-facing explanation** — when events fire, what players see, opt-out, battleground selection, the complete eligibility list, and the @@ -33,8 +40,18 @@ commands — see **[docs/how-it-works.md](docs/how-it-works.md)**. as [mod-cfbg](https://github.com/azerothcore/mod-cfbg) fills both teams. On a stock core the queue itself still requires the minimum in each faction, so mode 1 can queue faction-lopsided batches that never pop; set - `CrossFaction = 0` there. The module logs a warning when it detects this + `CrossFaction = 0` there. Conversely, with mod-cfbg active keep + `CrossFaction = 1` (mod-cfbg fills both teams from the total, so per-faction + viability under-queues). The module logs a warning on either misconfiguration. +- **Toggling `CFBG.Enable` at runtime.** Players queued while mod-cfbg was in + the other state sit in queue buckets the newly-active matcher never reads: + they will not be invited until they leave the queue and re-join (or log + out). This is a core/mod-cfbg limitation that affects manual queuers too; + the module excludes such stale entries from its viability counts and logs a + warning when it sees them. After flipping `CFBG.Enable` and reloading, + expect players queued before the flip to need a re-queue (a worldserver + restart avoids the situation entirely). - **Deserter tracking noise.** If `Battleground.TrackDeserters.Enable` is enabled, players who decline auto-queue invites may appear in the deserter tracking table. This is informational only, not a player-facing penalty. @@ -55,6 +72,14 @@ Things to be aware of when running this on a live server: immediate mode emits one line per player (see "Queue announcer" above — prefer its timed / player-only mode). Spreading the burst across multiple ticks is intentionally not implemented; size your `Interval` accordingly. +- **A large batch opens several instances.** The core creates at most one new + battleground instance per queue update, so after a pass the module keeps + nudging the queue every few seconds while a bracket still holds enough + uninvited players for another instance. A 60-player bracket therefore fills + multiple simultaneous instances within seconds instead of leaving the + surplus queued until the next pass. Any leftover below one instance's worth + stays queued and is served by normal core behavior (declines, leavers, + manual joins) or by the next pass. - **`.reload config` restarts the timer.** Every config reload resets the interval and re-applies `InitialDelay`. If you reload more frequently than `Interval`, the next automatic pass is pushed back each time and may never @@ -91,6 +116,8 @@ All options are documented in `conf/mod-bg-auto-queue.conf.dist`: - `BgAutoQueue.BroadcastMessage` — the warning text (empty disables it). - `BgAutoQueue.CrossFaction` — how the available count is judged against a BG's minimum players per team; `1` requires a cross-faction matcher such as mod-cfbg (see "Interactions with other systems"). - `BgAutoQueue.SkipGameMasters` — skip GMs in the warning and the queueing. +- `BgAutoQueue.SkipAFK` — skip players flagged AFK (on by default; note the client auto-flags idle players). +- `BgAutoQueue.SkipAuras` — CSV of aura IDs whose carriers are skipped for the pass. ## Commands @@ -98,3 +125,9 @@ All options are documented in `conf/mod-bg-auto-queue.conf.dist`: - `.bgevents off` — opt the current character out (future passes only; does not dequeue an existing queue). - `.bgevents` — *(no argument)* show the opt-in state and the time until the next scheduled pass. - `.bgevents run` — *(GM, console-capable)* run a queue pass immediately, even when the automatic schedule is disabled. Does not reset the periodic timer. + +Note: while a character holds the auto-created queue entry, the client +silently rejects a manual re-join of that same battleground — solo, and for +the whole party on "Join as Group" if any member holds the entry. Players who +want to queue as a group right after a pass must first leave the automatic +queue (via the queue icon next to the minimap) or wait for the invite. diff --git a/conf/mod-bg-auto-queue.conf.dist b/conf/mod-bg-auto-queue.conf.dist index ef88576..da650e0 100644 --- a/conf/mod-bg-auto-queue.conf.dist +++ b/conf/mod-bg-auto-queue.conf.dist @@ -13,6 +13,14 @@ # ".bgevents off" still works but lasts only for the current session, and a # warning is logged at startup. This module does not change the core config. # +# NOTE: when no pool candidate can reach its minimum player count for a +# bracket, the pass still queues the bracket into the smallest battleground +# to seed it. Until that queue pops (or the player leaves it), the core +# blocks Random Battleground joins, and with "JoinBGAndLFG.Enable = 0" (the +# core default) it also blocks joining the Dungeon Finder. Realms where +# below-minimum brackets are common should consider "JoinBGAndLFG.Enable=1" +# so seeded players keep Dungeon Finder access. +# # BgAutoQueue.Enable # Description: Enable the automatic, periodic battleground queue pass. # When enabled, the module periodically gathers eligible @@ -61,10 +69,12 @@ BgAutoQueue.Level.Max = 79 # 7 = Eye of the Storm # 9 = Strand of the Ancients # 30 = Isle of Conquest -# Invalid IDs, arenas and Random Battleground (32) are -# rejected at load with a warning. If the pool ends up -# empty, only live-battleground reinforcement can queue -# players. +# Non-numeric entries and Random Battleground (32) are +# rejected at load with a warning; unknown IDs, arenas and +# battlegrounds disabled via the `disables` table are +# reported with a warning at the first pass after each +# (re)load. If the pool ends up empty, only +# live-battleground reinforcement can queue players. # Default: "2,3,7" - Warsong Gulch, Arathi Basin, Eye of the Storm # @@ -125,7 +135,10 @@ BgAutoQueue.BroadcastMessage = "A Battleground event is starting shortly. Type . # faction, so mode 1 can queue faction-lopsided batches # that never pop; players stay queued until enough # minority-faction players appear. Without a -# cross-faction matcher, set this to 0. +# cross-faction matcher, set this to 0. With mod-cfbg +# active (CFBG.Enable = 1) keep this at 1: mod-cfbg fills +# both teams from the total, so per-faction viability +# under-queues (a warning is logged on this combination). # Default: 1 - Cross-faction: total players >= 2 x MinPlayersPerTeam # (requires a cross-faction matcher such as mod-cfbg) # 0 - Vanilla: Alliance >= min AND Horde >= min @@ -146,8 +159,11 @@ BgAutoQueue.SkipGameMasters = 1 # # BgAutoQueue.SkipAFK # Description: Skip players flagged as AFK in both the warning and the -# queueing, so idle characters are not teleported into a -# battleground. +# queueing, so idle characters do not receive queue invites +# they will never answer. (Nobody is ever teleported +# automatically: entering always requires the player to +# click "Enter Battle".) Note that the client flags idle +# players AFK automatically after a few minutes. # Default: 1 - Skip AFK players # 0 - Include AFK players # diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 85e628d..1953c93 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -84,6 +84,9 @@ You're skipped for an event (this event only) if you're: - **using the Dungeon Finder (LFG)**, - a **Death Knight still in the Ebon Hold starting zone**, - a **Game Master** (skipped by default; this can be turned off), +- **flagged AFK** (skipped by default; this can be turned off — note the game + flags you AFK automatically after a few minutes of inactivity, so if you were + idle you may get neither the warning nor the pop-up), - carrying one of the **configured skip auras** (a server setting). Once whatever's blocking you clears, you'll be considered again at the next @@ -94,6 +97,16 @@ event. Don't want to wait for an event? Open the Battleground window (press **H**) and queue yourself as usual. Events are just a convenience on top of that. +**One catch:** while you hold the queue spot an event gave you, the game +silently ignores a manual re-join of that **same** Battleground — nothing +happens when you click. The same applies to **Join as Group**: it silently +fails for the whole party if any member holds the event's queue spot. To queue +for that Battleground with friends right after an event, first **leave the +event queue** (click the battlefields icon next to the minimap and leave), or +wait for the invite and decline it — then queue as a group normally. Queueing +for a *different* Battleground is unaffected (you can be in two queues at +once). + ## Command reference | Command | Who | What it does | diff --git a/src/BgAutoQueue.cpp b/src/BgAutoQueue.cpp index d4d296b..05ae429 100644 --- a/src/BgAutoQueue.cpp +++ b/src/BgAutoQueue.cpp @@ -47,6 +47,12 @@ namespace BATTLEGROUND_IC }; + // How often UpdatePendingDrains re-checks queued-into brackets for a + // backlog worth another instance. Cheap (a few queue-list scans), and one + // extra instance can pop per check, so a mass batch drains within + // seconds instead of one instance per 45-minute pass. + constexpr uint32 BG_AUTO_QUEUE_DRAIN_CHECK_MS = 5000; + // default for BgAutoQueue.BroadcastMessage config constexpr char const* BG_AUTO_QUEUE_DEFAULT_BROADCAST = "A Battleground event is starting shortly. Type .bgevents off to opt " @@ -78,7 +84,7 @@ void BgAutoQueue::LoadConfig() std::string const poolStr = sConfigMgr->GetOption("BgAutoQueue.Pool", "2,3,7"); for (std::string_view token : Acore::Tokenize(poolStr, ',', false)) { - Optional value = Acore::StringTo(token); + Optional value = Acore::StringTo(Acore::String::Trim(std::string(token))); if (!value) { LOG_WARN("module", "BgAutoQueue.Pool entry '{}' is not a valid number, ignoring.", token); @@ -96,16 +102,30 @@ void BgAutoQueue::LoadConfig() } if (_poolRaw.empty()) - LOG_WARN("module", "BgAutoQueue.Pool is empty; the random pick is disabled (only live-BG reinforcement can queue players)."); + LOG_WARN("module", "BgAutoQueue.Pool is empty; the random pick and waiter reinforcement are disabled (only live-battleground reinforcement can queue players)."); + + // Timers are stored in uint32 milliseconds; clamp instead of silently + // wrapping (a wrapped Interval can land in the seconds range and turn the + // module into a continuous mass-queue loop). + constexpr uint64 maxTimerMs = std::numeric_limits::max(); uint32 const intervalMin = sConfigMgr->GetOption("BgAutoQueue.Interval", 45); - _intervalMs = intervalMin * 60u * 1000u; + uint64 const intervalMs = uint64(intervalMin) * 60 * 1000; + if (intervalMs > maxTimerMs) + LOG_WARN("module", "BgAutoQueue.Interval ({} min) exceeds the supported maximum, clamping to {} min.", intervalMin, maxTimerMs / (60 * 1000)); + _intervalMs = static_cast(std::min(intervalMs, maxTimerMs)); uint32 const initialDelaySec = sConfigMgr->GetOption("BgAutoQueue.InitialDelay", 0); - _initialDelayMs = initialDelaySec * 1000u; + uint64 const initialDelayMs = uint64(initialDelaySec) * 1000; + if (initialDelayMs > maxTimerMs) + LOG_WARN("module", "BgAutoQueue.InitialDelay ({} s) exceeds the supported maximum, clamping to {} s.", initialDelaySec, maxTimerMs / 1000); + _initialDelayMs = static_cast(std::min(initialDelayMs, maxTimerMs)); uint32 const warningLeadSec = sConfigMgr->GetOption("BgAutoQueue.WarningLeadTime", 60); - _warningLeadMs = warningLeadSec * 1000u; + uint64 const warningLeadMs = uint64(warningLeadSec) * 1000; + if (warningLeadMs > maxTimerMs) + LOG_WARN("module", "BgAutoQueue.WarningLeadTime ({} s) exceeds the supported maximum, clamping to {} s.", warningLeadSec, maxTimerMs / 1000); + _warningLeadMs = static_cast(std::min(warningLeadMs, maxTimerMs)); if (_intervalMs > 0 && _warningLeadMs >= _intervalMs) LOG_WARN("module", "BgAutoQueue.WarningLeadTime ({} s) >= Interval ({} min); the warning will not fire.", warningLeadSec, intervalMin); @@ -115,13 +135,22 @@ void BgAutoQueue::LoadConfig() _crossFaction = sConfigMgr->GetOption("BgAutoQueue.CrossFaction", true); - // Mirror mod-cfbg's defaults (CFBG.cpp LoadConfig). Only CFBG on + EvenTeams - // on + threshold 0 pins CFBG's allowedDiff to 0 in both match formation and - // reinforcement; every other combination tolerates odd totals, so the parity - // trim in QueueBucket self-disables (including installs without mod-cfbg). - _evenTeamsStrict = sConfigMgr->GetOption("CFBG.Enable", false) - && sConfigMgr->GetOption("CFBG.EvenTeams.Enabled", false) - && sConfigMgr->GetOption("CFBG.EvenTeams.MaxPlayersThreshold", 0) == 0; + // Mirror mod-cfbg's config (CFBG.cpp LoadConfig; showLogs=false because + // absent CFBG.* keys are the normal state on installs without mod-cfbg). + // While CFBG.Enable is on, mod-cfbg routes every non-rated queue entry + // through the cross-faction bucket (which drives waiter counting) and its + // EvenTeams match formation pins allowedDiff to 0 regardless of + // MaxPlayersThreshold — the threshold only relaxes reinforcement of games + // that already reached threshold*2 — so the parity trim in QueueBucket + // arms on formation strictness alone. + _cfbgEnabled = sConfigMgr->GetOption("CFBG.Enable", false, false); + _evenTeamsStrict = _cfbgEnabled + && sConfigMgr->GetOption("CFBG.EvenTeams.Enabled", false, false); + + if (_cfbgEnabled && !_crossFaction) + LOG_WARN("module", "mod-bg-auto-queue: CFBG.Enable = 1 but BgAutoQueue.CrossFaction = 0. " + "mod-cfbg fills both teams from the total player count, so per-faction viability " + "under-queues; set BgAutoQueue.CrossFaction = 1 while mod-cfbg is active."); _skipGameMasters = sConfigMgr->GetOption("BgAutoQueue.SkipGameMasters", true); _skipAfk = sConfigMgr->GetOption("BgAutoQueue.SkipAFK", true); @@ -142,13 +171,19 @@ void BgAutoQueue::LoadConfig() } // Reset timing on (re)load. Reload re-applies InitialDelay — accepted. - _elapsedMs = 0; - _warningSent = false; - _firstPass = true; - _matcherWarnLogged = false; - - LOG_INFO("module", "mod-bg-auto-queue: enabled={}, levels=[{}-{}], pool size={}, interval={} min, initialDelay={} s, warningLead={} s, crossFaction={}, evenTeamsStrict={}, skipGM={}, skipAFK={}, skipAuras={}.", - _enabled, _levelMin, _levelMax, _poolRaw.size(), intervalMin, initialDelaySec, warningLeadSec, _crossFaction, _evenTeamsStrict, _skipGameMasters, _skipAfk, _skipAuras.size()); + // _pendingDrains survives a reload on purpose: it only nudges the core + // queue to serve players a previous pass already queued, and every entry + // is re-validated against the current templates before each nudge. + _elapsedMs = 0; + _warningSent = false; + _firstPass = true; + _matcherWarnLogged = false; + _staleQueueWarnLogged = false; + _deserterWarnLogged = false; + _poolResolveLogged = false; + + LOG_INFO("module", "mod-bg-auto-queue: enabled={}, levels=[{}-{}], configured pool size={}, interval={} min, initialDelay={} s, warningLead={} s, crossFaction={}, cfbg={}, evenTeamsStrict={}, skipGM={}, skipAFK={}, skipAuras={}.", + _enabled, _levelMin, _levelMax, _poolRaw.size(), intervalMin, initialDelaySec, warningLeadSec, _crossFaction, _cfbgEnabled, _evenTeamsStrict, _skipGameMasters, _skipAfk, _skipAuras.size()); // Opt-out is stored via the core PlayerSettings system, which only persists // across logins when EnablePlayerSettings is on. Without it, .bgevents @@ -161,19 +196,41 @@ void BgAutoQueue::LoadConfig() void BgAutoQueue::ResolvePool() { + // First resolve after a config load reports at WARN so the operator sees + // rejects with stock log levels; ResolvePool runs every pass, so later + // repeats drop to DEBUG. + bool const firstResolve = !_poolResolveLogged; + auto poolLog = [firstResolve](std::string const& message) + { + if (firstResolve) + LOG_WARN("module", "{}", message); + else + LOG_DEBUG("module", "{}", message); + }; + _pool.clear(); for (BattlegroundTypeId bgTypeId : _poolRaw) { Battleground* bgTemplate = sBattlegroundMgr->GetBattlegroundTemplate(bgTypeId); if (!bgTemplate) { - LOG_DEBUG("module", "BgAutoQueue.Pool entry {} has no battleground template, ignoring.", static_cast(bgTypeId)); + // A battleground disabled in the `disables` table at startup gets + // no template at all, so this path covers both bad IDs and disables. + poolLog(Acore::StringFormat("BgAutoQueue.Pool entry {} has no battleground template (unknown ID, or disabled in the `disables` table), ignoring.", static_cast(bgTypeId))); continue; } if (bgTemplate->isArena()) { - LOG_DEBUG("module", "BgAutoQueue.Pool entry {} is an arena (unsupported), ignoring.", static_cast(bgTypeId)); + poolLog(Acore::StringFormat("BgAutoQueue.Pool entry {} is an arena (unsupported), ignoring.", static_cast(bgTypeId))); + continue; + } + + // Runtime disables (`.reload disables` after startup) keep the + // template, so the no-template path above does not catch them. + if (sDisableMgr->IsDisabledFor(DISABLE_TYPE_BATTLEGROUND, bgTypeId, nullptr)) + { + poolLog(Acore::StringFormat("BgAutoQueue.Pool entry {} is disabled in the `disables` table, ignoring.", static_cast(bgTypeId))); continue; } @@ -181,7 +238,12 @@ void BgAutoQueue::ResolvePool() } if (_pool.empty() && !_poolRaw.empty()) - LOG_DEBUG("module", "BgAutoQueue.Pool has no usable battlegrounds against the current templates; the random pick is disabled."); + poolLog("BgAutoQueue.Pool has no usable battlegrounds against the current templates; the random pick and waiter reinforcement are disabled (only live-battleground reinforcement can queue players)."); + + if (firstResolve) + LOG_INFO("module", "mod-bg-auto-queue: pool resolved: {} usable of {} configured entries.", _pool.size(), _poolRaw.size()); + + _poolResolveLogged = true; } bool BgAutoQueue::IsOptedOut(Player* player) const @@ -266,8 +328,8 @@ bool BgAutoQueue::IsEligible(Player* player, SkipReason* reason) const return fail(SkipReason::AlreadyQueued); } - // Deserter check via any standard template (WSG). - if (Battleground* bgTemplate = sBattlegroundMgr->GetBattlegroundTemplate(BATTLEGROUND_WS)) + // Deserter/permission check via any normal-BG template. + if (Battleground* bgTemplate = GetDeserterCheckTemplate()) { if (!player->CanJoinToBattleground(bgTemplate)) { @@ -319,6 +381,23 @@ bool BgAutoQueue::IsEligible(Player* player, SkipReason* reason) const return true; } +Battleground* BgAutoQueue::GetDeserterCheckTemplate() const +{ + // CanJoinToBattleground only reads the template's arena/RB type, which is + // identical for every normal battleground, so any existing template works. + for (BattlegroundTypeId bgTypeId : BG_NORMAL_TYPES) + if (Battleground* bgTemplate = sBattlegroundMgr->GetBattlegroundTemplate(bgTypeId)) + return bgTemplate; + + if (!_deserterWarnLogged) + { + LOG_WARN("module", "mod-bg-auto-queue: no normal battleground template exists; the deserter/permission eligibility check is skipped."); + _deserterWarnLogged = true; + } + + return nullptr; +} + bool BgAutoQueue::CanEnter(Player* player, BattlegroundTypeId bgTypeId) const { if (!player) @@ -337,9 +416,9 @@ bool BgAutoQueue::CanEnter(Player* player, BattlegroundTypeId bgTypeId) const bool BgAutoQueue::BucketHasAnyFit(BattlegroundTypeId bgTypeId, BracketBucket const& bucket) const { - for (ObjectGuid guid : bucket.players) + for (BucketMember const& member : bucket.players) { - Player* player = ObjectAccessor::FindPlayer(guid); + Player* player = ObjectAccessor::FindPlayer(member.guid); if (player && CanEnter(player, bgTypeId)) return true; } @@ -350,9 +429,9 @@ bool BgAutoQueue::BucketHasAnyFit(BattlegroundTypeId bgTypeId, BracketBucket con bool BgAutoQueue::BucketFitsLiveBg(Battleground* bg, BracketBucket const& bucket) const { BattlegroundTypeId const bgTypeId = bg->GetBgTypeID(); - for (ObjectGuid guid : bucket.players) + for (BucketMember const& member : bucket.players) { - Player* player = ObjectAccessor::FindPlayer(guid); + Player* player = ObjectAccessor::FindPlayer(member.guid); if (!player || !player->GetBGAccessByLevel(bgTypeId)) continue; @@ -364,8 +443,8 @@ bool BgAutoQueue::BucketFitsLiveBg(Battleground* bg, BracketBucket const& bucket return false; } -BgAutoQueue::QueuedWaiters BgAutoQueue::CountUninvitedWaiters(BattlegroundTypeId bgTypeId, - uint32 minLevel, uint32 maxLevel) const +BgAutoQueue::QueuedWaiters BgAutoQueue::CountUninvitedWaitersInBracket(BattlegroundTypeId bgTypeId, + BattlegroundBracketId bracketId) const { QueuedWaiters waiters; @@ -373,20 +452,6 @@ BgAutoQueue::QueuedWaiters BgAutoQueue::CountUninvitedWaiters(BattlegroundTypeId if (bgQueueTypeId == BATTLEGROUND_QUEUE_NONE) return waiters; - Battleground* bgTemplate = sBattlegroundMgr->GetBattlegroundTemplate(bgTypeId); - if (!bgTemplate) - return waiters; - - // Bracket indices are numbered per map, so the level range must be resolved - // against the candidate BG's own map. Brackets are contiguous, so the two - // endpoints cover the whole range (0, 1, or 2 distinct ids); a level with no - // bracket on this map cannot enter this BG and contributes nothing. - std::vector bracketIds; - for (uint32 level : { minLevel, maxLevel }) - if (PvPDifficultyEntry const* entry = GetBattlegroundBracketByLevel(bgTemplate->GetMapId(), level)) - if (std::find(bracketIds.begin(), bracketIds.end(), entry->GetBracketId()) == bracketIds.end()) - bracketIds.push_back(entry->GetBracketId()); - BattlegroundQueue& bgQueue = sBattlegroundMgr->GetBattlegroundQueue(bgQueueTypeId); static constexpr BattlegroundQueueGroupTypes WAITER_GROUP_TYPES[] = @@ -398,39 +463,133 @@ BgAutoQueue::QueuedWaiters BgAutoQueue::CountUninvitedWaiters(BattlegroundTypeId BG_QUEUE_CFBG }; - for (BattlegroundBracketId bracketId : bracketIds) + for (BattlegroundQueueGroupTypes groupType : WAITER_GROUP_TYPES) { - for (BattlegroundQueueGroupTypes groupType : WAITER_GROUP_TYPES) + // mod-cfbg serves only BG_QUEUE_CFBG while enabled; stock matching + // serves only the premade/faction buckets. Entries on the inactive + // side (queued before a CFBG.Enable flip + reload) can never pop, so + // they must not count as waiters — warn instead, with the diagnosis + // matching the direction of the flip. + bool const matcherReads = _cfbgEnabled == (groupType == BG_QUEUE_CFBG); + + for (GroupQueueInfo const* gInfo : bgQueue.m_QueuedGroups[bracketId][groupType]) { - for (GroupQueueInfo const* gInfo : bgQueue.m_QueuedGroups[bracketId][groupType]) - { - if (gInfo->IsInvitedToBGInstanceGUID != 0) - continue; + if (gInfo->IsInvitedToBGInstanceGUID != 0) + continue; - if (_crossFaction && !_matcherWarnLogged - && (groupType == BG_QUEUE_NORMAL_ALLIANCE || groupType == BG_QUEUE_NORMAL_HORDE)) + if (!matcherReads) + { + if (!_staleQueueWarnLogged) { - LOG_WARN("module", "mod-bg-auto-queue: BgAutoQueue.CrossFaction = 1 but uninvited " - "players sit in a faction-specific queue bucket, so no cross-faction matcher " - "(e.g. mod-cfbg) appears to be active. Total-based viability can queue " - "faction-lopsided batches the core queue never pops. Set " - "BgAutoQueue.CrossFaction = 0 on a stock core."); - _matcherWarnLogged = true; + if (_cfbgEnabled) + LOG_WARN("module", "mod-bg-auto-queue: uninvited players sit in premade/faction " + "queue buckets that the cross-faction matcher (CFBG.Enable = 1) never reads. " + "They were queued before mod-cfbg was enabled and can never be invited; they " + "are excluded from viability counts and must leave and re-join the queue " + "(or log out)."); + else + LOG_WARN("module", "mod-bg-auto-queue: uninvited players sit in the cross-faction " + "queue bucket while CFBG.Enable is 0/absent. They were queued before mod-cfbg " + "was disabled and can never be invited; they are excluded from viability " + "counts and must leave and re-join the queue (or log out)."); + _staleQueueWarnLogged = true; } + // Nothing in a non-read bucket is counted, and one uninvited + // entry suffices for the diagnostic, so skip the rest. + break; + } - uint32 const count = static_cast(gInfo->Players.size()); - waiters.total += count; - if (gInfo->RealTeamID == TEAM_ALLIANCE) - waiters.alliance += count; - else - waiters.horde += count; + if (_crossFaction && !_cfbgEnabled && !_matcherWarnLogged + && (groupType == BG_QUEUE_NORMAL_ALLIANCE || groupType == BG_QUEUE_NORMAL_HORDE)) + { + LOG_WARN("module", "mod-bg-auto-queue: BgAutoQueue.CrossFaction = 1 but uninvited " + "players sit in a faction-specific queue bucket, so no cross-faction matcher " + "(e.g. mod-cfbg) appears to be active. Total-based viability can queue " + "faction-lopsided batches the core queue never pops. Set " + "BgAutoQueue.CrossFaction = 0 on a stock core."); + _matcherWarnLogged = true; } + + uint32 const count = static_cast(gInfo->Players.size()); + waiters.total += count; + if (gInfo->RealTeamID == TEAM_ALLIANCE) + waiters.alliance += count; + else + waiters.horde += count; } } return waiters; } +BgAutoQueue::QueuedWaiters BgAutoQueue::CountUninvitedWaiters(BattlegroundTypeId bgTypeId, + uint32 minLevel, uint32 maxLevel) const +{ + QueuedWaiters waiters; + + Battleground* bgTemplate = sBattlegroundMgr->GetBattlegroundTemplate(bgTypeId); + if (!bgTemplate) + return waiters; + + // Bracket indices are numbered per map, so the level range must be resolved + // against the candidate BG's own map. Brackets are contiguous, so the two + // endpoints cover the whole range (0, 1, or 2 distinct ids); a level with no + // bracket on this map cannot enter this BG and contributes nothing. + std::vector bracketIds; + for (uint32 level : { minLevel, maxLevel }) + if (PvPDifficultyEntry const* entry = GetBattlegroundBracketByLevel(bgTemplate->GetMapId(), level)) + if (std::find(bracketIds.begin(), bracketIds.end(), entry->GetBracketId()) == bracketIds.end()) + bracketIds.push_back(entry->GetBracketId()); + + for (BattlegroundBracketId bracketId : bracketIds) + { + QueuedWaiters const bracketWaiters = CountUninvitedWaitersInBracket(bgTypeId, bracketId); + waiters.total += bracketWaiters.total; + waiters.alliance += bracketWaiters.alliance; + waiters.horde += bracketWaiters.horde; + } + + return waiters; +} + +std::vector BgAutoQueue::PartitionByCandidateBracket(Battleground* bgTemplate, + BracketBucket const& bucket) const +{ + std::vector partitions; + + for (BucketMember const& member : bucket.players) + { + // Mirror the queue-time per-player gates: the battleground_template + // level window (a template, so GetMin/MaxLevel are the DB values) and + // the DBC bracket lookup. + if (member.level < bgTemplate->GetMinLevel() || member.level > bgTemplate->GetMaxLevel()) + continue; + + PvPDifficultyEntry const* entry = GetBattlegroundBracketByLevel(bgTemplate->GetMapId(), member.level); + if (!entry) + continue; + + auto itr = std::find_if(partitions.begin(), partitions.end(), + [entry](BracketPartition const& partition) + { + return partition.bracket == entry; + }); + if (itr == partitions.end()) + { + partitions.push_back({ entry, 0, 0, 0 }); + itr = std::prev(partitions.end()); + } + + ++itr->total; + if (member.team == TEAM_ALLIANCE) + ++itr->alliance; + else + ++itr->horde; + } + + return partitions; +} + uint32 BgAutoQueue::GetEffectiveMinPlayersPerTeam(Battleground* bgTemplate, BracketBucket const& bucket) const { PvPDifficultyEntry const* bracketEntry = GetBattlegroundBracketByLevel(bgTemplate->GetMapId(), bucket.minLevel); @@ -445,14 +604,30 @@ uint32 BgAutoQueue::GetEffectiveMinPlayersPerTeam(Battleground* bgTemplate, Brac bool BgAutoQueue::IsViable(Battleground* bgTemplate, BracketBucket const& bucket) const { - uint32 const minPerTeam = GetEffectiveMinPlayersPerTeam(bgTemplate, bucket); - QueuedWaiters const waiters = CountUninvitedWaiters(bgTemplate->GetBgTypeID(), bucket.minLevel, bucket.maxLevel); + // Core matching pops strictly per bracket_id, so every non-empty partition + // must reach the minimum on its own — a pooled bucket total can look + // viable while each split bracket stays below minimum forever (AV's + // shifted brackets straddle the reference buckets). + std::vector const partitions = PartitionByCandidateBracket(bgTemplate, bucket); + if (partitions.empty()) + return false; - if (_crossFaction) - return (bucket.alliance + bucket.horde + waiters.total) >= (2u * minPerTeam); + for (BracketPartition const& partition : partitions) + { + uint32 const minPerTeam = ::GetMinPlayersPerTeam(bgTemplate, partition.bracket); + QueuedWaiters const waiters = CountUninvitedWaitersInBracket(bgTemplate->GetBgTypeID(), partition.bracket->GetBracketId()); + + if (_crossFaction) + { + if (partition.total + waiters.total < 2u * minPerTeam) + return false; + } + else if (partition.alliance + waiters.alliance < minPerTeam + || partition.horde + waiters.horde < minPerTeam) + return false; + } - return (bucket.alliance + waiters.alliance) >= minPerTeam - && (bucket.horde + waiters.horde) >= minPerTeam; + return true; } BattlegroundTypeId BgAutoQueue::SelectBattlegroundForBracket(BracketBucket const& bucket, @@ -491,12 +666,15 @@ BattlegroundTypeId BgAutoQueue::SelectBattlegroundForBracket(BracketBucket const return bgTypeId; } - // (a2) Prefer a not-yet-running BG that already has uninvited queuers, so a + // (a2) Prefer the pool candidate that already has uninvited queuers, so a // manual queuer's chosen BG is reinforced instead of bypassed. Among viable - // candidates pick the one closest to popping (most waiters); ties at random. + // candidates pick the one closest to popping (most waiters); ties at + // random. Restricted to the configured pool: otherwise a single manual + // queuer could steer every cycle's mass-queue into a battleground the + // operator deliberately excluded. std::vector waiterLeaders; uint32 bestWaiters = 0; - for (BattlegroundTypeId bgTypeId : BG_NORMAL_TYPES) + for (BattlegroundTypeId bgTypeId : _pool) { if (sDisableMgr->IsDisabledFor(DISABLE_TYPE_BATTLEGROUND, bgTypeId, nullptr)) continue; @@ -590,9 +768,9 @@ uint32 BgAutoQueue::QueueBucket(BattlegroundTypeId bgTypeId, BracketBucket const // is safe: both run inside this one synchronous call, no tick boundary. std::vector> verified; - for (ObjectGuid guid : bucket.players) + for (BucketMember const& member : bucket.players) { - Player* player = ObjectAccessor::FindPlayer(guid); + Player* player = ObjectAccessor::FindPlayer(member.guid); if (!player) continue; @@ -614,6 +792,17 @@ uint32 BgAutoQueue::QueueBucket(BattlegroundTypeId bgTypeId, BracketBucket const continue; } + // The core join handler refuses players outside the + // battleground_template level window — a free-form DB range distinct + // from the DBC bracket below, so operators can restrict a BG to a + // narrower range than its brackets. + if (!player->GetBGAccessByLevel(bgTypeId)) + { + if (skippedAtQueueTime) + ++*skippedAtQueueTime; + continue; + } + PvPDifficultyEntry const* bracketEntry = GetBattlegroundBracketByLevel(bgTemplate->GetMapId(), player->GetLevel()); if (!bracketEntry) { @@ -635,16 +824,43 @@ uint32 BgAutoQueue::QueueBucket(BattlegroundTypeId bgTypeId, BracketBucket const verified.push_back({ player, bracketEntry }); } - // Strict EvenTeams (allowedDiff 0) can never invite an odd total: one solo - // would be stranded until an external parity break. Leave the newest - // unqueued instead; the next pass picks them up. - if (_crossFaction && _evenTeamsStrict && !verified.empty()) + // Strict EvenTeams (formation allowedDiff 0) can never invite an odd count + // from one queue list: one solo would be stranded until an external parity + // break. Matching pops per bracket, so parity is enforced per bracket + // (global evenness of a split wave guarantees nothing); the odd bracket's + // newest player is left unqueued and picked up by the next pass. Live-BG + // reinforcement is exempt: how many players the live game absorbs depends + // on its own team imbalance, and trimming there strands a player exactly + // when that imbalance is odd (the routine post-leaver state). + if (_evenTeamsStrict && !liveBracket && !verified.empty()) { - QueuedWaiters const waiters = CountUninvitedWaiters(bgTypeId, bucket.minLevel, bucket.maxLevel); - if ((verified.size() + waiters.total) % 2 != 0) + std::vector trimBrackets; + for (auto const& [player, bracketEntry] : verified) + if (std::find(trimBrackets.begin(), trimBrackets.end(), bracketEntry->GetBracketId()) == trimBrackets.end()) + trimBrackets.push_back(bracketEntry->GetBracketId()); + + for (BattlegroundBracketId trimBracket : trimBrackets) { - LOG_DEBUG("module", "mod-bg-auto-queue: parity trim: leaving {} unqueued to keep the wave even for strict EvenTeams.", verified.back().first->GetName()); - verified.pop_back(); + size_t const bracketCount = std::count_if(verified.begin(), verified.end(), + [trimBracket](std::pair const& entry) + { + return entry.second->GetBracketId() == trimBracket; + }); + + QueuedWaiters const waiters = CountUninvitedWaitersInBracket(bgTypeId, trimBracket); + if ((bracketCount + waiters.total) % 2 == 0) + continue; + + for (auto itr = verified.rbegin(); itr != verified.rend(); ++itr) + { + if (itr->second->GetBracketId() != trimBracket) + continue; + + LOG_DEBUG("module", "mod-bg-auto-queue: parity trim: leaving {} unqueued to keep bracket {} even for strict EvenTeams.", + itr->first->GetName(), static_cast(trimBracket)); + verified.erase(std::next(itr).base()); + break; + } } } @@ -675,15 +891,84 @@ uint32 BgAutoQueue::QueueBucket(BattlegroundTypeId bgTypeId, BracketBucket const } // One queue update per distinct bracket queued into, not once per player. + // The bracket is also registered for draining: core creates at most one + // new instance per queue update, so a batch larger than one instance + // needs follow-up updates (see UpdatePendingDrains). for (BattlegroundBracketId scheduledBracket : scheduledBrackets) { sBattlegroundMgr->ScheduleQueueUpdate(0, 0, bgQueueTypeId, bgTypeId, scheduledBracket); + RegisterPendingDrain(bgQueueTypeId, bgTypeId, scheduledBracket); LOG_DEBUG("module", "mod-bg-auto-queue: scheduled queue update for bgTypeId {} bracket {}.", static_cast(bgTypeId), static_cast(scheduledBracket)); } return queued; } +void BgAutoQueue::RegisterPendingDrain(BattlegroundQueueTypeId queueTypeId, + BattlegroundTypeId bgTypeId, BattlegroundBracketId bracketId) +{ + for (PendingDrain const& drain : _pendingDrains) + if (drain.queueTypeId == queueTypeId && drain.bgTypeId == bgTypeId && drain.bracketId == bracketId) + return; + + _pendingDrains.push_back({ queueTypeId, bgTypeId, bracketId }); +} + +void BgAutoQueue::UpdatePendingDrains(uint32 diff) +{ + if (_pendingDrains.empty()) + return; + + _drainTimerMs += diff; + if (_drainTimerMs < BG_AUTO_QUEUE_DRAIN_CHECK_MS) + return; + + _drainTimerMs = 0; + + for (auto itr = _pendingDrains.begin(); itr != _pendingDrains.end();) + { + // The core's scheduled queue-update path has no disable check, so + // nudging a runtime-disabled battleground could still pop instances. + // Queueing players into it after a re-enable re-registers the drain. + if (sDisableMgr->IsDisabledFor(DISABLE_TYPE_BATTLEGROUND, itr->bgTypeId, nullptr)) + { + itr = _pendingDrains.erase(itr); + continue; + } + + Battleground* bgTemplate = sBattlegroundMgr->GetBattlegroundTemplate(itr->bgTypeId); + PvPDifficultyEntry const* bracketEntry = bgTemplate + ? GetBattlegroundBracketById(bgTemplate->GetMapId(), itr->bracketId) + : nullptr; + + uint32 const minPerTeam = bracketEntry ? ::GetMinPlayersPerTeam(bgTemplate, bracketEntry) : 0; + if (!minPerTeam) + { + itr = _pendingDrains.erase(itr); + continue; + } + + // Drop the entry once the bracket can no longer fill another instance; + // below that, invite declines and manual joins re-trigger the core + // queue on their own. + QueuedWaiters const waiters = CountUninvitedWaitersInBracket(itr->bgTypeId, itr->bracketId); + bool const canPop = _crossFaction + ? waiters.total >= 2u * minPerTeam + : (waiters.alliance >= minPerTeam && waiters.horde >= minPerTeam); + + if (!canPop) + { + itr = _pendingDrains.erase(itr); + continue; + } + + sBattlegroundMgr->ScheduleQueueUpdate(0, 0, itr->queueTypeId, itr->bgTypeId, itr->bracketId); + LOG_DEBUG("module", "mod-bg-auto-queue: drain: re-scheduled queue update for bgTypeId {} bracket {} ({} uninvited waiter(s)).", + static_cast(itr->bgTypeId), static_cast(itr->bracketId), waiters.total); + ++itr; + } +} + BgAutoQueue::QueuePassResult BgAutoQueue::RunQueuePass() { // Rebuild the pool against the templates that exist right now, so every @@ -715,11 +1000,7 @@ BgAutoQueue::QueuePassResult BgAutoQueue::RunQueuePass() BracketBucket& bucket = buckets[bracketEntry->GetBracketId()]; bucket.minLevel = bracketEntry->minLevel; bucket.maxLevel = bracketEntry->maxLevel; - bucket.players.push_back(player->GetGUID()); - if (player->GetTeamId() == TEAM_ALLIANCE) - ++bucket.alliance; - else - ++bucket.horde; + bucket.players.push_back({ player->GetGUID(), static_cast(player->GetLevel()), player->GetTeamId() }); } for (auto const& [bracketId, bucket] : buckets) @@ -756,6 +1037,10 @@ BgAutoQueue::QueuePassResult BgAutoQueue::RunQueuePass() void BgAutoQueue::Update(uint32 diff) { + // Before the Enable/Interval gate: a `.bgevents run` backlog must drain + // even while the periodic schedule is off. + UpdatePendingDrains(diff); + if (!_enabled || _intervalMs == 0) return; @@ -763,7 +1048,10 @@ void BgAutoQueue::Update(uint32 diff) uint32 const target = (_firstPass && _initialDelayMs > 0) ? _initialDelayMs : _intervalMs; - if (!_warningSent && target > _warningLeadMs && (target - _elapsedMs) <= _warningLeadMs) + // uint64 sum: plain (target - _elapsedMs) underflows when one stalled + // world tick carries _elapsedMs past target, silently skipping the + // promised warning (it then fires late, right before the pass below). + if (!_warningSent && target > _warningLeadMs && uint64(_elapsedMs) + _warningLeadMs >= target) { BroadcastWarning(); _warningSent = true; diff --git a/src/BgAutoQueue.h b/src/BgAutoQueue.h index 19ab039..f845e4e 100644 --- a/src/BgAutoQueue.h +++ b/src/BgAutoQueue.h @@ -16,6 +16,7 @@ class Battleground; class Player; +struct PvPDifficultyEntry; // PlayerSettings layout for this module. source = "mod-bg-auto-queue". enum BgAutoQueueSetting @@ -89,7 +90,8 @@ class BgAutoQueue // (always), so the Enable/Interval gate lives only in Update. QueuePassResult RunQueuePass(); - // Drives the periodic queue pass. Call from WorldScript::OnUpdate. + // Drives the periodic queue pass and the post-pass backlog draining. + // Call from WorldScript::OnUpdate. void Update(uint32 diff); // Milliseconds until the next automatic pass (0 when no pass is scheduled). @@ -100,12 +102,19 @@ class BgAutoQueue // Per-bracket bucket of eligible players gathered during a pass. Players // are stored by GUID and re-resolved at queue time (never store a - // long-lived Player*). + // long-lived Player*). Level and faction are cached at gather time for the + // per-bracket partition math; the whole pass runs synchronously inside one + // world tick, so they cannot change before queue time. + struct BucketMember + { + ObjectGuid guid; + uint8 level = 0; + TeamId team = TEAM_NEUTRAL; + }; + struct BracketBucket { - std::vector players; - uint32 alliance = 0; - uint32 horde = 0; + std::vector players; uint32 minLevel = 0; // bracket level range, drives waiter counting and logging uint32 maxLevel = 0; }; @@ -134,52 +143,120 @@ class BgAutoQueue uint32 horde = 0; }; - // Counts uninvited players already sitting in bgTypeId's core queue, in the - // candidate BG's own map-relative bracket(s) spanning [minLevel, maxLevel] - // (bracket indices are numbered per map), split by the players' real faction - // (RealTeamID, immune to CFBG's staging mutations of teamId). Scans every - // solo/premade/cross-faction group bucket because which bucket a manual - // queuer lands in depends on whether mod-cfbg is active. Groups already - // invited to a forming instance are skipped. + // Counts uninvited players already sitting in bgTypeId's core queue for one + // map-relative bracket, split by the players' real faction (RealTeamID, + // immune to CFBG's staging mutations of teamId). Only the queue buckets the + // currently-active matcher reads are counted (BG_QUEUE_CFBG while the + // mirrored CFBG.Enable is on, the premade/faction buckets otherwise): + // entries stranded on the wrong side of a CFBG.Enable reload can never pop, + // so counting them would inflate viability and steer passes into dead + // queues; they are surfaced by a once-per-load warning instead. Groups + // already invited to a forming instance are skipped. + QueuedWaiters CountUninvitedWaitersInBracket(BattlegroundTypeId bgTypeId, + BattlegroundBracketId bracketId) const; + + // Sums CountUninvitedWaitersInBracket over the candidate BG's own + // map-relative bracket(s) spanning [minLevel, maxLevel] (bracket indices + // are numbered per map; brackets are contiguous, so the two endpoints + // cover the whole range). QueuedWaiters CountUninvitedWaiters(BattlegroundTypeId bgTypeId, uint32 minLevel, uint32 maxLevel) const; - // Bracket-aware MinPlayersPerTeam for viability math: resolves the bucket's - // bracket on the candidate BG's own map and returns the same override-aware - // minimum core matching uses (GetMinPlayersPerTeam, BattlegroundUtils.h), so - // Battleground.Override.LowLevels.MinPlayers is honoured on sub-max brackets. - // Bucket levels come from the WSG reference map, so the candidate may have no - // bracket at minLevel (hence the maxLevel retry) or none at all, in which - // case the raw template minimum is returned. + // One candidate-map bracket's share of a bucket. Core matching pops + // strictly per bracket_id, so viability and parity must be judged on these + // partitions, never on the pooled bucket total (AV's shifted brackets + // split the 60-69 and 70-79 reference buckets in two; a pooled total can + // look viable while every partition is below minimum). + struct BracketPartition + { + PvPDifficultyEntry const* bracket = nullptr; + uint32 total = 0; + uint32 alliance = 0; + uint32 horde = 0; + }; + + // Partitions the bucket's players by their own bracket on the candidate + // BG's map, applying the same per-player gates QueueBucket applies (the + // battleground_template level window and bracket existence), so partition + // counts match what would actually be queued. + std::vector PartitionByCandidateBracket(Battleground* bgTemplate, + BracketBucket const& bucket) const; + + // Bracket-aware MinPlayersPerTeam used only to rank fallback candidates + // when nothing is viable: resolves the bucket's bracket on the candidate + // BG's own map and returns the same override-aware minimum core matching + // uses (GetMinPlayersPerTeam, BattlegroundUtils.h). Bucket levels come + // from the WSG reference map, so the candidate may have no bracket at + // minLevel (hence the maxLevel retry) or none at all, in which case the + // raw template minimum is returned. uint32 GetEffectiveMinPlayersPerTeam(Battleground* bgTemplate, BracketBucket const& bucket) const; // Viability per CrossFaction: cross-faction => total >= 2*min; otherwise - // each faction tally >= min. Includes uninvited players already queued for - // the candidate BG at the bucket's levels, not just the freshly-gathered batch. + // each faction tally >= min. Judged independently for every non-empty + // bracket partition (see BracketPartition) with that partition's own + // waiters and override-aware minimum; all partitions must pass. Includes + // uninvited players already queued for the candidate BG, not just the + // freshly-gathered batch. bool IsViable(Battleground* bgTemplate, BracketBucket const& bucket) const; - // Selects the BG for a populated bracket: live-BG reinforcement first, - // then a not-yet-running BG that already has uninvited queuers, then a - // random pick from the configured pool with documented fallbacks. On a - // live-BG pick, liveBracket carries the matched game's own map-relative - // bracket id; it stays empty on every other path. + // Selects the BG for a populated bracket: live-BG reinforcement first + // (not limited to the pool), then the pool candidate that already has + // uninvited queuers, then a random pick from the configured pool with + // documented fallbacks. On a live-BG pick, liveBracket carries the matched + // game's own map-relative bracket id; it stays empty on every other path. BattlegroundTypeId SelectBattlegroundForBracket(BracketBucket const& bucket, Optional& liveBracket) const; // Queues every player in the bucket into bgTypeId, then schedules one - // queue update per distinct bracket queued into. When liveBracket is set + // queue update per distinct bracket queued into and registers the bracket + // for backlog draining (see UpdatePendingDrains). When liveBracket is set // (live-BG reinforcement), players whose own bracket differs are skipped — // that game's queue list would never serve them. Under strict CFBG - // EvenTeams (see _evenTeamsStrict), an odd wave (re-validated players plus - // uninvited waiters) leaves its newest player unqueued for this pass so no - // solo is stranded behind an allowedDiff of 0; the next pass picks them up. - // Returns the number of players queued. When non-null, skippedAtQueueTime - // is incremented for each bucket player dropped by the queue-time re-check, - // the BG-specific veto, or the off-bracket skip (not for the parity trim — - // a deferral, not a drop). + // EvenTeams (see _evenTeamsStrict), a bracket whose wave (re-validated + // players plus that bracket's uninvited waiters) is odd leaves its newest + // player unqueued for this pass so no solo is stranded behind an + // allowedDiff of 0; the next pass picks them up. Parity is judged per + // bracket (matching pops per bracket, so global evenness guarantees + // nothing) and skipped entirely for live-BG reinforcement, where how many + // players fit depends on the live game's own team imbalance. Returns the + // number of players queued. When non-null, skippedAtQueueTime is + // incremented for each bucket player dropped by the queue-time re-check, + // the BG-specific veto, or the level/bracket mismatch skips (not for the + // parity trim — a deferral, not a drop). uint32 QueueBucket(BattlegroundTypeId bgTypeId, BracketBucket const& bucket, Optional liveBracket, uint32* skippedAtQueueTime = nullptr); + // Any normal-BG template works for the deserter/permission check + // (Player::CanJoinToBattleground is template-type-independent for normal + // battlegrounds); individual templates can be absent (disabled via + // `disables` or removed), so the first existing one is used. Warns once + // per config load when none exists so the gate never silently vanishes. + Battleground* GetDeserterCheckTemplate() const; + + // One (queue, battleground, bracket) the module queued players into that + // may still hold more than one instance's worth of uninvited waiters. + // Core's non-rated queue update creates at most ONE new instance per call + // and nothing re-triggers it for normal BG queues on its own (the periodic + // scheduler only serves arena queues; an all-accept invite wave leaves no + // re-trigger), so a mass batch would drain one instance per pass without + // this. + struct PendingDrain + { + BattlegroundQueueTypeId queueTypeId = BATTLEGROUND_QUEUE_NONE; + BattlegroundTypeId bgTypeId = BATTLEGROUND_TYPE_NONE; + BattlegroundBracketId bracketId = BG_BRACKET_ID_FIRST; + }; + + void RegisterPendingDrain(BattlegroundQueueTypeId queueTypeId, + BattlegroundTypeId bgTypeId, BattlegroundBracketId bracketId); + + // Re-issues one ScheduleQueueUpdate per tracked entry every few seconds + // while the entry's bracket still holds enough uninvited waiters to pop + // another instance; entries below that threshold are dropped (declines and + // manual joins re-trigger the core queue on their own). Runs even while + // the periodic pass is disabled so a `.bgevents run` backlog still drains. + void UpdatePendingDrains(uint32 diff); + void BroadcastWarning() const; // Rebuilds _pool from the configured _poolRaw against the battleground @@ -197,7 +274,8 @@ class BgAutoQueue uint32 _initialDelayMs = 0; uint32 _warningLeadMs = 60u * 1000u; bool _crossFaction = true; - bool _evenTeamsStrict = false; // mod-cfbg EvenTeams with threshold 0 (see LoadConfig) + bool _cfbgEnabled = false; // mirrored CFBG.Enable; selects which queue buckets the matcher reads + bool _evenTeamsStrict = false; // mod-cfbg EvenTeams active: match formation pins allowedDiff to 0 (see LoadConfig) bool _skipGameMasters = true; bool _skipAfk = true; std::vector _skipAuras; // aura ids that exclude a player from a pass @@ -209,6 +287,17 @@ class BgAutoQueue // Latches the CrossFaction/matcher WARN to once per config load (mutable: // CountUninvitedWaiters is const). mutable bool _matcherWarnLogged = false; + // Latches the stale-queue-bucket WARN (entries unreachable after a + // CFBG.Enable reload) to once per config load. + mutable bool _staleQueueWarnLogged = false; + // Latches the no-deserter-template WARN to once per config load. + mutable bool _deserterWarnLogged = false; + // First ResolvePool after a config load reports rejected entries at WARN + // (visible with stock log levels); later passes repeat them at DEBUG. + bool _poolResolveLogged = false; + + std::vector _pendingDrains; + uint32 _drainTimerMs = 0; }; #define sBgAutoQueue BgAutoQueue::instance() diff --git a/src/cs_bg_auto_queue.cpp b/src/cs_bg_auto_queue.cpp index 168d2c9..e6b8695 100644 --- a/src/cs_bg_auto_queue.cpp +++ b/src/cs_bg_auto_queue.cpp @@ -118,10 +118,10 @@ class bg_auto_queue_commandscript : public CommandScript } if (result.bracketsWithoutBg > 0) - handler->PSendSysMessage(" no eligible battleground for their level: {} bracket(s)", result.bracketsWithoutBg); + handler->PSendSysMessage(" no eligible battleground (level range, disables, or empty pool): {} bracket(s)", result.bracketsWithoutBg); if (result.skippedAtQueueTime > 0) - handler->PSendSysMessage(" dropped at queue time (state change, veto, or off-bracket): {} player(s)", result.skippedAtQueueTime); + handler->PSendSysMessage(" dropped at queue time (state change, veto, or level/bracket mismatch): {} player(s)", result.skippedAtQueueTime); } };