Skip to content

Commit 81dc925

Browse files
fix: even out lopsided teams when the battleground starts (#180)
1 parent 8a29b6a commit 81dc925

4 files changed

Lines changed: 220 additions & 25 deletions

File tree

conf/CFBG.conf.dist

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,17 @@
121121
# grouped players are never moved.
122122
# Default: 1 - (Enabled)
123123
# 0 - (Disabled)
124+
#
125+
# CFBG.BalanceTeamsAtStart.Enabled
126+
# Description: When the gates open, if declined or ignored invites left the
127+
# physical teams uneven by 2 or more, flip surplus entrants to
128+
# the smaller side so the match starts within 1 of even (e.g. a
129+
# 4v1 opens 3v2). Real battleground premades are never split; a
130+
# solo player merely sitting in a social/questing party still
131+
# counts as flippable. Decisions use live head counts only, so
132+
# reservations that never showed up don't skew the repair.
133+
# Default: 1 - (Enabled)
134+
# 0 - (Disabled)
124135

125136
CFBG.Enable = 1
126137
CFBG.Battlefield.Enable = 1
@@ -141,3 +152,4 @@ CFBG.ResetCooldowns = 0
141152
CFBG.Show.PlayerName = 0
142153
CFBG.RandomRaceSelection = 1
143154
CFBG.BalanceTeamsOnEntry.Enabled = 1
155+
CFBG.BalanceTeamsAtStart.Enabled = 1

src/CFBG.cpp

Lines changed: 164 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
#include "Chat.h"
1212
#include "Config.h"
1313
#include "Containers.h"
14+
#include "Group.h"
1415
#include "Language.h"
16+
#include "Log.h"
1517
#include "ObjectAccessor.h"
1618
#include "Opcodes.h"
1719
#include "ReputationMgr.h"
@@ -136,6 +138,7 @@ void CFBG::LoadConfig()
136138
_IsEnableBalanceClassLowLevel = sConfigMgr->GetOption<bool>("CFBG.BalancedTeams.Class.LowLevel", true);
137139
_IsEnableResetCooldowns = sConfigMgr->GetOption<bool>("CFBG.ResetCooldowns", false);
138140
_IsEnableBalanceTeamsOnEntry = sConfigMgr->GetOption<bool>("CFBG.BalanceTeamsOnEntry.Enabled", true);
141+
_IsEnableBalanceTeamsAtStart = sConfigMgr->GetOption<bool>("CFBG.BalanceTeamsAtStart.Enabled", true);
139142
_showPlayerName = sConfigMgr->GetOption<bool>("CFBG.Show.PlayerName", false);
140143
_EvenTeamsMaxPlayersThreshold = sConfigMgr->GetOption<uint32>("CFBG.EvenTeams.MaxPlayersThreshold", 0);
141144
_MaxPlayersCountInGroup = sConfigMgr->GetOption<uint32>("CFBG.Players.Count.In.Group", 3);
@@ -341,11 +344,6 @@ void CFBG::EnforceBGTeamConsistency(Player* player)
341344
if (!bg || bg->isArena())
342345
return;
343346

344-
// EndBattleground already restored real identities; re-faking a ghost who
345-
// reclaims during WAIT_LEAVE would double-morph him for the rest of the window.
346-
if (bg->GetStatus() == STATUS_WAIT_LEAVE)
347-
return;
348-
349347
TeamId const assigned = player->GetBgTeamId();
350348

351349
// Native: must not carry a fake.
@@ -392,8 +390,11 @@ void CFBG::BalanceTeamsOnEntry(Battleground* bg, Player* player)
392390
if (bg->isArena() || bg->isRated())
393391
return;
394392

395-
// Solo entrants only: never split a party across teams.
396-
if (player->GetGroup())
393+
// Never split a genuine BG premade materialising here, but a solo-queued
394+
// player who merely sits in a social/questing party (e.g. a duo auto-queued
395+
// as two separate solo entries) is still eligible. At this hook -- before BG
396+
// raid placement -- GetGroup() is the social party.
397+
if (IsPartyCommittedToBG(player, player->GetGroup(), bg))
397398
return;
398399

399400
// Genuine first entry only: skip relog re-adds (already in the BG), otherwise
@@ -452,6 +453,150 @@ void CFBG::BalanceTeamsOnEntry(Battleground* bg, Player* player)
452453
startPos->GetPositionZ(), startPos->GetOrientation());
453454
}
454455

