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;
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?
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)
}
}
- .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()
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;
}
{
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;
&& (!teamplay || autocvar_g_balance_teams)))
{
if(joinAllowed(this))
- Join(this);
+ Join(this, true);
return;
}
}
// 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;
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;
}
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:
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
}
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
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)
}
}
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);
}
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)
{
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))
//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;
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");
}
}
+// 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
VoteReset(true);
+ MUTATOR_CALLHOOK(MatchEnd_BeforeScores);
+
DumpStats(true);
// send statistics