]> de.git.xonotic.org Git - xonotic/xonotic-data.pk3dir.git/commitdiff
Merge branch 'z411/team_queue' into 'master'
authorbones_was_here <bones_was_here@xonotic.au>
Sat, 25 May 2024 11:09:54 +0000 (11:09 +0000)
committerbones_was_here <bones_was_here@xonotic.au>
Sat, 25 May 2024 11:09:54 +0000 (11:09 +0000)
Create queue system to prevent team imbalance in teamplay

See merge request xonotic/xonotic-data.pk3dir!1093

1  2 
qcsrc/server/client.qc
qcsrc/server/command/cmd.qc
qcsrc/server/teamplay.qc
qcsrc/server/world.qc

diff --combined qcsrc/server/client.qc
index e66f270cbf0b78af07db0e597322c754bc2c1043,ead29f4ceb47f09b636689fcea2550fb1fbdb6d2..4bd2e2e695a1208b68b338e81517c3d9b6733e9c
@@@ -643,9 -643,8 +643,9 @@@ void PutPlayerInServer(entity this
        this.respawn_flags = 0;
        this.respawn_time = 0;
        STAT(RESPAWN_TIME, this) = 0;
 -      // DP model scaling uses 1/16 accuracy and 13/16 is closest to 56/69
 -      this.scale = ((q3compat && autocvar_sv_q3compat_changehitbox) ? 0.8125 : autocvar_sv_player_scale);
 +      this.scale = ((q3compat && autocvar_sv_q3compat_changehitbox) || !autocvar_sv_mapformat_is_quake3)
 +              ? 0.8125 // DP model scaling uses 1/16 accuracy and 13/16 is closest to 56/69
 +              : autocvar_sv_player_scale;
        this.fade_time = 0;
        this.pain_finished = 0;
        this.pushltime = 0;
@@@ -1135,6 -1134,7 +1135,7 @@@ void ClientConnect(entity this
                GameLogEcho(strcat(":join:", ftos(this.playerid), ":", ftos(etof(this)), ":", ((IS_REAL_CLIENT(this)) ? GameLog_ProcessIP(this.netaddress) : "bot"), ":", playername(this.netname, this.team, false)));
  
        CS(this).just_joined = true;  // stop spamming the eventlog with additional lines when the client connects
+       this.wants_join = 0;
  
        stuffcmd(this, clientstuff, "\n");
        stuffcmd(this, "cl_particles_reloadeffects\n"); // TODO do we still need this?
@@@ -1306,6 -1306,10 +1307,10 @@@ void ClientDisconnect(entity this
  
        if (player_count == 0)
                localcmd("\nsv_hook_lastleave\n");
+       if (!TeamBalance_QueuedPlayersTagIn(this))
+       if (autocvar_g_balance_teams_remove)
+               TeamBalance_RemoveExcessPlayers(NULL);
  }
  
  void ChatBubbleThink(entity this)
@@@ -2001,24 -2005,38 +2006,38 @@@ void ShowRespawnCountdown(entity this
        }
  }
  
- .bool team_selected;
  bool ShowTeamSelection(entity this)
  {
        if (!teamplay || autocvar_g_campaign || autocvar_g_balance_teams || this.team_selected || (CS(this).wasplayer && autocvar_g_changeteam_banned) || Player_HasRealForcedTeam(this))
                return false;
+       if (QueuedPlayersReady(this, true))
+               return false;
        if (frametime) // once per frame is more than enough
                stuffcmd(this, "_scoreboard_team_selection 1\n");
        return true;
  }
- void Join(entity this)
+ void Join(entity this, bool queued_join)
  {
+       bool teamautoselect = autocvar_g_campaign || autocvar_g_balance_teams || this.wants_join < 0;
        if (autocvar_g_campaign && !campaign_bots_may_start && !game_stopped && time >= game_starttime)
                ReadyRestart(true);
  
        TRANSMUTE(Player, this);
  
-       if(!this.team_selected)
-       if(autocvar_g_campaign || autocvar_g_balance_teams)
+       if(queued_join)
+       {
+               // First we must put queued player(s) in their team(s) (they chose first).
+               FOREACH_CLIENT(IS_REAL_CLIENT(it) && it != this && it.wants_join,
+               {
+                       Join(it, false);
+                       // ensure TeamBalance_JoinBestTeam will run if necessary for `this`
+                       teamautoselect = true;
+               });
+       }
+       if(!this.team_selected && teamautoselect)
                TeamBalance_JoinBestTeam(this);
  
        if(autocvar_g_campaign)
        if(IS_PLAYER(this))
        if(teamplay && this.team != -1)
        {
+               if(this.wants_join)
+                       Send_Notification(NOTIF_ALL, NULL, MSG_INFO, APP_TEAM_NUM(this.team, INFO_JOIN_PLAY_TEAM), this.netname);
        }
        else
                Send_Notification(NOTIF_ALL, NULL, MSG_INFO, INFO_JOIN_PLAY, this.netname);
        this.team_selected = false;
+       this.wants_join = 0;
  }
  
  int GetPlayerLimit()
@@@ -2107,6 -2128,31 +2129,31 @@@ int nJoinAllowed(entity this, entity ig
        return free_slots;
  }
  
+ bool queuePlayer(entity this, int team_index)
+ {
+       if(IS_BOT_CLIENT(this) || !IS_QUEUE_NEEDED(this) || QueuedPlayersReady(this, false))
+               return false;
+       if(team_index <= 0)
+       {
+               // defer team selection until Join()
+               this.wants_join = -1;
+               this.team_selected = false;
+               this.team = -1;
+               Send_Notification(NOTIF_ALL, NULL, MSG_INFO, INFO_JOIN_WANTS, this.netname);
+               Send_Notification(NOTIF_ONE, this, MSG_CENTER, CENTER_JOIN_PREVENT_QUEUE);
+       }
+       else
+       {
+               this.wants_join = team_index; // Player queued to join
+               this.team_selected = true; // no autoselect in Join()
+               Send_Notification(NOTIF_ALL, NULL, MSG_INFO, APP_TEAM_NUM(this.team, INFO_JOIN_WANTS_TEAM), this.netname);
+               Send_Notification(NOTIF_ONE, this, MSG_CENTER, APP_TEAM_NUM(this.team, CENTER_JOIN_PREVENT_QUEUE_TEAM));
+       }
+       return true;
+ }
  bool joinAllowed(entity this)
  {
        if (CS(this).version_mismatch) return false;
        if (teamplay && lockteams) return false;
        if (MUTATOR_CALLHOOK(ForbidSpawn, this)) return false;
        if (ShowTeamSelection(this)) return false;
+       if (this.wants_join) return false;
+       if (queuePlayer(this, 0)) return false;
        return true;
  }
  
@@@ -2380,7 -2428,7 +2429,7 @@@ void ObserverOrSpectatorThink(entity th
                        {
                                this.flags &= ~FL_SPAWNING;
                                if(joinAllowed(this))
-                                       Join(this);
+                                       Join(this, true);
                                else if(time < CS(this).jointime + MIN_SPEC_TIME)
                                        CS(this).autojoin_checked = -1;
                                return;
@@@ -2485,7 -2533,7 +2534,7 @@@ void PlayerPreThink (entity this
                                && (!teamplay || autocvar_g_balance_teams)))
                {
                        if(joinAllowed(this))
-                               Join(this);
+                               Join(this, true);
                        return;
                }
        }
@@@ -2750,9 -2798,9 +2799,9 @@@ void PlayerFrame (entity this
  
  
  // formerly PostThink code
-       if (autocvar_sv_maxidle > 0 || (IS_PLAYER(this) && autocvar_sv_maxidle_playertospectator > 0))
+       if (autocvar_sv_maxidle > 0 || ((IS_PLAYER(this) || this.wants_join) && autocvar_sv_maxidle_playertospectator > 0))
        if (IS_REAL_CLIENT(this))
-       if (IS_PLAYER(this) || autocvar_sv_maxidle_alsokickspectators)
+       if (IS_PLAYER(this) || this.wants_join || autocvar_sv_maxidle_alsokickspectators)
        if (!intermission_running) // NextLevel() kills all centerprints after setting this true
        {
                int totalClients = 0;
                                        totalClients = 0;
                        }
                }
-               else if (IS_PLAYER(this) && autocvar_sv_maxidle_playertospectator > 0)
+               else if ((IS_PLAYER(this) || this.wants_join) && autocvar_sv_maxidle_playertospectator > 0)
                {
                        FOREACH_CLIENT(IS_REAL_CLIENT(it),
                        {
                else
                {
                        float maxidle_time = autocvar_sv_maxidle;
-                       if (IS_PLAYER(this) && autocvar_sv_maxidle_playertospectator > 0)
+                       if ((IS_PLAYER(this) || this.wants_join)
+                       && autocvar_sv_maxidle_playertospectator > 0)
                                maxidle_time = autocvar_sv_maxidle_playertospectator;
                        float timeleft = ceil(maxidle_time - (time - CS(this).parm_idlesince));
                        float countdown_time = max(min(10, maxidle_time - 1), ceil(maxidle_time * 0.33)); // - 1 to support maxidle_time <= 10
                        if (timeleft == countdown_time && !CS(this).idlekick_lasttimeleft)
                        {
-                               if (IS_PLAYER(this) && autocvar_sv_maxidle_playertospectator > 0)
-                                       Send_Notification(NOTIF_ONE_ONLY, this, MSG_CENTER, CENTER_MOVETOSPEC_IDLING, timeleft);
+                               if ((IS_PLAYER(this) || this.wants_join) && autocvar_sv_maxidle_playertospectator > 0)
+                               {
+                                       if (!this.wants_join) // no countdown centreprint when getting kicked off the join queue
+                                               Send_Notification(NOTIF_ONE_ONLY, this, MSG_CENTER, CENTER_MOVETOSPEC_IDLING, timeleft);
+                               }
                                else
                                        Send_Notification(NOTIF_ONE_ONLY, this, MSG_CENTER, CENTER_DISCONNECT_IDLING, timeleft);
                        }
-                       if (timeleft <= 0) {
-                               if (IS_PLAYER(this) && autocvar_sv_maxidle_playertospectator > 0)
+                       if (timeleft <= 0)
+                       {
+                               if ((IS_PLAYER(this) || this.wants_join)
+                               && autocvar_sv_maxidle_playertospectator > 0)
                                {
-                                       Send_Notification(NOTIF_ALL, NULL, MSG_INFO, INFO_MOVETOSPEC_IDLING, this.netname, maxidle_time);
+                                       if (this.wants_join)
+                                               Send_Notification(NOTIF_ALL, NULL, MSG_INFO, INFO_MOVETOSPEC_IDLING_QUEUE, this.netname, maxidle_time);
+                                       else
+                                               Send_Notification(NOTIF_ALL, NULL, MSG_INFO, INFO_MOVETOSPEC_IDLING, this.netname, maxidle_time);
                                        PutObserverInServer(this, true, true);
+                                       // when the player is kicked off the server, these are called in ClientDisconnect()
+                                       if (!TeamBalance_QueuedPlayersTagIn(this))
+                                       if (autocvar_g_balance_teams_remove)
+                                               TeamBalance_RemoveExcessPlayers(this);
                                }
                                else
                                {
                                }
                                return;
                        }
-                       else if (timeleft <= countdown_time) {
+                       else if (timeleft <= countdown_time
+                       && !this.wants_join) // no countdown bangs when getting kicked off the join queue
+                       {
                                if (timeleft != CS(this).idlekick_lasttimeleft)
                                        play2(this, SND(TALK2));
                                CS(this).idlekick_lasttimeleft = timeleft;
index 057b45307a6834ac62b7c1ead1dca94484d7acd1,0cc6086e85d09e53d82e4f2cb2485ecb64fa9a2e..1e14def1d7bb30f5470333488785886adef7c7fa
@@@ -359,7 -359,7 +359,7 @@@ void ClientCommand_join(entity caller, 
                        if (!game_stopped && IS_CLIENT(caller) && !IS_PLAYER(caller))
                        {
                                if (joinAllowed(caller))
-                                       Join(caller);
+                                       Join(caller, true);
                                else if(time < CS(caller).jointime + MIN_SPEC_TIME)
                                        CS(caller).autojoin_checked = -1;
                        }
@@@ -583,12 -583,9 +583,9 @@@ void ClientCommand_selectteam(entity ca
                        if (team_num)
                                ClientKill_TeamChange(caller, team_num);
                        else // auto
-                               ClientKill_TeamChange(caller, -1);
+                               if (!queuePlayer(caller, 0)) // the queue uses deferred autoselect
+                                       ClientKill_TeamChange(caller, -1);
  
-                       if (!IS_PLAYER(caller))
-                       {
-                               caller.team_selected = true; // avoids asking again for team selection on join
-                       }
                        return;
                }
                default:
@@@ -694,7 -691,7 +691,7 @@@ void ClientCommand_spectate(entity call
  
                                if (mutator_returnvalue == MUT_SPECCMD_RETURN) return;
  
-                               if ((IS_PLAYER(caller) || mutator_returnvalue == MUT_SPECCMD_FORCE))
+                               if ((IS_PLAYER(caller) || mutator_returnvalue == MUT_SPECCMD_FORCE || caller.wants_join))
                                if (autocvar_sv_spectate == 1)
                                        ClientKill_TeamChange(caller, -2); // observe
                        }
@@@ -1025,19 -1022,6 +1022,19 @@@ void SV_ParseClientCommand(entity this
                case "sentcvar": break;                            // handled by server in this file
                case "spawn": break;                               // handled by engine in host_cmd.c
                case "say": case "say_team": case "tell": break;   // chat has its own flood control in chat.qc
 +              case "minigame":                                   // flood control only for common commands
 +                      string arg = argv(1);
 +                      if (arg == "")
 +                              goto flood_control;
 +                      for (int i = 0; i < MINIGAME_COMMON_CMD_COUNT; ++i)
 +                      {
 +                              if (MINIGAME_COMMON_CMD[i] == arg)
 +                                      goto flood_control;
 +                      }
 +                      // if we get here we haven't found any common command, so no flood control for other commands
 +                      // individual minigame commands shouldn't be limited for gameplay reasons
 +                      // FIXME unknown/wrong minigame commands have no flood control
 +                      break;
                case "color": case "topcolor": case "bottomcolor": // handled by engine in host_cmd.c
                        if(!IS_CLIENT(this)) // on connection
                        {
                        if(!IS_CLIENT(this)) break;
                        // else fall through to default: flood control
                default:
 +                      LABEL(flood_control)
                        if (!timeout_status)  // not while paused
                        {
                                entity store = IS_CLIENT(this) ? CS(this) : this; // unfortunately, we need to store these on the client initially
diff --combined qcsrc/server/teamplay.qc
index d6b46b0510962f4913a854878540872a7135c4a3,8ce35494a4e75a4a016d122af136b95b2e63cefd..1cdabcc3421a8e9bdb0ecc7df65e7d4ad99019e9
@@@ -234,6 -234,27 +234,27 @@@ bool Player_SetTeamIndex(entity player
        return true;
  }
  
+ /** Returns true when enough players are queued that the next will join directly
+  * to the only available team (also triggering the joins of the queued players).
+  * Optionally only counts players who selected a specific team when joining the queue.
+  */
+ bool QueuedPlayersReady(entity this, bool checkspecificteam)
+ {
+       int numplayersqueued = 0;
+       FOREACH_CLIENT(IS_REAL_CLIENT(it) && it != this
+       && (checkspecificteam ? it.wants_join > 0 : it.wants_join),
+       {
+               LOG_DEBUGF("Player %s is waiting to join team %d", it.netname, it.wants_join);
+               ++numplayersqueued;
+               if (numplayersqueued >= AVAILABLE_TEAMS - 1)
+                       return true;
+       });
+       LOG_DEBUG("No players waiting to join.");
+       return false;
+ }
  bool SetPlayerTeam(entity player, int team_index, int type)
  {
        int old_team_index = Entity_GetTeamIndex(player);
                        TeamBalance_AutoBalanceBots();
  
                if (team_index != -1)
-                       Send_Notification(NOTIF_ALL, NULL, MSG_INFO, APP_TEAM_NUM(player.team, INFO_JOIN_PLAY_TEAM), player.netname);
+               {
+                       if (!queuePlayer(player, team_index))
+                       {
+                               Send_Notification(NOTIF_ALL, NULL, MSG_INFO, APP_TEAM_NUM(player.team, INFO_JOIN_PLAY_TEAM), player.netname);
+                               player.team_selected = true; // no autoselect in Join()
+                       }
+               }
        }
  
        if (team_index == -1)
@@@ -320,6 -347,19 +347,19 @@@ void Player_SetTeamIndexChecked(entity 
                }
        }
        TeamBalance_Destroy(balance);
+       // g_balance_teams_queue: before joining the queue,
+       // check if a queued player already chose the selected team
+       if (!IS_BOT_CLIENT(player) && IS_QUEUE_NEEDED(player))
+       {
+               FOREACH_CLIENT(IS_REAL_CLIENT(it) && it != player && it.wants_join == team_index,
+               {
+                       Send_Notification(NOTIF_ONE, player, MSG_CENTER, APP_TEAM_NUM(Team_IndexToTeam(team_index), CENTER_JOIN_PREVENT_QUEUE_TEAM_FAIL), it.netname);
+                       player.team_selected = false;
+                       return;
+               });
+       }
        SetPlayerTeam(player, team_index, TEAM_CHANGE_MANUAL);
  }
  
@@@ -657,6 -697,171 +697,171 @@@ int TeamBalance_GetAllowedTeams(entity 
        return result;
  }
  
+ bool TeamBalance_AreEqual(entity ignore, bool would_leave)
+ {
+       entity balance = TeamBalance_CheckAllowedTeams(ignore);
+       TeamBalance_GetTeamCounts(balance, ignore);
+       bool equality = true;
+       int total;
+       int prev_total = 0;
+       int bots = 0;
+       for(int i = 1; i <= AVAILABLE_TEAMS; ++i)
+       {
+               total = TeamBalance_GetTeamFromIndex(balance, i).m_num_players;
+               bots += TeamBalance_GetTeamFromIndex(balance, i).m_num_bots;
+               if(i > 1 && total != prev_total)
+               {
+                       equality = false;
+                       break;
+               }
+               prev_total = total;
+       }
+       TeamBalance_Destroy(balance);
+       // Ignore if there are "ghost" bots that would leave if anyone joined
+       if (would_leave && bots > autocvar_bot_number)
+               return false;
+       return equality;
+ }
+ entity remove_countdown;
+ void Remove_Countdown(entity this)
+ {
+       if(this.lifetime <= 0 || TeamBalance_AreEqual(NULL, false))
+       {
+               if(this.lifetime <= 0)
+               {
+                       Send_Notification(NOTIF_ALL, NULL, MSG_INFO, INFO_MOVETOSPEC_REMOVE, playername(remove_countdown.enemy.netname, remove_countdown.enemy.team, true));
+                       PutObserverInServer(remove_countdown.enemy, true, true);
+               }
+               Kill_Notification(NOTIF_ALL, NULL, MSG_CENTER, CPID_REMOVE);
+               delete(this);
+               remove_countdown = NULL;
+               TeamBalance_RemoveExcessPlayers(NULL); // Check again for excess players in case someone also left while in countdown
+               return;
+       }
+       --this.lifetime;
+       this.nextthink = time + 1;
+ }
+ // FIXME: support more than 2 teams, the notification will be... awkward
+ // FIXME: also don't kick the fc/bc/kc lol
+ void TeamBalance_RemoveExcessPlayers(entity ignore)
+ {
+       if(AVAILABLE_TEAMS != 2 || autocvar_g_campaign) return;
+       entity balance = TeamBalance_CheckAllowedTeams(ignore);
+       TeamBalance_GetTeamCounts(balance, ignore);
+       int min = 0;
+       for(int i = 1; i <= AVAILABLE_TEAMS; ++i)
+       {
+               int cur = TeamBalance_GetTeamFromIndex(balance, i).m_num_players;
+               if(i == 1 || cur < min)
+                       min = cur;
+       }
+       for(int tmi = 1; tmi <= AVAILABLE_TEAMS; ++tmi)
+       {
+               int cur = TeamBalance_GetTeamFromIndex(balance, tmi).m_num_players;
+               if(cur > 0 && cur > min) // If this team has excess players
+               {
+                       // Get newest player
+                       int latest_join = 0;
+                       entity latest_join_pl = NULL;
+                       FOREACH_CLIENT(IS_REAL_CLIENT(it) || INGAME(it), {
+                               if(it.team == Team_IndexToTeam(tmi) && CS(it).startplaytime > latest_join)
+                               {
+                                       latest_join = CS(it).startplaytime;
+                                       latest_join_pl = it;
+                               }
+                       });
+                       // Force player to spectate
+                       if(latest_join_pl)
+                       {
+                               // Send player to spectate
+                               if(autocvar_g_balance_teams_remove_wait)
+                               {
+                                       // Give a warning before moving to spect
+                                       if (!remove_countdown)
+                                       {
+                                               remove_countdown = new_pure(remove_countdown);
+                                               setthink(remove_countdown, Remove_Countdown);
+                                               remove_countdown.nextthink = time;
+                                               Send_Notification(NOTIF_ALL, NULL, MSG_CENTER, CENTER_MOVETOSPEC_REMOVE, playername(latest_join_pl.netname, latest_join_pl.team, true), autocvar_g_balance_teams_remove_wait);
+                                       }
+                                       remove_countdown.enemy = latest_join_pl;
+                                       remove_countdown.lifetime = autocvar_g_balance_teams_remove_wait;
+                               }
+                               else
+                               {
+                                       // Move to spects immediately
+                                       Send_Notification(NOTIF_ALL, NULL, MSG_INFO, INFO_MOVETOSPEC_REMOVE, latest_join_pl.netname);
+                                       PutObserverInServer(latest_join_pl, true, true);
+                               }
+                       }
+               }
+       }
+       TeamBalance_Destroy(balance);
+ }
+ bool TeamBalance_QueuedPlayersTagIn(entity ignore)
+ {
+       if (!teamplay)
+               return true;
+       bool balanced = true;
+       int j, teamplayers, teamplayers_max = 0;
+       entity balance = TeamBalance_CheckAllowedTeams(ignore);
+       TeamBalance_GetTeamCounts(balance, ignore);
+       // find the target player count
+       for (j = 1; j <= AVAILABLE_TEAMS; ++j)
+       {
+               teamplayers = TeamBalance_GetTeamFromIndex(balance, j).m_num_players;
+               if(teamplayers > teamplayers_max)
+                       teamplayers_max = teamplayers;
+       }
+       for (j = 1; j <= AVAILABLE_TEAMS; ++j)
+       {
+               teamplayers = TeamBalance_GetTeamFromIndex(balance, j).m_num_players;
+               // first pass: find player(s) who want to play on this specific team
+               FOREACH_CLIENT(it != ignore && it.wants_join == j,
+               {
+                       if (teamplayers >= teamplayers_max)
+                               break;
+                       Join(it, false);
+                       ++teamplayers;
+               });
+               // second pass: find player(s) who want to play on any team
+               FOREACH_CLIENT(it != ignore && it.wants_join < 0,
+               {
+                       if (teamplayers >= teamplayers_max)
+                               break;
+                       Join(it, false);
+                       ++teamplayers;
+               });
+               if (teamplayers < teamplayers_max)
+                       balanced = false;
+       }
+       TeamBalance_Destroy(balance);
+       return balanced;
+ }
  bool TeamBalance_IsTeamAllowed(entity balance, int index)
  {
        if (balance == NULL)
@@@ -708,6 -913,10 +913,10 @@@ void TeamBalance_GetTeamCounts(entity b
                        {
                                continue;
                        }
+                       if (it.wants_join)
+                       {
+                               continue; // Queued players aren't actually in the game.
+                       }
                        int team_num;
                        // TODO: Reconsider when the player is truly on the team.
                        if (IS_CLIENT(it) || INGAME(it))
@@@ -889,8 -1098,6 +1098,8 @@@ void TeamBalance_AutoBalanceBots(
        //if (!(autocvar_g_balance_teams && autocvar_g_balance_teams_prevent_imbalance))
        //      return;
  
 +      if (intermission_running) return;
 +
        entity balance = TeamBalance_CheckAllowedTeams(NULL);
        TeamBalance_GetTeamCounts(balance, NULL);
        int smallest_team_index = 0;
diff --combined qcsrc/server/world.qc
index 060779d9383fbbcb47bef19688aa2c0031db0373,7779879d8069660e27c1efad0ab0b0bd4326844f..cecef015a7838ae610b73bb94ef312c8d33221ce
@@@ -450,6 -450,9 +450,9 @@@ void cvar_changes_init(
                BADCVAR("gametype");
                BADCVAR("g_antilag");
                BADCVAR("g_balance_teams");
+               BADCVAR("g_balance_teams_queue");
+               BADCVAR("g_balance_teams_remove");
+               BADCVAR("g_balance_teams_remove_wait");
                BADCVAR("g_balance_teams_prevent_imbalance");
                BADCVAR("g_balance_teams_scorefactor");
                BADCVAR("g_ban_sync_trusted_servers");
@@@ -1319,40 -1322,6 +1322,40 @@@ void DumpStats(float final
        }
  }
  
 +// it should be called by gametypes where players can join a team but have to wait before playing
 +// it puts players who joined too late (without being able to play) back to spectators
 +// if prev_team_field is not team it also puts players who previously switched team (without being
 +// able to play on the new team) back to previous team
 +void MatchEnd_RestoreSpectatorAndTeamStatus(.int prev_team_field)
 +{
 +      bool fix_team = (teamplay && prev_team_field != team);
 +      FOREACH_CLIENT(true,
 +      {
 +              if (!IS_PLAYER(it) && INGAME_JOINING(it))
 +              {
 +                      INGAME_STATUS_CLEAR(it);
 +                      PutObserverInServer(it, true, false);
 +                      bprint(playername(it.netname, it.team, false), " has been moved back to spectator");
 +                      it.winning = false;
 +              }
 +              else if (fix_team && INGAME_JOINED(it) && it.(prev_team_field) && it.team != it.(prev_team_field))
 +              {
 +                      Player_SetForcedTeamIndex(it, TEAM_FORCE_DEFAULT);
 +                      if (MoveToTeam(it, Team_TeamToIndex(it.(prev_team_field)), 6))
 +                      {
 +                              string pl_name = playername(it.netname, it.team, false);
 +                              bprint(pl_name, " has been moved back to the ", Team_ColoredFullName(it.team));
 +                      }
 +                      it.winning = (it.team == WinningConditionHelper_winnerteam);
 +              }
 +      });
 +}
 +
 +void MatchEnd_RestoreSpectatorStatus()
 +{
 +      MatchEnd_RestoreSpectatorAndTeamStatus(team);
 +}
 +
  /*
  go to the next level for deathmatch
  only called if a time or frag limit has expired
@@@ -1380,8 -1349,6 +1383,8 @@@ void NextLevel(
  
        VoteReset(true);
  
 +      MUTATOR_CALLHOOK(MatchEnd_BeforeScores);
 +
        DumpStats(true);
  
        // send statistics