From 36d23bfd0c94d0c92fffc838694b988e86516b20 Mon Sep 17 00:00:00 2001 From: z411 Date: Sat, 25 May 2024 11:09:54 +0000 Subject: [PATCH] Create queue system to prevent team imbalance in teamplay --- notifications.cfg | 9 ++ qcsrc/client/hud/panel/infomessages.qc | 8 +- qcsrc/client/hud/panel/scoreboard.qc | 9 ++ qcsrc/common/ent_cs.qc | 4 + qcsrc/common/ent_cs.qh | 10 ++ qcsrc/common/notifications/all.inc | 9 ++ qcsrc/common/notifications/all.qh | 1 + qcsrc/common/teams.qh | 2 +- qcsrc/server/client.qc | 96 +++++++++-- qcsrc/server/client.qh | 9 +- qcsrc/server/clientkill.qc | 16 +- qcsrc/server/command/cmd.qc | 11 +- qcsrc/server/main.qc | 5 + qcsrc/server/teamplay.qc | 211 ++++++++++++++++++++++++- qcsrc/server/teamplay.qh | 19 +++ qcsrc/server/world.qc | 3 + xonotic-server.cfg | 3 + 17 files changed, 395 insertions(+), 30 deletions(-) diff --git a/notifications.cfg b/notifications.cfg index 22a4e70a3..92f416971 100644 --- a/notifications.cfg +++ b/notifications.cfg @@ -234,6 +234,8 @@ seta notification_INFO_JETPACK_NOFUEL "1" "0 = off, 1 = print to console, 2 = pr seta notification_INFO_JOIN_CONNECT "2" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" seta notification_INFO_JOIN_PLAY "2" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" seta notification_INFO_JOIN_PLAY_TEAM "2" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" +seta notification_INFO_JOIN_WANTS "2" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" +seta notification_INFO_JOIN_WANTS_TEAM "2" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" seta notification_INFO_KEEPAWAY_DROPPED "1" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" seta notification_INFO_KEEPAWAY_PICKUP "1" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" seta notification_INFO_KEYHUNT_CAPTURE "1" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" @@ -246,6 +248,8 @@ seta notification_INFO_LMS_NOLIVES "1" "0 = off, 1 = print to console, 2 = print seta notification_INFO_MINIGAME_INVITE "1" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" seta notification_INFO_MONSTERS_DISABLED "1" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" seta notification_INFO_MOVETOSPEC_IDLING "2" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" +seta notification_INFO_MOVETOSPEC_IDLING_QUEUE "2" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" +seta notification_INFO_MOVETOSPEC_REMOVE "2" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" seta notification_INFO_NEXBALL_RETURN_HELD "1" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" seta notification_INFO_ONSLAUGHT_CAPTURE "1" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" seta notification_INFO_ONSLAUGHT_CAPTURE_NONAME "1" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" @@ -262,6 +266,7 @@ seta notification_INFO_QUIT_KICK_IDLING "2" "0 = off, 1 = print to console, 2 = seta notification_INFO_QUIT_KICK_SPECTATING "1" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" seta notification_INFO_QUIT_KICK_TEAMKILL "2" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" seta notification_INFO_QUIT_PLAYBAN_TEAMKILL "2" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" +seta notification_INFO_QUIT_QUEUE "2" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" seta notification_INFO_QUIT_SPECTATE "2" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" seta notification_INFO_RACE_ABANDONED "1" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" seta notification_INFO_RACE_FAIL_RANKED "1" "0 = off, 1 = print to console, 2 = print to console and chatbox (if notification_allow_chatboxprint is enabled)" @@ -483,6 +488,9 @@ seta notification_CENTER_JOIN_NOSPAWNS "1" "0 = off, 1 = centerprint" seta notification_CENTER_JOIN_PLAYBAN "1" "0 = off, 1 = centerprint" seta notification_CENTER_JOIN_PREVENT "1" "0 = off, 1 = centerprint" seta notification_CENTER_JOIN_PREVENT_MINIGAME "1" "0 = off, 1 = centerprint" +seta notification_CENTER_JOIN_PREVENT_QUEUE "1" "0 = off, 1 = centerprint" +seta notification_CENTER_JOIN_PREVENT_QUEUE_TEAM_FAIL "1" "0 = off, 1 = centerprint" +seta notification_CENTER_JOIN_PREVENT_QUEUE_TEAM "1" "0 = off, 1 = centerprint" seta notification_CENTER_KEEPAWAY_DROPPED "1" "0 = off, 1 = centerprint" seta notification_CENTER_KEEPAWAY_PICKUP "1" "0 = off, 1 = centerprint" seta notification_CENTER_KEEPAWAY_PICKUP_SELF "1" "0 = off, 1 = centerprint" @@ -499,6 +507,7 @@ seta notification_CENTER_LMS_VISIBLE_OTHER "1" "0 = off, 1 = centerprint" seta notification_CENTER_MISSING_PLAYERS "1" "0 = off, 1 = centerprint" seta notification_CENTER_MISSING_TEAMS "1" "0 = off, 1 = centerprint" seta notification_CENTER_MOVETOSPEC_IDLING "1" "0 = off, 1 = centerprint" +seta notification_CENTER_MOVETOSPEC_REMOVE "1" "0 = off, 1 = centerprint" seta notification_CENTER_NADE_BONUS "1" "0 = off, 1 = centerprint" seta notification_CENTER_NADE_THROW "1" "0 = off, 1 = centerprint" seta notification_CENTER_NIX_COUNTDOWN "1" "0 = off, 1 = centerprint" diff --git a/qcsrc/client/hud/panel/infomessages.qc b/qcsrc/client/hud/panel/infomessages.qc index 94bfa4751..ff5bd8d5d 100644 --- a/qcsrc/client/hud/panel/infomessages.qc +++ b/qcsrc/client/hud/panel/infomessages.qc @@ -127,7 +127,13 @@ void HUD_InfoMessages() if(!mutator_returnvalue) { - s = sprintf(_("^1Press ^3%s^1 to join"), getcommandkey(_("jump"), "+jump")); + if(entcs_GetWantsJoin(current_player)) + { + int tm = Team_IndexToTeam(entcs_GetWantsJoin(current_player)); + s = sprintf(_("^2You're queued to join the %s%s^2 team"), Team_ColorCode(tm), Team_ColorName(tm)); + } + else + s = sprintf(_("^1Press ^3%s^1 to join"), getcommandkey(_("jump"), "+jump")); InfoMessage(s); } } diff --git a/qcsrc/client/hud/panel/scoreboard.qc b/qcsrc/client/hud/panel/scoreboard.qc index 1258a5e5d..9e1fd9181 100644 --- a/qcsrc/client/hud/panel/scoreboard.qc +++ b/qcsrc/client/hud/panel/scoreboard.qc @@ -1497,6 +1497,15 @@ vector Scoreboard_DrawOthers(vector item_pos, vector rgb, int this_team, entity if(pl == ignored_pl) continue; + if(entcs_GetWantsJoin(pl.sv_entnum)) + { + vector tmcolor = Team_ColorRGB(Team_IndexToTeam(entcs_GetWantsJoin(pl.sv_entnum))); + tmcolor -= tmcolor * sin(2*M_PI*time); + + drawstring(pos, "(Q)", hud_fontsize, tmcolor, sbt_fg_alpha, DRAWFLAG_NORMAL); + pos.x += stringwidth("(Q) ", true, hud_fontsize); + } + field = ""; if(this_team == NUM_SPECTATOR) { diff --git a/qcsrc/common/ent_cs.qc b/qcsrc/common/ent_cs.qc index 85119de08..11c5e7bb0 100644 --- a/qcsrc/common/ent_cs.qc +++ b/qcsrc/common/ent_cs.qc @@ -152,6 +152,10 @@ ENTCS_PROP(FRAGS, true, frags, frags, ENTCS_SET_NORMAL, { WriteShort(chan, ent.frags); }, { ent.frags = ReadShort(); }) +ENTCS_PROP(WANTSJOIN, true, wants_join, wants_join, ENTCS_SET_NORMAL, + { WriteByte(chan, ent.wants_join); }, + { ent.wants_join = ReadByte(); }) + // use sv_solid to avoid changing solidity state of entcs entities ENTCS_PROP(SOLID, true, sv_solid, solid, ENTCS_SET_NORMAL, { WriteByte(chan, ent.sv_solid); }, diff --git a/qcsrc/common/ent_cs.qh b/qcsrc/common/ent_cs.qh index aa689e59d..3c96661e6 100644 --- a/qcsrc/common/ent_cs.qh +++ b/qcsrc/common/ent_cs.qh @@ -71,6 +71,7 @@ REGISTER_NET_TEMP(CLIENT_ENTCS) * @param i zero indexed player */ .int frags; + .int wants_join; const int ENTCS_SPEC_PURE = 1; // real spectator const int ENTCS_SPEC_IN_SCOREBOARD = 2; // spectator but still in game (can be in a team) #define entcs_IsSpectating(i) boolean(entcs_GetSpecState(i)) @@ -90,6 +91,15 @@ REGISTER_NET_TEMP(CLIENT_ENTCS) /** * @param i zero indexed player + */ + int entcs_GetWantsJoin(int i) + { + entity e = entcs_receiver(i); + return e.wants_join; + } + + /** + * @param i zero indexed player */ int entcs_GetClientColors(int i) { diff --git a/qcsrc/common/notifications/all.inc b/qcsrc/common/notifications/all.inc index dc118fac6..2d79f73de 100644 --- a/qcsrc/common/notifications/all.inc +++ b/qcsrc/common/notifications/all.inc @@ -392,6 +392,8 @@ string multiteam_info_sprintf(string input, string teamname) { return ((input != MSG_INFO_NOTIF(JOIN_CONNECT, N_CHATCON, 1, 0, "s1", "", "", _("^BG%s^F3 connected"), "") MSG_INFO_NOTIF(JOIN_PLAY, N_CHATCON, 1, 0, "s1", "", "", _("^BG%s^F3 is now playing"), "") MULTITEAM_INFO(JOIN_PLAY_TEAM, N_CHATCON, 1, 0, "s1", "", "", _("^BG%s^F3 is now playing on the ^TC^TT team"), "", NAME) + MULTITEAM_INFO(JOIN_WANTS_TEAM, N_CHATCON, 1, 0, "s1", "", "", _("^BG%s^F3 wants to play on the ^TC^TT team"), "", NAME) + MSG_INFO_NOTIF(JOIN_WANTS, N_CHATCON, 1, 0, "s1", "", "", _("^BG%s^F3 wants to play"), "") MSG_INFO_NOTIF(KEEPAWAY_DROPPED, N_CONSOLE, 1, 0, "s1", "s1", "notify_balldropped", _("^BG%s^BG has dropped the ball!"), "") MSG_INFO_NOTIF(KEEPAWAY_PICKUP, N_CONSOLE, 1, 0, "s1", "s1", "notify_ballpickedup", _("^BG%s^BG has picked up the ball!"), "") @@ -424,10 +426,13 @@ string multiteam_info_sprintf(string input, string teamname) { return ((input != MSG_INFO_NOTIF(QUIT_DISCONNECT, N_CHATCON, 1, 0, "s1", "", "", _("^BG%s^F3 disconnected"), "") MSG_INFO_NOTIF(QUIT_KICK_IDLING, N_CHATCON, 1, 1, "s1 f1", "", "", _("^BG%s^F3 was kicked after idling for %s seconds"), "") MSG_INFO_NOTIF(MOVETOSPEC_IDLING, N_CHATCON, 1, 1, "s1 f1", "", "", _("^BG%s^F3 was moved to^BG spectators^F3 after idling for %s seconds"), "") + MSG_INFO_NOTIF(MOVETOSPEC_IDLING_QUEUE, N_CHATCON, 1, 1, "s1 f1", "", "", _("^BG%s^F3 has left the queue after idling for %s seconds"), "") + MSG_INFO_NOTIF(MOVETOSPEC_REMOVE, N_CHATCON, 1, 0, "s1", "", "", _("^BG%s^F3 was moved to^BG spectators^F3 for balance reasons"), "") MSG_INFO_NOTIF(QUIT_KICK_SPECTATING, N_CONSOLE, 0, 0, "", "", "", _("^F2You were kicked from the server because you are a spectator and spectators aren't allowed at the moment."), "") MSG_INFO_NOTIF(QUIT_KICK_TEAMKILL, N_CHATCON, 1, 0, "s1", "", "", _("^BG%s^F3 was kicked for excessive teamkilling"), "") MSG_INFO_NOTIF(QUIT_PLAYBAN_TEAMKILL, N_CHATCON, 1, 0, "s1", "", "", _("^BG%s^F3 was forced to spectate for excessive teamkilling"), "") MSG_INFO_NOTIF(QUIT_SPECTATE, N_CHATCON, 1, 0, "s1", "", "", _("^BG%s^F3 is now^BG spectating"), "") + MSG_INFO_NOTIF(QUIT_QUEUE, N_CHATCON, 1, 0, "s1", "", "", _("^BG%s^F3 has left the queue"), "") MSG_INFO_NOTIF(RACE_ABANDONED, N_CONSOLE, 1, 0, "s1", "", "", _("^BG%s^BG has abandoned the race"), "") MSG_INFO_NOTIF(RACE_FAIL_RANKED, N_CONSOLE, 1, 3, "s1 race_col f1ord race_col f3race_time race_diff", "s1 f3race_time", "race_newfail", _("^BG%s^BG couldn't break their %s%s^BG place record of %s%s %s"), "") @@ -677,6 +682,7 @@ string multiteam_info_sprintf(string input, string teamname) { return ((input != MSG_CENTER_NOTIF(DISCONNECT_IDLING, N_ENABLE, 0, 1, "", CPID_IDLING, "1 f1", BOLD(_("^K1Stop idling!\n^BGDisconnecting in ^COUNT...")), "") MSG_CENTER_NOTIF(MOVETOSPEC_IDLING, N_ENABLE, 0, 1, "", CPID_IDLING, "1 f1", BOLD(_("^K1Stop idling!\n^BGMoving to spectators in ^COUNT...")), "") + MSG_CENTER_NOTIF(MOVETOSPEC_REMOVE, N_ENABLE, 1, 1, "s1", CPID_REMOVE, "1 f1", BOLD(_("^K1Teams unbalanced!\n^BGMoving %s^BG to spectators in ^COUNT...")), "") MSG_CENTER_NOTIF(DOOR_LOCKED_NEED, N_ENABLE, 1, 0, "s1", CPID_Null, "0 0", _("^BGYou need %s^BG!"), "") MSG_CENTER_NOTIF(DOOR_LOCKED_ALSONEED, N_ENABLE, 1, 0, "s1", CPID_Null, "0 0", _("^BGYou also need %s^BG!"), "") @@ -715,6 +721,9 @@ string multiteam_info_sprintf(string input, string teamname) { return ((input != MSG_CENTER_NOTIF(JOIN_PLAYBAN, N_ENABLE, 0, 0, "", CPID_PREVENT_JOIN, "0 0", BOLD(_("^K1You aren't allowed to play because you are banned in this server")), "") MSG_CENTER_NOTIF(JOIN_PREVENT, N_ENABLE, 0, 1, "f1", CPID_PREVENT_JOIN, "0 0", _("^K1You may not join the game at this time.\nThis match is limited to ^F2%s^BG players."), "") MSG_CENTER_NOTIF(JOIN_PREVENT_MINIGAME, N_ENABLE, 0, 0, "", CPID_Null, "0 0", _("^K1Cannot join given minigame session!"), "" ) + MSG_CENTER_NOTIF(JOIN_PREVENT_QUEUE, N_ENABLE, 0, 0, "", CPID_PREVENT_JOIN, "0 0", _("^BGYou are now queued to join the game."), "") + MULTITEAM_CENTER(JOIN_PREVENT_QUEUE_TEAM, N_ENABLE, 0, 0, "", CPID_PREVENT_JOIN, "0 0", _("^BGYou are now queued to join on ^TC^TT^BG team."), "", NAME) + MULTITEAM_CENTER(JOIN_PREVENT_QUEUE_TEAM_FAIL, N_ENABLE, 1, 0, "s1", CPID_PREVENT_JOIN, "0 0", _("^K2Please choose a different team! %s^K2 chose ^TC^TT^BG first."), "", NAME) MSG_CENTER_NOTIF(KEEPAWAY_DROPPED, N_ENABLE, 1, 0, "s1", CPID_KEEPAWAY, "0 0", _("^BG%s^BG has dropped the ball!"), "") MSG_CENTER_NOTIF(KEEPAWAY_PICKUP, N_ENABLE, 1, 0, "s1", CPID_KEEPAWAY, "0 0", _("^BG%s^BG has picked up the ball!"), "") diff --git a/qcsrc/common/notifications/all.qh b/qcsrc/common/notifications/all.qh index f4c25968c..2ac682f96 100644 --- a/qcsrc/common/notifications/all.qh +++ b/qcsrc/common/notifications/all.qh @@ -52,6 +52,7 @@ ENUMCLASS(CPID) CASE(CPID, STALEMATE) CASE(CPID, NADES) CASE(CPID, IDLING) + CASE(CPID, REMOVE) CASE(CPID, ITEM) CASE(CPID, PREVENT_JOIN) CASE(CPID, KEEPAWAY) diff --git a/qcsrc/common/teams.qh b/qcsrc/common/teams.qh index 62c3e7b16..dfedb2a27 100644 --- a/qcsrc/common/teams.qh +++ b/qcsrc/common/teams.qh @@ -83,7 +83,7 @@ vector Team_ColorRGB(int teamid) case NUM_TEAM_4: return '1 0.0625 1'; // 0xFF0FFF } - return '0 0 0'; + return '1 1 1'; } string Team_ColorName(int teamid) diff --git a/qcsrc/server/client.qc b/qcsrc/server/client.qc index 1d62feffa..ead29f4ce 100644 --- a/qcsrc/server/client.qc +++ b/qcsrc/server/client.qc @@ -1134,6 +1134,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? @@ -1305,6 +1306,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) @@ -2000,24 +2005,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) @@ -2030,10 +2049,13 @@ void Join(entity this) 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() @@ -2106,6 +2128,31 @@ int nJoinAllowed(entity this, entity ignore) 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; @@ -2114,6 +2161,8 @@ bool joinAllowed(entity this) 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; } @@ -2379,7 +2428,7 @@ void ObserverOrSpectatorThink(entity this) { 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; @@ -2484,7 +2533,7 @@ void PlayerPreThink (entity this) && (!teamplay || autocvar_g_balance_teams))) { if(joinAllowed(this)) - Join(this); + Join(this, true); return; } } @@ -2749,9 +2798,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; @@ -2768,7 +2817,7 @@ void PlayerFrame (entity this) 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), { @@ -2792,22 +2841,35 @@ void PlayerFrame (entity this) 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 { @@ -2816,7 +2878,9 @@ void PlayerFrame (entity this) } 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; diff --git a/qcsrc/server/client.qh b/qcsrc/server/client.qh index 9611df4e8..e8fcd5ffe 100644 --- a/qcsrc/server/client.qh +++ b/qcsrc/server/client.qh @@ -72,7 +72,9 @@ float autocvar_sv_player_scale; .int spectatee_status; .bool zoomstate; +.bool team_selected; .bool just_joined; +.bool wants_join; .int pressedkeys; @@ -158,6 +160,7 @@ CLASS(Client, Object) ATTRIB(Client, teamkill_soundsource, entity, this.teamkill_soundsource); ATTRIB(Client, usekeypressed, bool, this.usekeypressed); ATTRIB(Client, jointime, float, this.jointime); + ATTRIB(Client, wants_join, bool, this.wants_join); ATTRIB(Client, spectatortime, float, this.spectatortime); ATTRIB(Client, startplaytime, float, this.startplaytime); ATTRIB(Client, version_nagtime, float, this.version_nagtime); @@ -361,8 +364,6 @@ bool PlayerInIPList(entity p, string iplist); void ClientData_Touch(entity e); -int nJoinAllowed(entity this, entity ignore); - void PlayerUseKey(entity this); void FixClientCvars(entity e); @@ -399,8 +400,10 @@ void ClientInit_misc(entity this); int GetPlayerLimit(); const int MIN_SPEC_TIME = 1; +void Join(entity this, bool queued_join); +int nJoinAllowed(entity this, entity ignore); +bool queuePlayer(entity this, int team_index); bool joinAllowed(entity this); -void Join(entity this); void PlayerFrame (entity this); diff --git a/qcsrc/server/clientkill.qc b/qcsrc/server/clientkill.qc index 296a95bfe..d61481ab0 100644 --- a/qcsrc/server/clientkill.qc +++ b/qcsrc/server/clientkill.qc @@ -25,7 +25,21 @@ void ClientKill_Now_TeamChange(entity this) { if (blockSpectators) Send_Notification(NOTIF_ONE_ONLY, this, MSG_INFO, INFO_SPECTATE_WARNING, autocvar_g_maxplayers_spectator_blocktime); - PutObserverInServer(this, false, true); + + if (this.wants_join) + { + this.wants_join = 0; + this.team_selected = false; + Send_Notification(NOTIF_ALL, NULL, MSG_INFO, INFO_QUIT_QUEUE, this.netname); + SetPlayerTeam(this, -1, TEAM_CHANGE_SPECTATOR); + } + else + { + PutObserverInServer(this, false, true); + if (!TeamBalance_QueuedPlayersTagIn(this)) + if (autocvar_g_balance_teams_remove) + TeamBalance_RemoveExcessPlayers(this); + } } else { diff --git a/qcsrc/server/command/cmd.qc b/qcsrc/server/command/cmd.qc index f93880a68..0cc6086e8 100644 --- a/qcsrc/server/command/cmd.qc +++ b/qcsrc/server/command/cmd.qc @@ -359,7 +359,7 @@ void ClientCommand_join(entity caller, int request) 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 @@ void ClientCommand_selectteam(entity caller, int request, int argc) 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 @@ void ClientCommand_spectate(entity caller, int request) 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 } diff --git a/qcsrc/server/main.qc b/qcsrc/server/main.qc index 72e3dac3d..194808177 100644 --- a/qcsrc/server/main.qc +++ b/qcsrc/server/main.qc @@ -59,6 +59,11 @@ bool dropclient_schedule(entity this) setthink(e, dropclient_do); e.owner = this; e.nextthink = time + 0.1; + + // ignore this player for team balancing and queuing + this.team = -1; + this.wants_join = 0; + this.classname = STR_OBSERVER; return true; } diff --git a/qcsrc/server/teamplay.qc b/qcsrc/server/teamplay.qc index 1cfdbf4a0..8ce35494a 100644 --- a/qcsrc/server/teamplay.qc +++ b/qcsrc/server/teamplay.qc @@ -234,6 +234,27 @@ bool Player_SetTeamIndex(entity player, int index) 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); @@ -252,7 +273,13 @@ bool SetPlayerTeam(entity player, int team_index, int type) 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 @@ void Player_SetTeamIndexChecked(entity player, int team_index) } } 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 @@ int TeamBalance_GetAllowedTeams(entity balance) 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 @@ void TeamBalance_GetTeamCounts(entity balance, entity ignore) { 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)) diff --git a/qcsrc/server/teamplay.qh b/qcsrc/server/teamplay.qh index d96b7df4b..61d086c78 100644 --- a/qcsrc/server/teamplay.qh +++ b/qcsrc/server/teamplay.qh @@ -7,6 +7,9 @@ bool autocvar_teamplay_lockonrestart; bool autocvar_g_balance_teams; bool autocvar_g_balance_teams_prevent_imbalance; +bool autocvar_g_balance_teams_queue; +bool autocvar_g_balance_teams_remove; +int autocvar_g_balance_teams_remove_wait; string autocvar_g_forced_team_otherwise; @@ -14,6 +17,10 @@ bool lockteams; .int team_forced; // can be a team number to force a team, or 0 for default action, or -1 for forced spectator +#define IS_QUEUE_NEEDED(ignore) \ + (teamplay && !warmup_stage && autocvar_g_balance_teams_queue && !autocvar_g_campaign \ + && TeamBalance_AreEqual(ignore, true)) + // ========================== Global teams API ================================ void Team_InitTeams(); @@ -113,6 +120,8 @@ enum TEAM_CHANGE_SPECTATOR = 4 ///< Player is joining spectators. //TODO: Remove? }; +bool QueuedPlayersReady(entity this, bool checkspecificteam); + /// \brief Sets the team of the player. /// \param[in,out] player Player to adjust. /// \param[in] team_index Index of the team to set. @@ -183,6 +192,16 @@ void TeamBalance_Destroy(entity balance); /// \return Bitmask of allowed teams. int TeamBalance_GetAllowedTeams(entity balance); +bool TeamBalance_AreEqual(entity ignore, bool would_leave); +void TeamBalance_RemoveExcessPlayers(entity ignore); +/** Joins queued player(s) to team(s) with a shortage, + * this should be more robust than only replacing the player that left. + * Chooses players with a specific team preference first + * to increase chances of everyone getting what they want. + * Returns true if the teams are now balanced. + */ +bool TeamBalance_QueuedPlayersTagIn(entity ignore); + /// \brief Returns whether the team change to the specified team is allowed. /// \param[in] balance Team balance entity. /// \param[in] index Index of the team. diff --git a/qcsrc/server/world.qc b/qcsrc/server/world.qc index 4ce29a707..7779879d8 100644 --- a/qcsrc/server/world.qc +++ b/qcsrc/server/world.qc @@ -450,6 +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"); diff --git a/xonotic-server.cfg b/xonotic-server.cfg index e63857722..461c2e524 100644 --- a/xonotic-server.cfg +++ b/xonotic-server.cfg @@ -281,6 +281,9 @@ set g_teamdamage_resetspeed 20 "for teamplay_mode 4: how fast player's team set g_balance_teams 1 "automatically balance out players entering instead of asking them for their preferred team" set g_balance_teams_prevent_imbalance 1 "prevent players from changing to larger teams" +set g_balance_teams_queue 0 "queue players to maintain balance when they join during the match" +set g_balance_teams_remove 0 "remove excess players from teams to maintain balance when someone leaves (currently does nothing in matches with more than 2 teams)" +set g_balance_teams_remove_wait 10 "seconds to warn everyone before removing an excess player (0 = immediately)" set g_changeteam_banned 0 "not allowed to change team" set sv_teamnagger 1 "enable a nag message when the teams are unbalanced" -- 2.39.2