From: Lyberta <lyberta@lyberta.net>
Date: Tue, 13 Jun 2017 03:45:04 +0000 (+0300)
Subject: New design for bot autobalance.
X-Git-Tag: xonotic-v0.8.5~2482^2~22
X-Git-Url: https://git.rm.cloudns.org/?a=commitdiff_plain;h=2243a0c996e01e3b877ccac65d9693dfe311005b;p=xonotic%2Fxonotic-data.pk3dir.git

New design for bot autobalance.
---

diff --git a/qcsrc/server/autocvars.qh b/qcsrc/server/autocvars.qh
index cd042661f..8dd913e29 100644
--- a/qcsrc/server/autocvars.qh
+++ b/qcsrc/server/autocvars.qh
@@ -88,7 +88,7 @@ float autocvar_g_balance_superweapons_time;
 float autocvar_g_balance_selfdamagepercent;
 bool autocvar_g_balance_teams;
 bool autocvar_g_balance_teams_prevent_imbalance;
-float autocvar_g_balance_teams_scorefactor;
+//float autocvar_g_balance_teams_scorefactor;
 float autocvar_g_ballistics_density_corpse;
 float autocvar_g_ballistics_density_player;
 float autocvar_g_ballistics_mindistance;
diff --git a/qcsrc/server/command/cmd.qc b/qcsrc/server/command/cmd.qc
index da9fd8621..686eeae9a 100644
--- a/qcsrc/server/command/cmd.qc
+++ b/qcsrc/server/command/cmd.qc
@@ -319,82 +319,103 @@ void ClientCommand_selectteam(entity caller, float request, float argc)
 	{
 		case CMD_REQUEST_COMMAND:
 		{
-			if (argv(1) != "")
+			if (argv(1) == "")
 			{
-				if (IS_CLIENT(caller))
+				return;
+			}
+			if (!IS_CLIENT(caller))
+			{
+				return;
+			}
+			if (!teamplay)
+			{
+				sprint(caller, "^7selectteam can only be used in teamgames\n");
+				return;
+			}
+			if (caller.team_forced > 0)
+			{
+				sprint(caller, "^7selectteam can not be used as your team is forced\n");
+				return;
+			}
+			if (lockteams)
+			{
+				sprint(caller, "^7The game has already begun, you must wait until the next map to be able to join a team.\n");
+				return;
+			}
+			float selection;
+			switch (argv(1))
+			{
+				case "red":
 				{
-					if (teamplay)
-					{
-						if (caller.team_forced <= 0)
-						{
-							if (!lockteams)
-							{
-								float selection;
-
-								switch (argv(1))
-								{
-									case "red": selection = NUM_TEAM_1;
-										break;
-									case "blue": selection = NUM_TEAM_2;
-										break;
-									case "yellow": selection = NUM_TEAM_3;
-										break;
-									case "pink": selection = NUM_TEAM_4;
-										break;
-									case "auto": selection = (-1);
-										break;
-
-									default: selection = 0;
-										break;
-								}
-
-								if (selection)
-								{
-									if (caller.team == selection && selection != -1 && !IS_DEAD(caller))
-									{
-										sprint(caller, "^7You already are on that team.\n");
-									}
-									else if (caller.wasplayer && autocvar_g_changeteam_banned)
-									{
-										sprint(caller, "^1You cannot change team, forbidden by the server.\n");
-									}
-									else
-									{
-										if (autocvar_g_balance_teams && autocvar_g_balance_teams_prevent_imbalance)
-										{
-											CheckAllowedTeams(caller);
-											GetTeamCounts(caller);
-											if (!TeamSmallerEqThanTeam(Team_TeamToNumber(selection), Team_TeamToNumber(caller.team), caller))
-											{
-												Send_Notification(NOTIF_ONE, caller, MSG_INFO, INFO_TEAMCHANGE_LARGERTEAM);
-												return;
-											}
-										}
-										ClientKill_TeamChange(caller, selection);
-									}
-									if(!IS_PLAYER(caller))
-										caller.team_selected = true; // avoids asking again for team selection on join
-								}
-							}
-							else
-							{
-								sprint(caller, "^7The game has already begun, you must wait until the next map to be able to join a team.\n");
-							}
-						}
-						else
-						{
-							sprint(caller, "^7selectteam can not be used as your team is forced\n");
-						}
-					}
-					else
-					{
-						sprint(caller, "^7selectteam can only be used in teamgames\n");
-					}
+					selection = NUM_TEAM_1;
+					break;
+				}
+				case "blue":
+				{
+					selection = NUM_TEAM_2;
+					break;
+				}
+				case "yellow":
+				{
+					selection = NUM_TEAM_3;
+					break;
+				}
+				case "pink":
+				{
+					selection = NUM_TEAM_4;
+					break;
+				}
+				case "auto":
+				{
+					selection = (-1);
+					break;
+				}
+				default:
+				{
+					return;
 				}
+			}
+			if (caller.team == selection && selection != -1 && !IS_DEAD(caller))
+			{
+				sprint(caller, "^7You already are on that team.\n");
 				return;
 			}
-		}
+			if (caller.wasplayer && autocvar_g_changeteam_banned)
+			{
+				sprint(caller, "^1You cannot change team, forbidden by the server.\n");
+				return;
+			}
+			if (selection == -1)
+			{
+				ClientKill_TeamChange(caller, selection);
+				if (!IS_PLAYER(caller))
+				{
+					caller.team_selected = true; // avoids asking again for team selection on join
+				}
+				return;
+			}
+			if (autocvar_g_balance_teams && autocvar_g_balance_teams_prevent_imbalance)
+			{
+				CheckAllowedTeams(caller);
+				GetTeamCounts(caller);
+				if (caller.team == -1)
+				{
 
+				}
+				else if (!TeamSmallerEqThanTeam(Team_TeamToNumber(selection), Team_TeamToNumber(caller.team), caller, false))
+				{
+					PrintToChatAll("TeamSmallerEqThanTeam prevented team switch.");
+					Send_Notification(NOTIF_ONE, caller, MSG_INFO, INFO_TEAMCHANGE_LARGERTEAM);
+					return;
+				}
+			}
+			ClientKill_TeamChange(caller, selection);
+			if (!IS_PLAYER(caller))
+			{
+				caller.team_selected = true; // avoids asking again for team selection on join
+			}
+			return;
+		}
 		default:
 			sprint(caller, "Incorrect parameters for ^2selectteam^7\n");
 		case CMD_REQUEST_USAGE:
