From 4ba4066208af7dbbdc03bb80a08f0239b0f40e61 Mon Sep 17 00:00:00 2001 From: FrancescoBorzi Date: Sat, 11 Jul 2026 15:52:13 +0200 Subject: [PATCH] fix: even out lopsided teams when the battleground starts --- conf/CFBG.conf.dist | 12 +++ src/CFBG.cpp | 173 +++++++++++++++++++++++++++++++++++++++++--- src/CFBG.h | 15 ++++ src/CFBG_SC.cpp | 45 ++++++++---- 4 files changed, 220 insertions(+), 25 deletions(-) diff --git a/conf/CFBG.conf.dist b/conf/CFBG.conf.dist index 28d8a4e..b4529cc 100644 --- a/conf/CFBG.conf.dist +++ b/conf/CFBG.conf.dist @@ -121,6 +121,17 @@ # grouped players are never moved. # Default: 1 - (Enabled) # 0 - (Disabled) +# +# CFBG.BalanceTeamsAtStart.Enabled +# Description: When the gates open, if declined or ignored invites left the +# physical teams uneven by 2 or more, flip surplus entrants to +# the smaller side so the match starts within 1 of even (e.g. a +# 4v1 opens 3v2). Real battleground premades are never split; a +# solo player merely sitting in a social/questing party still +# counts as flippable. Decisions use live head counts only, so +# reservations that never showed up don't skew the repair. +# Default: 1 - (Enabled) +# 0 - (Disabled) CFBG.Enable = 1 CFBG.Battlefield.Enable = 1 @@ -141,3 +152,4 @@ CFBG.ResetCooldowns = 0 CFBG.Show.PlayerName = 0 CFBG.RandomRaceSelection = 1 CFBG.BalanceTeamsOnEntry.Enabled = 1 +CFBG.BalanceTeamsAtStart.Enabled = 1 diff --git a/src/CFBG.cpp b/src/CFBG.cpp index d1a18b9..a635a5d 100644 --- a/src/CFBG.cpp +++ b/src/CFBG.cpp @@ -11,7 +11,9 @@ #include "Chat.h" #include "Config.h" #include "Containers.h" +#include "Group.h" #include "Language.h" +#include "Log.h" #include "ObjectAccessor.h" #include "Opcodes.h" #include "ReputationMgr.h" @@ -136,6 +138,7 @@ void CFBG::LoadConfig() _IsEnableBalanceClassLowLevel = sConfigMgr->GetOption("CFBG.BalancedTeams.Class.LowLevel", true); _IsEnableResetCooldowns = sConfigMgr->GetOption("CFBG.ResetCooldowns", false); _IsEnableBalanceTeamsOnEntry = sConfigMgr->GetOption("CFBG.BalanceTeamsOnEntry.Enabled", true); + _IsEnableBalanceTeamsAtStart = sConfigMgr->GetOption("CFBG.BalanceTeamsAtStart.Enabled", true); _showPlayerName = sConfigMgr->GetOption("CFBG.Show.PlayerName", false); _EvenTeamsMaxPlayersThreshold = sConfigMgr->GetOption("CFBG.EvenTeams.MaxPlayersThreshold", 0); _MaxPlayersCountInGroup = sConfigMgr->GetOption("CFBG.Players.Count.In.Group", 3); @@ -341,11 +344,6 @@ void CFBG::EnforceBGTeamConsistency(Player* player) if (!bg || bg->isArena()) return; - // EndBattleground already restored real identities; re-faking a ghost who - // reclaims during WAIT_LEAVE would double-morph him for the rest of the window. - if (bg->GetStatus() == STATUS_WAIT_LEAVE) - return; - TeamId const assigned = player->GetBgTeamId(); // Native: must not carry a fake. @@ -392,8 +390,11 @@ void CFBG::BalanceTeamsOnEntry(Battleground* bg, Player* player) if (bg->isArena() || bg->isRated()) return; - // Solo entrants only: never split a party across teams. - if (player->GetGroup()) + // Never split a genuine BG premade materialising here, but a solo-queued + // player who merely sits in a social/questing party (e.g. a duo auto-queued + // as two separate solo entries) is still eligible. At this hook -- before BG + // raid placement -- GetGroup() is the social party. + if (IsPartyCommittedToBG(player, player->GetGroup(), bg)) return; // Genuine first entry only: skip relog re-adds (already in the BG), otherwise @@ -452,6 +453,150 @@ void CFBG::BalanceTeamsOnEntry(Battleground* bg, Player* player) startPos->GetPositionZ(), startPos->GetOrientation()); } +bool CFBG::IsPartyCommittedToBG(Player* player, Group* group, Battleground* bg) +{ + if (!group) + return false; + + for (auto const& slot : group->GetMemberSlots()) + { + if (slot.guid == player->GetGUID()) + continue; + + // Already standing in this instance. + if (bg->GetPlayers().find(slot.guid) != bg->GetPlayers().end()) + return true; + + // Or still porting in on an invite to it. An offline member can't be on + // his way, so he never blocks the flip. + Player* member = ObjectAccessor::FindConnectedPlayer(slot.guid); + if (member && member->IsInvitedForBattlegroundInstance(bg->GetInstanceID())) + return true; + } + + return false; +} + +void CFBG::BalanceTeamsAtStart(Battleground* bg) +{ + // The gates just opened. Team selection was balanced when the invites went + // out, but same-side no-shows with an empty backfill queue can leave the + // physical teams grossly uneven (4v1). Nothing re-checks the split before the + // doors open, so do it here: flip surplus entrants onto the smaller side + // until the diff is at most 1. + if (!IsEnableSystem() || !IsEnableBalanceTeamsAtStart()) + return; + + if (!bg || bg->isArena() || bg->isRated()) + return; + + // Decide on physical head counts only: the pending reservations that never + // materialised are exactly what produced the imbalance, so the invited ledger + // must not steer the repair. Each flip shrinks the diff by 2, so the loop + // terminates when the teams are within 1 or no flippable candidate is left. + while (true) + { + uint32 const countA = bg->GetPlayersCountByTeam(TEAM_ALLIANCE); + uint32 const countH = bg->GetPlayersCountByTeam(TEAM_HORDE); + uint32 const diff = countA > countH ? countA - countH : countH - countA; + + if (diff < 2) + break; + + TeamId const larger = countA > countH ? TEAM_ALLIANCE : TEAM_HORDE; + TeamId const smaller = larger == TEAM_ALLIANCE ? TEAM_HORDE : TEAM_ALLIANCE; + + // Prefer flipping a faked player whose real faction is the smaller side: + // the flip just unfakes him back to native (least disruption). Otherwise + // take any flippable player on the larger side. + Player* toFlip = nullptr; + Player* fallback = nullptr; + + for (auto const& [guid, player] : bg->GetPlayers()) + { + if (!player || player->GetBgTeamId() != larger) + continue; + + // Never split a real premade. Inside the BG the social party is the + // original group -- GetGroup() is the BG raid at this point. + if (IsPartyCommittedToBG(player, player->GetOriginalGroup(), bg)) + continue; + + if (IsPlayerFake(player) && player->GetTeamId(true) == smaller) + { + toFlip = player; + break; + } + + if (!fallback) + fallback = player; + } + + if (!toFlip) + toFlip = fallback; + + // Nothing left to flip: any residual imbalance is rooted in premades we + // won't split. Open the match as-is -- the existing 5-minute premature + // finish path handles a still-degenerate game, exactly as today. + if (!toFlip) + break; + + // Keep both the physical counts and the invited ledger zero-sum with the + // player's future leave-time decrement (mirrors BalanceTeamsOnEntry). + bg->UpdatePlayersCountByTeam(larger, true); + bg->UpdatePlayersCountByTeam(smaller, false); + bg->DecreaseInvitedCount(larger); + bg->IncreaseInvitedCount(smaller); + toFlip->GetBGData().bgTeamId = smaller; + + // Move him into the smaller side's BG raid. Remove from the old raid + // first: AddOrSetPlayerToCorrectBgGroup early-returns while the player is + // still in a BG group. + if (Group* oldRaid = bg->GetBgRaid(larger)) + if (oldRaid->IsMember(toFlip->GetGUID())) + if (!oldRaid->RemoveMember(toFlip->GetGUID())) // group was disbanded + bg->SetBgRaid(larger, nullptr); + bg->AddOrSetPlayerToCorrectBgGroup(toFlip, smaller); + + // Apply/clear/redo the fake for the new side. + EnforceBGTeamConsistency(toFlip); + + // The flip changed his race/faction; refresh every client's cached + // identity for him (and his for theirs) so nobody keeps the pre-flip + // race in their name-query cache -- same path a fresh entrant takes via + // OnBattlegroundAddPlayer. + FitPlayerInTeam(toFlip, bg); + + // AV forced reactions track the assigned side, so refresh them for a + // player who is now cross-faction (a now-native player had them cleared + // by the unfake). Mirrors ValidatePlayerForBG's entry-time handling. + if (!IsPlayingNative(toFlip) && bg->GetMapId() == MapAlteracValley) + { + if (smaller == TEAM_HORDE) + { + toFlip->GetReputationMgr().ApplyForceReaction(FACTION_FROSTWOLF_CLAN, REP_FRIENDLY, true); + toFlip->GetReputationMgr().ApplyForceReaction(FACTION_STORMPIKE_GUARD, REP_HOSTILE, true); + } + else + { + toFlip->GetReputationMgr().ApplyForceReaction(FACTION_FROSTWOLF_CLAN, REP_HOSTILE, true); + toFlip->GetReputationMgr().ApplyForceReaction(FACTION_STORMPIKE_GUARD, REP_FRIENDLY, true); + } + + toFlip->GetReputationMgr().SendForceReactions(); + } + + // Move him from his old base to the smaller side's. + Position const* startPos = bg->GetTeamStartPosition(smaller); + toFlip->TeleportTo(bg->GetMapId(), startPos->GetPositionX(), startPos->GetPositionY(), + startPos->GetPositionZ(), startPos->GetOrientation()); + + LOG_DEBUG("module", "mod-cfbg: BalanceTeamsAtStart flipped {} to {} in instance {} ({}v{})", + toFlip->GetName(), static_cast(smaller), bg->GetInstanceID(), + bg->GetPlayersCountByTeam(TEAM_ALLIANCE), bg->GetPlayersCountByTeam(TEAM_HORDE)); + } +} + uint32 CFBG::GetMorphFromRace(uint8 race, uint8 gender) { switch (race) @@ -779,8 +924,18 @@ std::array CFBG::GetProjectedBaseCounts(Battleground* bg, Battlegroun // accept deleted their ginfo; AddPlayer has not run yet). The BG's invited // ledger still holds every reservation, so clamp up to it; max() degrades // gracefully if either register is skewed. - counts[TEAM_ALLIANCE] = std::max(counts[TEAM_ALLIANCE], bg->GetInvitedCount(TEAM_ALLIANCE)); - counts[TEAM_HORDE] = std::max(counts[TEAM_HORDE], bg->GetInvitedCount(TEAM_HORDE)); + uint32 const computedA = counts[TEAM_ALLIANCE]; + uint32 const computedH = counts[TEAM_HORDE]; + counts[TEAM_ALLIANCE] = std::max(computedA, bg->GetInvitedCount(TEAM_ALLIANCE)); + counts[TEAM_HORDE] = std::max(computedH, bg->GetInvitedCount(TEAM_HORDE)); + + // The ledger exceeding the physical + invited-queued tally is the signature + // of a leaked reservation steering selection. In-flight accepts trip this + // briefly and legitimately, so it stays at debug for operators hunting a + // persistent skew. + if (counts[TEAM_ALLIANCE] > computedA || counts[TEAM_HORDE] > computedH) + LOG_DEBUG("module", "mod-cfbg: instance {} projections clamped by invited ledger (A {}->{}, H {}->{}), possible phantom reservation", + bg->GetInstanceID(), computedA, counts[TEAM_ALLIANCE], computedH, counts[TEAM_HORDE]); return counts; } diff --git a/src/CFBG.h b/src/CFBG.h index 337c7fc..85752ae 100644 --- a/src/CFBG.h +++ b/src/CFBG.h @@ -131,6 +131,7 @@ class CFBG inline bool IsEnableEvenTeams() const { return _IsEnableEvenTeams; } inline bool IsEnableResetCooldowns() const { return _IsEnableResetCooldowns; } inline bool IsEnableBalanceTeamsOnEntry() const { return _IsEnableBalanceTeamsOnEntry; } + inline bool IsEnableBalanceTeamsAtStart() const { return _IsEnableBalanceTeamsAtStart; } inline uint32 EvenTeamsMaxPlayersThreshold() const { return _EvenTeamsMaxPlayersThreshold; } inline uint32 GetMaxPlayersCountInGroup() const { return _MaxPlayersCountInGroup; } inline uint8 GetBalanceClassMinLevel() const { return _balanceClassMinLevel; } @@ -165,6 +166,12 @@ class CFBG // Forces race/faction/m_team/fake-store into agreement with the player's // assigned BG team (GetBgTeamId()); idempotent and self-correcting. void EnforceBGTeamConsistency(Player* player); + + // Doors-open head-count repair: when declined/no-show invites left the teams + // grossly uneven (diff >= 2) once the gates open, flip surplus solo entrants + // to the smaller side so the match starts within 1 of even. Real BG premades + // are never split. Fired from OnBattlegroundStart. + void BalanceTeamsAtStart(Battleground* bg); void SetFakeRaceAndMorph(Player* player); void SetFakeRaceAndMorphForBF(Player* player, TeamId assignedTeam); void SetFactionForRace(Player* player, uint8 Race, TeamId teamId); @@ -202,6 +209,13 @@ class CFBG // Arrival-time head-count correction for solo entrants. void BalanceTeamsOnEntry(Battleground* bg, Player* player); + // True when another member of `player`'s social party is already committed to + // this bg instance (already entered, or holding an invite to it). Flipping the + // player would then split a real premade that is materializing here, so the + // head-count repairs leave them put. `group` is the social party: GetGroup() + // before the BG raid is assigned (entry hook), GetOriginalGroup() once inside. + bool IsPartyCommittedToBG(Player* player, Group* group, Battleground* bg); + RandomSkinInfo GetRandomRaceMorph(TeamId team, uint8 playerClass, uint8 gender); uint32 GetMorphFromRace(uint8 race, uint8 gender); @@ -242,6 +256,7 @@ class CFBG bool _IsEnableEvenTeams; bool _IsEnableResetCooldowns; bool _IsEnableBalanceTeamsOnEntry; + bool _IsEnableBalanceTeamsAtStart; bool _showPlayerName; bool _randomizeRaces; uint32 _EvenTeamsMaxPlayersThreshold; diff --git a/src/CFBG_SC.cpp b/src/CFBG_SC.cpp index c42bf31..769f7de 100644 --- a/src/CFBG_SC.cpp +++ b/src/CFBG_SC.cpp @@ -21,7 +21,7 @@ class CFBG_BG : public BGScript CFBG_BG() : BGScript("CFBG_BG", { ALLBATTLEGROUNDHOOK_ON_BATTLEGROUND_BEFORE_ADD_PLAYER, ALLBATTLEGROUNDHOOK_ON_BATTLEGROUND_ADD_PLAYER, - ALLBATTLEGROUNDHOOK_ON_BATTLEGROUND_END_REWARD, + ALLBATTLEGROUNDHOOK_ON_BATTLEGROUND_START, ALLBATTLEGROUNDHOOK_ON_BATTLEGROUND_REMOVE_PLAYER_AT_LEAVE, ALLBATTLEGROUNDHOOK_ON_ADD_GROUP, ALLBATTLEGROUNDHOOK_CAN_FILL_PLAYERS_TO_BG, @@ -34,6 +34,11 @@ class CFBG_BG : public BGScript sCFBG->ValidatePlayerForBG(bg, player); } + void OnBattlegroundStart(Battleground* bg) override + { + sCFBG->BalanceTeamsAtStart(bg); + } + void OnBattlegroundAddPlayer(Battleground* bg, Player* player) override { sCFBG->FitPlayerInTeam(player, bg); @@ -42,15 +47,6 @@ class CFBG_BG : public BGScript player->RemoveArenaSpellCooldowns(true); } - void OnBattlegroundEndReward(Battleground* bg, Player* player, TeamId /*winnerTeamId*/) override - { - if (!sCFBG->IsEnableSystem() || !bg || !player || bg->isArena()) - return; - - if (sCFBG->IsPlayerFake(player)) - sCFBG->ClearFakePlayer(player); - } - void OnBattlegroundRemovePlayerAtLeave(Battleground* bg, Player* player) override { if (!sCFBG->IsEnableSystem() || bg->isArena()) @@ -104,6 +100,7 @@ class CFBG_Player : public PlayerScript PLAYERHOOK_ON_LOGIN, PLAYERHOOK_ON_LOGOUT, PLAYERHOOK_ON_UPDATE_ZONE, + PLAYERHOOK_ON_UPDATE_FACTION, PLAYERHOOK_CAN_JOIN_IN_BATTLEGROUND_QUEUE, PLAYERHOOK_ON_BEFORE_UPDATE, PLAYERHOOK_ON_BEFORE_SEND_CHAT_MESSAGE, @@ -156,12 +153,12 @@ class CFBG_Player : public PlayerScript if (!sCFBG->IsPlayerFake(player)) return; - // Battleground fakes are owned by the BG hooks (OnBattlegroundRemovePlayerAtLeave - // / OnBattlegroundEndReward), not this WG cleanup. A battleground zone is not a - // WG battlefield, so without this guard entering a BG would clear a cross-faction - // player's fake right after the entry morph (Battleground::AddPlayer runs before - // UpdateZone), leaving GetTeamId() on the real faction while bgTeamId stays on the - // assigned side -- the flag-capture/win desync. WG players are not InBattleground(). + // Battleground fakes are owned by the BG hook OnBattlegroundRemovePlayerAtLeave, + // not this WG cleanup. A battleground zone is not a WG battlefield, so without + // this guard entering a BG would clear a cross-faction player's fake right after + // the entry morph (Battleground::AddPlayer runs before UpdateZone), leaving + // GetTeamId() on the real faction while bgTeamId stays on the assigned side -- + // the flag-capture/win desync. WG players are not InBattleground(). if (player->InBattleground()) return; @@ -170,6 +167,22 @@ class CFBG_Player : public PlayerScript sCFBG->ClearFakePlayer(player); } + // Core's Player::SetFactionForRace resets m_team to the real race, then + // applies the real faction template only when m_team matches that race -- + // this hook is the designed cooperation point. Keep m_team on the fake side + // so a faked BG player's assigned faction survives core faction resets (a + // stray or redundant .gm off would otherwise revert nameplate/hostility/team + // until the next resurrect). + void OnPlayerUpdateFaction(Player* player) override + { + if (!sCFBG->IsEnableSystem() || !player->InBattleground()) + return; + + if (FakePlayer const* fake = sCFBG->GetFakePlayer(player)) + if (player->GetTeamId() != fake->FakeTeamID) + player->setTeamId(fake->FakeTeamID); + } + bool OnPlayerCanJoinInBattlegroundQueue(Player* player, ObjectGuid /*BattlemasterGuid*/ , BattlegroundTypeId /*BGTypeID*/, uint8 joinAsGroup, GroupJoinBattlegroundResult& err) override { if (!sCFBG->IsEnableSystem())