456+
bool CFBG::IsPartyCommittedToBG(Player* player, Group* group, Battleground* bg)
457+
{
458+
if (!group)
459+
return false;
460+
461+
for (auto const& slot : group->GetMemberSlots())
462+
{
463+
if (slot.guid == player->GetGUID())
464+
continue;
465+
466+
// Already standing in this instance.
467+
if (bg->GetPlayers().find(slot.guid) != bg->GetPlayers().end())
468+
return true;
469+
470+
// Or still porting in on an invite to it. An offline member can't be on
471+
// his way, so he never blocks the flip.
472+
Player* member = ObjectAccessor::FindConnectedPlayer(slot.guid);
473+
if (member && member->IsInvitedForBattlegroundInstance(bg->GetInstanceID()))
474+
return true;
475+
}
476+
477+
return false;
478+
}
479+
480+
void CFBG::BalanceTeamsAtStart(Battleground* bg)
481+
{
482+
// The gates just opened. Team selection was balanced when the invites went
483+
// out, but same-side no-shows with an empty backfill queue can leave the
484+
// physical teams grossly uneven (4v1). Nothing re-checks the split before the
485+
// doors open, so do it here: flip surplus entrants onto the smaller side
486+
// until the diff is at most 1.
487+
if (!IsEnableSystem() || !IsEnableBalanceTeamsAtStart())
488+
return;
489+
490+
if (!bg || bg->isArena() || bg->isRated())
491+
return;
492+
493+
// Decide on physical head counts only: the pending reservations that never
494+
// materialised are exactly what produced the imbalance, so the invited ledger
495+
// must not steer the repair. Each flip shrinks the diff by 2, so the loop
496+
// terminates when the teams are within 1 or no flippable candidate is left.
497+
while (true)
498+
{
499+
uint32 const countA = bg->GetPlayersCountByTeam(TEAM_ALLIANCE);
500+
uint32 const countH = bg->GetPlayersCountByTeam(TEAM_HORDE);
501+
uint32 const diff = countA > countH ? countA - countH : countH - countA;
502+
503+
if (diff < 2)
504+
break;
505+
506+
TeamId const larger = countA > countH ? TEAM_ALLIANCE : TEAM_HORDE;
507+
TeamId const smaller = larger == TEAM_ALLIANCE ? TEAM_HORDE : TEAM_ALLIANCE;
508+
509+
// Prefer flipping a faked player whose real faction is the smaller side:
510+
// the flip just unfakes him back to native (least disruption). Otherwise
511+
// take any flippable player on the larger side.
512+
Player* toFlip = nullptr;
513+
Player* fallback = nullptr;
514+
515+
for (auto const& [guid, player] : bg->GetPlayers())
516+
{
517+
if (!player || player->GetBgTeamId() != larger)
518+
continue;
519+
520+
// Never split a real premade. Inside the BG the social party is the
521+
// original group -- GetGroup() is the BG raid at this point.
522+
if (IsPartyCommittedToBG(player, player->GetOriginalGroup(), bg))
523+
continue;
524+
525+
if (IsPlayerFake(player) && player->GetTeamId(true) == smaller)
526+
{
527+
toFlip = player;
528+
break;
529+
}
530+
531+
if (!fallback)
532+
fallback = player;
533+
}
534+
535+
if (!toFlip)
536+
toFlip = fallback;
537+
538+
// Nothing left to flip: any residual imbalance is rooted in premades we
539+
// won't split. Open the match as-is -- the existing 5-minute premature
540+
// finish path handles a still-degenerate game, exactly as today.
541+
if (!toFlip)
542+
break;
543+
544+
// Keep both the physical counts and the invited ledger zero-sum with the
545+
// player's future leave-time decrement (mirrors BalanceTeamsOnEntry).
546+
bg->UpdatePlayersCountByTeam(larger, true);
547+
bg->UpdatePlayersCountByTeam(smaller, false);
548+
bg->DecreaseInvitedCount(larger);
549+
bg->IncreaseInvitedCount(smaller);
550+
toFlip->GetBGData().bgTeamId = smaller;
551+
552+
// Move him into the smaller side's BG raid. Remove from the old raid
553+
// first: AddOrSetPlayerToCorrectBgGroup early-returns while the player is
554+
// still in a BG group.
555+
if (Group* oldRaid = bg->GetBgRaid(larger))
556+
if (oldRaid->IsMember(toFlip->GetGUID()))
557+
if (!oldRaid->RemoveMember(toFlip->GetGUID())) // group was disbanded
558+
bg->SetBgRaid(larger, nullptr);
559+
bg->AddOrSetPlayerToCorrectBgGroup(toFlip, smaller);
560+
561+
// Apply/clear/redo the fake for the new side.
562+
EnforceBGTeamConsistency(toFlip);
563+
564+
// The flip changed his race/faction; refresh every client's cached
565+
// identity for him (and his for theirs) so nobody keeps the pre-flip
566+
// race in their name-query cache -- same path a fresh entrant takes via
567+
// OnBattlegroundAddPlayer.
568+
FitPlayerInTeam(toFlip, bg);
569+
570+
// AV forced reactions track the assigned side, so refresh them for a
571+
// player who is now cross-faction (a now-native player had them cleared
572+
// by the unfake). Mirrors ValidatePlayerForBG's entry-time handling.
573+
if (!IsPlayingNative(toFlip) && bg->GetMapId() == MapAlteracValley)
574+
{
575+
if (smaller == TEAM_HORDE)
576+
{
577+
toFlip->GetReputationMgr().ApplyForceReaction(FACTION_FROSTWOLF_CLAN, REP_FRIENDLY, true);
578+
toFlip->GetReputationMgr().ApplyForceReaction(FACTION_STORMPIKE_GUARD, REP_HOSTILE, true);
579+
}
580+
else
581+
{
582+
toFlip->GetReputationMgr().ApplyForceReaction(FACTION_FROSTWOLF_CLAN, REP_HOSTILE, true);
583+
toFlip->GetReputationMgr().ApplyForceReaction(FACTION_STORMPIKE_GUARD, REP_FRIENDLY, true);
584+
}
585+
586+
toFlip->GetReputationMgr().SendForceReactions();
587+
}
588+
589+
// Move him from his old base to the smaller side's.
590+
Position const* startPos = bg->GetTeamStartPosition(smaller);
591+
toFlip->TeleportTo(bg->GetMapId(), startPos->GetPositionX(), startPos->GetPositionY(),
592+
startPos->GetPositionZ(), startPos->GetOrientation());
593+
594+
LOG_DEBUG("module", "mod-cfbg: BalanceTeamsAtStart flipped {} to {} in instance {} ({}v{})",
595+
toFlip->GetName(), static_cast<uint32>(smaller), bg->GetInstanceID(),
596+
bg->GetPlayersCountByTeam(TEAM_ALLIANCE), bg->GetPlayersCountByTeam(TEAM_HORDE));
597+
}
598+
}
599+
455600
uint32 CFBG::GetMorphFromRace(uint8 race, uint8 gender)
456601
{
457602
switch (race)
@@ -779,8 +924,18 @@ std::array<uint32, 2> CFBG::GetProjectedBaseCounts(Battleground* bg, Battlegroun
779924
// accept deleted their ginfo; AddPlayer has not run yet). The BG's invited
780925
// ledger still holds every reservation, so clamp up to it; max() degrades
781926
// gracefully if either register is skewed.
782-
counts[TEAM_ALLIANCE] = std::max(counts[TEAM_ALLIANCE], bg->GetInvitedCount(TEAM_ALLIANCE));
783-
counts[TEAM_HORDE] = std::max(counts[TEAM_HORDE], bg->GetInvitedCount(TEAM_HORDE));
927+
uint32 const computedA = counts[TEAM_ALLIANCE];
928+
uint32 const computedH = counts[TEAM_HORDE];
929+
counts[TEAM_ALLIANCE] = std::max(computedA, bg->GetInvitedCount(TEAM_ALLIANCE));
930+
counts[TEAM_HORDE] = std::max(computedH, bg->GetInvitedCount(TEAM_HORDE));
931+
932+
// The ledger exceeding the physical + invited-queued tally is the signature
933+
// of a leaked reservation steering selection. In-flight accepts trip this
934+
// briefly and legitimately, so it stays at debug for operators hunting a
935+
// persistent skew.
936+
if (counts[TEAM_ALLIANCE] > computedA || counts[TEAM_HORDE] > computedH)
937+
LOG_DEBUG("module", "mod-cfbg: instance {} projections clamped by invited ledger (A {}->{}, H {}->{}), possible phantom reservation",
938+
bg->GetInstanceID(), computedA, counts[TEAM_ALLIANCE], computedH, counts[TEAM_HORDE]);
784939

785940
return counts;
786941
}

src/CFBG.h

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ class CFBG
131131
inline bool IsEnableEvenTeams() const { return _IsEnableEvenTeams; }
132132
inline bool IsEnableResetCooldowns() const { return _IsEnableResetCooldowns; }
133133
inline bool IsEnableBalanceTeamsOnEntry() const { return _IsEnableBalanceTeamsOnEntry; }
134+
inline bool IsEnableBalanceTeamsAtStart() const { return _IsEnableBalanceTeamsAtStart; }
134135
inline uint32 EvenTeamsMaxPlayersThreshold() const { return _EvenTeamsMaxPlayersThreshold; }
135136
inline uint32 GetMaxPlayersCountInGroup() const { return _MaxPlayersCountInGroup; }
136137
inline uint8 GetBalanceClassMinLevel() const { return _balanceClassMinLevel; }
@@ -165,6 +166,12 @@ class CFBG
165166
// Forces race/faction/m_team/fake-store into agreement with the player's
166167
// assigned BG team (GetBgTeamId()); idempotent and self-correcting.
167168
void EnforceBGTeamConsistency(Player* player);
169+
170+
// Doors-open head-count repair: when declined/no-show invites left the teams
171+
// grossly uneven (diff >= 2) once the gates open, flip surplus solo entrants
172+
// to the smaller side so the match starts within 1 of even. Real BG premades
173+
// are never split. Fired from OnBattlegroundStart.
174+
void BalanceTeamsAtStart(Battleground* bg);
168175
void SetFakeRaceAndMorph(Player* player);
169176
void SetFakeRaceAndMorphForBF(Player* player, TeamId assignedTeam);
170177
void SetFactionForRace(Player* player, uint8 Race, TeamId teamId);
@@ -202,6 +209,13 @@ class CFBG
202209
// Arrival-time head-count correction for solo entrants.
203210
void BalanceTeamsOnEntry(Battleground* bg, Player* player);
204211

212+
// True when another member of `player`'s social party is already committed to
213+
// this bg instance (already entered, or holding an invite to it). Flipping the
214+
// player would then split a real premade that is materializing here, so the
215+
// head-count repairs leave them put. `group` is the social party: GetGroup()
216+
// before the BG raid is assigned (entry hook), GetOriginalGroup() once inside.
217+
bool IsPartyCommittedToBG(Player* player, Group* group, Battleground* bg);
218+
205219
RandomSkinInfo GetRandomRaceMorph(TeamId team, uint8 playerClass, uint8 gender);
206220

207221
uint32 GetMorphFromRace(uint8 race, uint8 gender);
@@ -242,6 +256,7 @@ class CFBG
242256
bool _IsEnableEvenTeams;
243257
bool _IsEnableResetCooldowns;
244258
bool _IsEnableBalanceTeamsOnEntry;
259+
bool _IsEnableBalanceTeamsAtStart;
245260
bool _showPlayerName;
246261
bool _randomizeRaces;
247262
uint32 _EvenTeamsMaxPlayersThreshold;

src/CFBG_SC.cpp

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class CFBG_BG : public BGScript
2121
CFBG_BG() : BGScript("CFBG_BG", {
2222
ALLBATTLEGROUNDHOOK_ON_BATTLEGROUND_BEFORE_ADD_PLAYER,
2323
ALLBATTLEGROUNDHOOK_ON_BATTLEGROUND_ADD_PLAYER,
24-
ALLBATTLEGROUNDHOOK_ON_BATTLEGROUND_END_REWARD,
24+
ALLBATTLEGROUNDHOOK_ON_BATTLEGROUND_START,
2525
ALLBATTLEGROUNDHOOK_ON_BATTLEGROUND_REMOVE_PLAYER_AT_LEAVE,
2626
ALLBATTLEGROUNDHOOK_ON_ADD_GROUP,
2727
ALLBATTLEGROUNDHOOK_CAN_FILL_PLAYERS_TO_BG,
@@ -34,6 +34,11 @@ class CFBG_BG : public BGScript
3434
sCFBG->ValidatePlayerForBG(bg, player);
3535
}
3636

37+
void OnBattlegroundStart(Battleground* bg) override
38+
{
39+
sCFBG->BalanceTeamsAtStart(bg);
40+
}
41+
3742
void OnBattlegroundAddPlayer(Battleground* bg, Player* player) override
3843
{
3944
sCFBG->FitPlayerInTeam(player, bg);
@@ -42,15 +47,6 @@ class CFBG_BG : public BGScript
4247
player->RemoveArenaSpellCooldowns(true);
4348
}
4449

45-
void OnBattlegroundEndReward(Battleground* bg, Player* player, TeamId /*winnerTeamId*/) override
46-
{
47-
if (!sCFBG->IsEnableSystem() || !bg || !player || bg->isArena())
48-
return;
49-
50-
if (sCFBG->IsPlayerFake(player))
51-
sCFBG->ClearFakePlayer(player);
52-
}
53-
5450
void OnBattlegroundRemovePlayerAtLeave(Battleground* bg, Player* player) override
5551
{
5652
if (!sCFBG->IsEnableSystem() || bg->isArena())
@@ -104,6 +100,7 @@ class CFBG_Player : public PlayerScript
104100
PLAYERHOOK_ON_LOGIN,
105101
PLAYERHOOK_ON_LOGOUT,
106102
PLAYERHOOK_ON_UPDATE_ZONE,
103+
PLAYERHOOK_ON_UPDATE_FACTION,
107104
PLAYERHOOK_CAN_JOIN_IN_BATTLEGROUND_QUEUE,
108105
PLAYERHOOK_ON_BEFORE_UPDATE,
109106
PLAYERHOOK_ON_BEFORE_SEND_CHAT_MESSAGE,
@@ -156,12 +153,12 @@ class CFBG_Player : public PlayerScript
156153
if (!sCFBG->IsPlayerFake(player))
157154
return;
158155

159-
// Battleground fakes are owned by the BG hooks (OnBattlegroundRemovePlayerAtLeave
160-
// / OnBattlegroundEndReward), not this WG cleanup. A battleground zone is not a
161-
// WG battlefield, so without this guard entering a BG would clear a cross-faction
162-
// player's fake right after the entry morph (Battleground::AddPlayer runs before
163-
// UpdateZone), leaving GetTeamId() on the real faction while bgTeamId stays on the
164-
// assigned side -- the flag-capture/win desync. WG players are not InBattleground().
156+
// Battleground fakes are owned by the BG hook OnBattlegroundRemovePlayerAtLeave,
157+
// not this WG cleanup. A battleground zone is not a WG battlefield, so without
158+
// this guard entering a BG would clear a cross-faction player's fake right after
159+
// the entry morph (Battleground::AddPlayer runs before UpdateZone), leaving
160+
// GetTeamId() on the real faction while bgTeamId stays on the assigned side --
161+
// the flag-capture/win desync. WG players are not InBattleground().
165162
if (player->InBattleground())
166163
return;
167164

@@ -170,6 +167,22 @@ class CFBG_Player : public PlayerScript
170167
sCFBG->ClearFakePlayer(player);
171168
}
172169

170+
// Core's Player::SetFactionForRace resets m_team to the real race, then
171+
// applies the real faction template only when m_team matches that race --
172+
// this hook is the designed cooperation point. Keep m_team on the fake side
173+
// so a faked BG player's assigned faction survives core faction resets (a
174+
// stray or redundant .gm off would otherwise revert nameplate/hostility/team
175+
// until the next resurrect).
176+
void OnPlayerUpdateFaction(Player* player) override
177+
{
178+
if (!sCFBG->IsEnableSystem() || !player->InBattleground())
179+
return;
180+
181+
if (FakePlayer const* fake = sCFBG->GetFakePlayer(player))
182+
if (player->GetTeamId() != fake->FakeTeamID)
183+
player->setTeamId(fake->FakeTeamID);
184+
}
185+
173186
bool OnPlayerCanJoinInBattlegroundQueue(Player* player, ObjectGuid /*BattlemasterGuid*/ , BattlegroundTypeId /*BGTypeID*/, uint8 joinAsGroup, GroupJoinBattlegroundResult& err) override
174187
{
175188
if (!sCFBG->IsEnableSystem())

0 commit comments

Comments
 (0)