diff --git a/qcsrc/server/teamplay.qc b/qcsrc/server/teamplay.qc
index cdcf4a429..8206ceff2 100644
--- a/qcsrc/server/teamplay.qc
+++ b/qcsrc/server/teamplay.qc
@@ -169,24 +169,17 @@ void setcolor(entity this, int clr)
 #endif
 }
 
-void SetPlayerColors(entity pl, float _color)
+void SetPlayerColors(entity player, float _color)
 {
-	/*string s;
-	s = ftos(cl);
-	stuffcmd(pl, strcat("color ", s, " ", s, "\n")  );
-	pl.team = cl + 1;
-	//pl.clientcolors = pl.clientcolors - (pl.clientcolors & 15) + cl;
-	pl.clientcolors = 16*cl + cl;*/
-
-	float pants, shirt;
-	pants = _color & 0x0F;
-	shirt = _color & 0xF0;
-
-
-	if(teamplay) {
-		setcolor(pl, 16*pants + pants);
-	} else {
-		setcolor(pl, shirt + pants);
+	float pants = _color & 0x0F;
+	float shirt = _color & 0xF0;
+	if (teamplay)
+	{
+		setcolor(player, 16 * pants + pants);
+	}
+	else
+	{
+		setcolor(player, shirt + pants);
 	}
 }
 
@@ -194,6 +187,9 @@ bool SetPlayerTeamSimple(entity player, int teamnum)
 {
 	if (player.team == teamnum)
 	{
+		// This is important when players join the game and one of their color
+		// matches the team color while other doesn't. For example [BOT]Lion.
+		SetPlayerColors(player, teamnum - 1);
 		return true;
 	}
 	if (MUTATOR_CALLHOOK(Player_ChangeTeam, player, Team_TeamToNumber(
@@ -208,20 +204,19 @@ bool SetPlayerTeamSimple(entity player, int teamnum)
 	return true;
 }
 
-void SetPlayerTeam(entity pl, float t, float s, float noprint)
+void SetPlayerTeam(entity player, int destinationteam, int sourceteam, bool noprint)
 {
-	if (t == s)
+	int teamnum = Team_NumberToTeam(destinationteam);
+	if (!SetPlayerTeamSimple(player, teamnum))
 	{
 		return;
 	}
-	float teamnum = Team_NumberToTeam(t);
-	SetPlayerTeamSimple(pl, teamnum);
-	LogTeamchange(pl.playerid, pl.team, 3);  // log manual team join
+	LogTeamchange(player.playerid, player.team, 3);  // log manual team join
 	if (noprint)
 	{
 		return;
 	}
-	bprint(playername(pl, false), "^7 has changed from ", Team_NumberToColoredFullName(s), "^7 to ", Team_NumberToColoredFullName(t), "\n");
+	bprint(playername(player, false), "^7 has changed from ", Team_NumberToColoredFullName(sourceteam), "^7 to ", Team_NumberToColoredFullName(destinationteam), "\n");
 }
 
 // set c1...c4 to show what teams are allowed
@@ -533,66 +528,69 @@ void GetTeamCounts(entity ignore)
 	}
 }
 
-float TeamSmallerEqThanTeam(float ta, float tb, entity e)
+float TeamSmallerEqThanTeam(int teama, int teamb, entity e, bool usescore)
 {
+	// equal
+	if (teama == teamb)
+	{
+		return true;
+	}
 	// we assume that CheckAllowedTeams and GetTeamCounts have already been called
-	float f;
-	float ca = -1, cb = -1, cba = 0, cbb = 0, sa = 0, sb = 0;
+	float numplayersteama = -1, numplayersteamb = -1;
+	float numbotsteama = 0, numbotsteamb = 0;
+	float scoreteama = 0, scoreteamb = 0;
 
-	switch(ta)
+	switch (teama)
 	{
-		case 1: ca = c1; cba = numbotsteam1; sa = team1_score; break;
-		case 2: ca = c2; cba = numbotsteam2; sa = team2_score; break;
-		case 3: ca = c3; cba = numbotsteam3; sa = team3_score; break;
-		case 4: ca = c4; cba = numbotsteam4; sa = team4_score; break;
+		case 1: numplayersteama = c1; numbotsteama = numbotsteam1; scoreteama = team1_score; break;
+		case 2: numplayersteama = c2; numbotsteama = numbotsteam2; scoreteama = team2_score; break;
+		case 3: numplayersteama = c3; numbotsteama = numbotsteam3; scoreteama = team3_score; break;
+		case 4: numplayersteama = c4; numbotsteama = numbotsteam4; scoreteama = team4_score; break;
 	}
-	switch(tb)
+	switch (teamb)
 	{
-		case 1: cb = c1; cbb = numbotsteam1; sb = team1_score; break;
-		case 2: cb = c2; cbb = numbotsteam2; sb = team2_score; break;
-		case 3: cb = c3; cbb = numbotsteam3; sb = team3_score; break;
-		case 4: cb = c4; cbb = numbotsteam4; sb = team4_score; break;
+		case 1: numplayersteamb = c1; numbotsteamb = numbotsteam1; scoreteamb = team1_score; break;
+		case 2: numplayersteamb = c2; numbotsteamb = numbotsteam2; scoreteamb = team2_score; break;
+		case 3: numplayersteamb = c3; numbotsteamb = numbotsteam3; scoreteamb = team3_score; break;
+		case 4: numplayersteamb = c4; numbotsteamb = numbotsteam4; scoreteamb = team4_score; break;
 	}
 
 	// invalid
-	if(ca < 0 || cb < 0)
+	if (numplayersteama < 0 || numplayersteamb < 0)
 		return false;
 
-	// equal
-	if(ta == tb)
+	if ((IS_REAL_CLIENT(e) && bots_would_leave))
+	{
+		numplayersteama -= numbotsteama;
+		numplayersteamb -= numbotsteamb;
+	}
+	if (!usescore)
+	{
+		return numplayersteama <= numplayersteamb;
+	}
+	if (numplayersteama < numplayersteamb)
+	{
 		return true;
-
-	if(IS_REAL_CLIENT(e))
+	}
+	if (numplayersteama > numplayersteamb)
 	{
-		if(bots_would_leave)
-		{
-			ca -= cba * 0.999;
-			cb -= cbb * 0.999;
-		}
+		return false;
 	}
-
-	// keep teams alive (teams of size 0 always count as smaller, ignoring score)
-	if(ca < 1)
-		if(cb >= 1)
-			return true;
-	if(ca >= 1)
-		if(cb < 1)
-			return false;
+	return scoreteama <= scoreteamb;
 
 	// first, normalize
-	f = max(ca, cb, 1);
-	ca /= f;
-	cb /= f;
-	f = max(sa, sb, 1);
-	sa /= f;
-	sb /= f;
+	//f = max(numplayersteama, numplayersteamb, 1);
+	//numplayersteama /= f;
+	//numplayersteamb /= f;
 
-	// the more we're at the end of the match, the more take scores into account
-	f = bound(0, game_completion_ratio * autocvar_g_balance_teams_scorefactor, 1);
-	ca += (sa - ca) * f;
-	cb += (sb - cb) * f;
+	//float f = max(scoreteama, scoreteamb, 1);
+	//scoreteama /= f;
+	//scoreteamb /= f;
 
-	return ca <= cb;
+	// the more we're at the end of the match, the more take scores into account
+	//f = bound(0, game_completion_ratio * autocvar_g_balance_teams_scorefactor, 1);
+	//numplayersteama += (scoreteama - numplayersteama) * f;
+	//numplayersteamb += (scoreteamb - numplayersteamb) * f;	
 }
 
 // returns # of smallest team (1, 2, 3, 4)
@@ -640,23 +638,23 @@ float FindSmallestTeam(entity pl, float ignore_pl)
 
 	RandomSelection_Init();
 
-	if(TeamSmallerEqThanTeam(1, t, pl))
+	if(TeamSmallerEqThanTeam(1, t, pl, true))
 		t = 1;
-	if(TeamSmallerEqThanTeam(2, t, pl))
+	if(TeamSmallerEqThanTeam(2, t, pl, true))
 		t = 2;
-	if(TeamSmallerEqThanTeam(3, t, pl))
+	if(TeamSmallerEqThanTeam(3, t, pl, true))
 		t = 3;
-	if(TeamSmallerEqThanTeam(4, t, pl))
+	if(TeamSmallerEqThanTeam(4, t, pl, true))
 		t = 4;
 
 	// now t is the minimum, or A minimum!
-	if(t == 1 || TeamSmallerEqThanTeam(1, t, pl))
+	if(t == 1 || TeamSmallerEqThanTeam(1, t, pl, true))
 		RandomSelection_AddFloat(1, 1, 1);
-	if(t == 2 || TeamSmallerEqThanTeam(2, t, pl))
+	if(t == 2 || TeamSmallerEqThanTeam(2, t, pl, true))
 		RandomSelection_AddFloat(2, 1, 1);
-	if(t == 3 || TeamSmallerEqThanTeam(3, t, pl))
+	if(t == 3 || TeamSmallerEqThanTeam(3, t, pl, true))
 		RandomSelection_AddFloat(3, 1, 1);
-	if(t == 4 || TeamSmallerEqThanTeam(4, t, pl))
+	if(t == 4 || TeamSmallerEqThanTeam(4, t, pl, true))
 		RandomSelection_AddFloat(4, 1, 1);
 
 	return RandomSelection_chosen_float;
@@ -714,16 +712,18 @@ int JoinBestTeam(entity this, bool only_return_best, bool forcebestteam)
 		return bestteam;
 	}
 	bestteam = Team_NumberToTeam(bestteam);
-	if (bestteam != -1)
-	{
-		TeamchangeFrags(this);
-		SetPlayerTeamSimple(this, bestteam);
-	}
-	else
+	if (bestteam == -1)
 	{
 		error("JoinBestTeam: invalid team\n");
 	}
+	int oldteam = Team_TeamToNumber(this.team);
+	TeamchangeFrags(this);
+	SetPlayerTeamSimple(this, bestteam);
 	LogTeamchange(this.playerid, this.team, 2); // log auto join
+	if (!IS_BOT_CLIENT(this))
+	{
+		AutoBalanceBots(oldteam, Team_TeamToNumber(bestteam));
+	}
 	if (!IS_DEAD(this) && (MUTATOR_CALLHOOK(Player_ChangeTeamKill, this) ==
 		false))
 	{
@@ -732,10 +732,9 @@ int JoinBestTeam(entity this, bool only_return_best, bool forcebestteam)
 	return bestteam;
 }
 
-//void() ctf_playerchanged;
 void SV_ChangeTeam(entity this, float _color)
 {
-	float scolor, dcolor, steam, dteam; //, dbotcount, scount, dcount;
+	float sourcecolor, destinationcolor, sourceteam, destinationteam;
 
 	// in normal deathmatch we can just apply the color and we're done
 	if(!teamplay)
@@ -751,78 +750,132 @@ void SV_ChangeTeam(entity this, float _color)
 	if(!teamplay)
 		return;
 
-	scolor = this.clientcolors & 0x0F;
-	dcolor = _color & 0x0F;
-
-	if(scolor == NUM_TEAM_1 - 1)
-		steam = 1;
-	else if(scolor == NUM_TEAM_2 - 1)
-		steam = 2;
-	else if(scolor == NUM_TEAM_3 - 1)
-		steam = 3;
-	else // if(scolor == NUM_TEAM_4 - 1)
-		steam = 4;
-	if(dcolor == NUM_TEAM_1 - 1)
-		dteam = 1;
-	else if(dcolor == NUM_TEAM_2 - 1)
-		dteam = 2;
-	else if(dcolor == NUM_TEAM_3 - 1)
-		dteam = 3;
-	else // if(dcolor == NUM_TEAM_4 - 1)
-		dteam = 4;
+	sourcecolor = this.clientcolors & 0x0F;
+	destinationcolor = _color & 0x0F;
 
+	sourceteam = Team_TeamToNumber(sourcecolor + 1);
+	destinationteam = Team_TeamToNumber(destinationcolor + 1);
+	
 	CheckAllowedTeams(this);
 
-	if(dteam == 1 && c1 < 0) dteam = 4;
-	if(dteam == 4 && c4 < 0) dteam = 3;
-	if(dteam == 3 && c3 < 0) dteam = 2;
-	if(dteam == 2 && c2 < 0) dteam = 1;
+	if (destinationteam == 1 && c1 < 0) destinationteam = 4;
+	if (destinationteam == 4 && c4 < 0) destinationteam = 3;
+	if (destinationteam == 3 && c3 < 0) destinationteam = 2;
+	if (destinationteam == 2 && c2 < 0) destinationteam = 1;
 
 	// not changing teams
-	if(scolor == dcolor)
+	if (sourcecolor == destinationcolor)
 	{
-		SetPlayerTeam(this, dteam, steam, true);
+		SetPlayerTeam(this, destinationteam, sourceteam, true);
 		return;
 	}
 
-	if((autocvar_g_campaign) || (autocvar_g_changeteam_banned && this.wasplayer)) {
+	if (autocvar_g_campaign || (autocvar_g_changeteam_banned && this.wasplayer))
+	{
 		Send_Notification(NOTIF_ONE, this, MSG_INFO, INFO_TEAMCHANGE_NOTALLOWED);
 		return; // changing teams is not allowed
 	}
 
 	// autocvar_g_balance_teams_prevent_imbalance only makes sense if autocvar_g_balance_teams is on, as it makes the team selection dialog pointless
-	if(autocvar_g_balance_teams && autocvar_g_balance_teams_prevent_imbalance)
+	if (autocvar_g_balance_teams && autocvar_g_balance_teams_prevent_imbalance)
 	{
 		GetTeamCounts(this);
-		if(!TeamSmallerEqThanTeam(dteam, steam, this))
+		if (!TeamSmallerEqThanTeam(destinationteam, sourceteam, this, false))
 		{
+			PrintToChatAll("TeamSmallerEqThanTeam prevented team switch.");
 			Send_Notification(NOTIF_ONE, this, MSG_INFO, INFO_TEAMCHANGE_LARGERTEAM);
 			return;
 		}
 	}
-
-//	bprint("allow change teams from ", ftos(steam), " to ", ftos(dteam), "\n");
-
-	if(IS_PLAYER(this) && steam != dteam)
+	if(IS_PLAYER(this) && sourceteam != destinationteam)
 	{
 		// reduce frags during a team change
 		TeamchangeFrags(this);
 	}
-
-	SetPlayerTeam(this, dteam, steam, !IS_CLIENT(this));
-
-	if(!IS_PLAYER(this) || (steam == dteam))
+	SetPlayerTeam(this, destinationteam, sourceteam, !IS_CLIENT(this));
+	AutoBalanceBots(sourceteam, destinationteam);
+	if (!IS_PLAYER(this) || (sourceteam == destinationteam))
 	{
 		return;
 	}
 	// kill player when changing teams
-	if(IS_DEAD(this) || (MUTATOR_CALLHOOK(Player_ChangeTeamKill, this) == true))
+	if (IS_DEAD(this) || (MUTATOR_CALLHOOK(Player_ChangeTeamKill, this) == true))
 	{
 		return;
 	}
 	Damage(this, this, this, 100000, DEATH_TEAMCHANGE.m_id, this.origin, '0 0 0');
 }
 
+void AutoBalanceBots(int sourceteam, int destinationteam)
+{
+	if ((sourceteam == -1) || (destinationteam == -1))
+	{
+		return;
+	}
+	if (!autocvar_g_balance_teams ||
+		!autocvar_g_balance_teams_prevent_imbalance)
+	{
+		return;
+	}
+	int numplayerssourceteam = 0;
+	int numplayersdestinationteam = 0;
+	entity lowestbotdestinationteam = NULL;
+	switch (sourceteam)
+	{
+		case 1:
+		{
+			numplayerssourceteam = c1;
+			break;
+		}
+		case 2:
+		{
+			numplayerssourceteam = c2;
+			break;
+		}
+		case 3:
+		{
+			numplayerssourceteam = c3;
+			break;
+		}
+		case 4:
+		{
+			numplayerssourceteam = c4;
+			break;
+		}
+	}
+	switch (destinationteam)
+	{
+		case 1:
+		{
+			numplayersdestinationteam = c1;
+			lowestbotdestinationteam = lowestbotteam1;
+			break;
+		}
+		case 2:
+		{
+			numplayersdestinationteam = c2;
+			lowestbotdestinationteam = lowestbotteam2;
+			break;
+		}
+		case 3:
+		{
+			numplayersdestinationteam = c3;
+			lowestbotdestinationteam = lowestbotteam3;
+			break;
+		}
+		case 4:
+		{
+			numplayersdestinationteam = c4;
+			lowestbotdestinationteam = lowestbotteam4;
+			break;
+		}
+	}
+	if ((numplayersdestinationteam > numplayerssourceteam) && (lowestbotdestinationteam != NULL))
+	{
+		SetPlayerTeamSimple(lowestbotdestinationteam, Team_NumberToTeam(sourceteam));
+	}
+}
+
 void ShufflePlayerOutOfTeam (float source_team)
 {
 	float smallestteam, smallestteam_count, steam;
diff --git a/qcsrc/server/teamplay.qh b/qcsrc/server/teamplay.qh
index 00a1ed5bb..078814cc6 100644
--- a/qcsrc/server/teamplay.qh
+++ b/qcsrc/server/teamplay.qh
@@ -37,7 +37,7 @@ string GetClientVersionMessage(entity this);
 
 string getwelcomemessage(entity this);
 
-void SetPlayerColors(entity pl, float _color);
+void SetPlayerColors(entity player, float _color);
 
 /// \brief Sets the team of the player.
 /// \param[in,out] player Player to adjust.
@@ -45,7 +45,13 @@ void SetPlayerColors(entity pl, float _color);
 /// \return True if team switch was successful, false otherwise.
 bool SetPlayerTeamSimple(entity player, int teamnum);
 
-void SetPlayerTeam(entity pl, float t, float s, float noprint);
+/// \brief Sets the team of the player.
+/// \param[in,out] player Player to adjust.
+/// \param[in] destinationteam Team to set.
+/// \param[in] sourceteam Previous team of the player.
+/// \param[in] noprint Whether to print this event to players' console.
+/// \return No return.
+void SetPlayerTeam(entity player, int destinationteam, int sourceteam, bool noprint);
 
 // set c1...c4 to show what teams are allowed
 void CheckAllowedTeams (entity for_whom);
@@ -56,7 +62,7 @@ float PlayerValue(entity p);
 // teams that are allowed will now have their player counts stored in c1...c4
 void GetTeamCounts(entity ignore);
 
-float TeamSmallerEqThanTeam(float ta, float tb, entity e);
+float TeamSmallerEqThanTeam(float teama, float teamb, entity e, bool usescore);
 
 // returns # of smallest team (1, 2, 3, 4)
 // NOTE: Assumes CheckAllowedTeams has already been called!
@@ -64,7 +70,13 @@ float FindSmallestTeam(entity pl, float ignore_pl);
 
 int JoinBestTeam(entity this, bool only_return_best, bool forcebestteam);
 
-//void() ctf_playerchanged;
+/// \brief Auto balances bots in teams after the player has changed team.
+/// \param[in] sourceteam Previous team of the player (1, 2, 3, 4).
+/// \param[in] destinationteam Current team of the player (1, 2, 3, 4).
+/// \return No return.
+/// \note This function assumes that CheckAllowedTeams and GetTeamCounts has
+/// been called.
+void AutoBalanceBots(int sourceteam, int destinationteam);
 
 void ShufflePlayerOutOfTeam (float source_team);