diff --git a/OpenRA.Game/Game.cs b/OpenRA.Game/Game.cs index 892579ca9e..b4a2670c39 100644 --- a/OpenRA.Game/Game.cs +++ b/OpenRA.Game/Game.cs @@ -86,8 +86,9 @@ namespace OpenRA static void JoinInner(OrderManager om) { - // Refresh TextNotificationsManager before the game starts. + // Refresh static classes before the game starts. TextNotificationsManager.Clear(); + UnitOrders.Clear(); // HACK: The shellmap World and OrderManager are owned by the main menu's WorldRenderer instead of Game. // This allows us to switch Game.OrderManager from the shellmap to the new network connection when joining diff --git a/OpenRA.Game/Network/UnitOrders.cs b/OpenRA.Game/Network/UnitOrders.cs index b869caa8ef..aed15b5740 100644 --- a/OpenRA.Game/Network/UnitOrders.cs +++ b/OpenRA.Game/Network/UnitOrders.cs @@ -20,11 +20,15 @@ namespace OpenRA.Network { public const int ChatMessageMaxLength = 2500; + public static int? KickVoteTarget { get; internal set; } + static Player FindPlayerByClient(this World world, Session.Client c) { return world.Players.FirstOrDefault(p => p.ClientIndex == c.Index && p.PlayerReference.Playable); } + static bool OrderNotFromServerOrWorldIsReplay(int clientId, World world) => clientId != 0 || (world != null && world.IsReplay); + internal static void ProcessOrder(OrderManager orderManager, World world, int clientId, Order order) { switch (order.OrderString) @@ -52,9 +56,7 @@ namespace OpenRA.Network case "DisableChatEntry": { - // Order must originate from the server - // Don't disable chat in replays - if (clientId != 0 || (world != null && world.IsReplay)) + if (OrderNotFromServerOrWorldIsReplay(clientId, world)) break; // Server may send MaxValue to indicate that it is disabled until further notice @@ -66,6 +68,26 @@ namespace OpenRA.Network break; } + case "StartKickVote": + { + if (OrderNotFromServerOrWorldIsReplay(clientId, world)) + break; + + KickVoteTarget = (int)order.ExtraData; + break; + } + + case "EndKickVote": + { + if (OrderNotFromServerOrWorldIsReplay(clientId, world)) + break; + + if (KickVoteTarget == (int)order.ExtraData) + KickVoteTarget = null; + + break; + } + case "Chat": { var client = orderManager.LobbyInfo.ClientWithIndex(clientId); @@ -365,5 +387,10 @@ namespace OpenRA.Network if (world.OrderValidators.All(vo => vo.OrderValidation(orderManager, world, clientId, order))) order.Subject.ResolveOrder(order); } + + public static void Clear() + { + KickVoteTarget = null; + } } } diff --git a/OpenRA.Game/Server/Server.cs b/OpenRA.Game/Server/Server.cs index 4d1002a93d..ff36189bb7 100644 --- a/OpenRA.Game/Server/Server.cs +++ b/OpenRA.Game/Server/Server.cs @@ -148,6 +148,8 @@ namespace OpenRA.Server GameInformation gameInfo; readonly List worldPlayers = new(); readonly Stopwatch pingUpdated = Stopwatch.StartNew(); + + public readonly VoteKickTracker VoteKickTracker; readonly PlayerMessageTracker playerMessageTracker; public ServerState State @@ -318,6 +320,7 @@ namespace OpenRA.Server MapStatusCache = new MapStatusCache(modData, MapStatusChanged, type == ServerType.Dedicated && settings.EnableLintChecks); playerMessageTracker = new PlayerMessageTracker(this, DispatchOrdersToClient, SendLocalizedMessageTo); + VoteKickTracker = new VoteKickTracker(this); LobbyInfo = new Session { @@ -1163,15 +1166,8 @@ namespace OpenRA.Server return LobbyInfo.ClientWithIndex(conn.PlayerIndex); } - /// Does not check if client is admin. - public bool CanKickClient(Session.Client kickee) - { - if (State != ServerState.GameStarted || kickee.IsObserver) - return true; - - var player = worldPlayers.FirstOrDefault(p => p?.ClientIndex == kickee.Index); - return player != null && player.Outcome != WinState.Undefined; - } + public bool HasClientWonOrLost(Session.Client client) => + worldPlayers.FirstOrDefault(p => p?.ClientIndex == client.Index)?.Outcome != WinState.Undefined; public void DropClient(Connection toDrop) { diff --git a/OpenRA.Game/Server/VoteKickTracker.cs b/OpenRA.Game/Server/VoteKickTracker.cs new file mode 100644 index 0000000000..32717d77b3 --- /dev/null +++ b/OpenRA.Game/Server/VoteKickTracker.cs @@ -0,0 +1,223 @@ +#region Copyright & License Information +/* + * Copyright (c) The OpenRA Developers and Contributors + * This file is part of OpenRA, which is free software. It is made + * available to you under the terms of the GNU General Public License + * as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. For more + * information, see COPYING. + */ +#endregion + +using System.Collections.Generic; +using System.Diagnostics; +using OpenRA.Network; + +namespace OpenRA.Server +{ + public sealed class VoteKickTracker + { + [TranslationReference("kickee")] + const string InsufficientVotes = "notification-insufficient-votes-to-kick"; + + [TranslationReference] + const string AlreadyVoted = "notification-kick-already-voted"; + + [TranslationReference("kicker", "kickee")] + const string VoteKickStarted = "notification-vote-kick-started"; + + [TranslationReference] + const string UnableToStartAVote = "notification-unable-to-start-a-vote"; + + [TranslationReference("kickee", "percentage")] + const string VoteKickProgress = "notification-vote-kick-in-progress"; + + [TranslationReference("kickee")] + const string VoteKickEnded = "notification-vote-kick-ended"; + + readonly Dictionary voteTracker = new(); + readonly Dictionary failedVoteKickers = new(); + readonly Server server; + + Stopwatch voteKickTimer; + (Session.Client Client, Connection Conn) kickee; + (Session.Client Client, Connection Conn) voteKickerStarter; + + public VoteKickTracker(Server server) + { + this.server = server; + } + + // Only admins and alive players can participate in a vote kick. + bool ClientHasPower(Session.Client client) => client.IsAdmin || (!client.IsObserver && !server.HasClientWonOrLost(client)); + + public void Tick() + { + if (voteKickTimer == null) + return; + + if (!server.Conns.Contains(kickee.Conn)) + { + EndKickVote(); + return; + } + + if (voteKickTimer.ElapsedMilliseconds > server.Settings.VoteKickTimer) + EndKickVoteAndBlockKicker(); + } + + public bool VoteKick(Connection conn, Session.Client kicker, Connection kickeeConn, Session.Client kickee, int kickeeID, bool vote) + { + var voteInProgress = voteKickTimer != null; + + if (server.State != ServerState.GameStarted + || (kickee.IsAdmin && server.Type != ServerType.Dedicated) + || (!voteInProgress && !vote) // Disallow starting a vote with a downvote + || (voteInProgress && this.kickee.Client != kickee) // Disallow starting new votes when one is already ongoing. + || !ClientHasPower(kicker)) + { + server.SendLocalizedMessageTo(conn, UnableToStartAVote); + return false; + } + + short eligiblePlayers = 0; + var isKickeeOnline = false; + var adminIsDeadButOnline = false; + foreach (var c in server.Conns) + { + var client = server.GetClient(c); + if (client != kickee && ClientHasPower(client)) + eligiblePlayers++; + + if (c == kickeeConn) + isKickeeOnline = true; + + if (client.IsAdmin && (client.IsObserver || server.HasClientWonOrLost(client))) + adminIsDeadButOnline = true; + } + + if (!isKickeeOnline) + { + EndKickVote(); + return false; + } + + if (eligiblePlayers < 2 || (adminIsDeadButOnline && !kickee.IsAdmin && eligiblePlayers < 3)) + { + if (!kickee.IsObserver && !server.HasClientWonOrLost(kickee)) + { + // Vote kick cannot be the sole deciding factor for a game. + server.SendLocalizedMessageTo(conn, InsufficientVotes, Translation.Arguments("kickee", kickee.Name)); + EndKickVote(); + return false; + } + else if (vote) + { + // If only a single player is playing, allow him to kick observers. + EndKickVote(false); + return true; + } + } + + if (!voteInProgress) + { + // Prevent vote kick spam abuse. + if (failedVoteKickers.TryGetValue(kicker, out var time)) + { + if (time + server.Settings.VoteKickerCooldown > kickeeConn.ConnectionTimer.ElapsedMilliseconds) + { + server.SendLocalizedMessageTo(conn, UnableToStartAVote); + return false; + } + else + failedVoteKickers.Remove(kicker); + } + + Log.Write("server", $"Vote kick started on {kickeeID}."); + voteKickTimer = Stopwatch.StartNew(); + server.SendLocalizedMessage(VoteKickStarted, Translation.Arguments("kicker", kicker.Name, "kickee", kickee.Name)); + server.DispatchServerOrdersToClients(new Order("StartKickVote", null, false) { ExtraData = (uint)kickeeID }.Serialize()); + this.kickee = (kickee, kickeeConn); + voteKickerStarter = (kicker, conn); + } + + if (!voteTracker.ContainsKey(conn.PlayerIndex)) + voteTracker[conn.PlayerIndex] = vote; + else + { + server.SendLocalizedMessageTo(conn, AlreadyVoted, null); + return false; + } + + short votesFor = 0; + short votesAgainst = 0; + foreach (var c in voteTracker) + { + if (c.Value) + votesFor++; + else + votesAgainst++; + } + + // Include the kickee in eligeablePlayers, so that in a 2v2 or any other even team + // matchup one team could not vote out the other team's player. + if (ClientHasPower(kickee)) + { + eligiblePlayers++; + votesAgainst++; + } + + var votesNeeded = eligiblePlayers / 2 + 1; + server.SendLocalizedMessage(VoteKickProgress, Translation.Arguments( + "kickee", kickee.Name, + "percentage", votesFor * 100 / eligiblePlayers)); + + // If a player or players during a vote lose or disconnect, it is possible that a downvote will + // kick a client. Guard against that situation. + if (vote && (votesFor >= votesNeeded)) + { + EndKickVote(false); + return true; + } + + // End vote if it can never succeed. + if (eligiblePlayers - votesAgainst < votesNeeded) + { + EndKickVoteAndBlockKicker(); + return false; + } + + voteKickTimer.Restart(); + return false; + } + + void EndKickVoteAndBlockKicker() + { + // Make sure vote kick is in progress. + if (voteKickTimer == null) + return; + + if (server.Conns.Contains(voteKickerStarter.Conn)) + failedVoteKickers[voteKickerStarter.Client] = voteKickerStarter.Conn.ConnectionTimer.ElapsedMilliseconds; + + EndKickVote(); + } + + void EndKickVote(bool sendMessage = true) + { + // Make sure vote kick is in progress. + if (voteKickTimer == null) + return; + + if (sendMessage) + server.SendLocalizedMessage(VoteKickEnded, Translation.Arguments("kickee", kickee.Client.Name)); + + server.DispatchServerOrdersToClients(new Order("EndKickVote", null, false) { ExtraData = (uint)kickee.Client.Index }.Serialize()); + + voteKickTimer = null; + voteKickerStarter = (null, null); + kickee = (null, null); + voteTracker.Clear(); + } + } +} diff --git a/OpenRA.Game/Settings.cs b/OpenRA.Game/Settings.cs index 4abdcb7335..a7ae46acfb 100644 --- a/OpenRA.Game/Settings.cs +++ b/OpenRA.Game/Settings.cs @@ -114,6 +114,15 @@ namespace OpenRA [Desc("Delay in milliseconds before players can send chat messages after flood was detected.")] public int FloodLimitCooldown = 15000; + [Desc("Can players vote to kick other players?")] + public bool EnableVoteKick = true; + + [Desc("After how much time in miliseconds should the vote kick fail after idling?")] + public int VoteKickTimer = 30000; + + [Desc("If a vote kick was unsuccessful for how long should the player who started the vote not be able to start new votes?")] + public int VoteKickerCooldown = 120000; + public ServerSettings Clone() { return (ServerSettings)MemberwiseClone(); diff --git a/OpenRA.Mods.Common/ServerTraits/LobbyCommands.cs b/OpenRA.Mods.Common/ServerTraits/LobbyCommands.cs index 9c9ab1ff00..f83def523e 100644 --- a/OpenRA.Mods.Common/ServerTraits/LobbyCommands.cs +++ b/OpenRA.Mods.Common/ServerTraits/LobbyCommands.cs @@ -22,7 +22,7 @@ using S = OpenRA.Server.Server; namespace OpenRA.Mods.Common.Server { - public class LobbyCommands : ServerTrait, IInterpretCommand, INotifyServerStart, INotifyServerEmpty, IClientJoined + public class LobbyCommands : ServerTrait, IInterpretCommand, INotifyServerStart, INotifyServerEmpty, IClientJoined, OpenRA.Server.ITick { [TranslationReference] const string CustomRules = "notification-custom-rules"; @@ -55,6 +55,9 @@ namespace OpenRA.Mods.Common.Server const string NoKickGameStarted = "notification-no-kick-game-started"; [TranslationReference("admin", "player")] + const string AdminKicked = "notification-admin-kicked"; + + [TranslationReference("player")] const string Kicked = "notification-kicked"; [TranslationReference("admin", "player")] @@ -156,6 +159,9 @@ namespace OpenRA.Mods.Common.Server [TranslationReference] const string YouWereKicked = "notification-you-were-kicked"; + [TranslationReference] + const string VoteKickDisabled = "notification-vote-kick-disabled"; + readonly IDictionary> commandHandlers = new Dictionary> { { "state", State }, @@ -170,6 +176,7 @@ namespace OpenRA.Mods.Common.Server { "option", Option }, { "assignteams", AssignTeams }, { "kick", Kick }, + { "vote_kick", VoteKick }, { "make_admin", MakeAdmin }, { "make_spectator", MakeSpectator }, { "name", Name }, @@ -207,7 +214,7 @@ namespace OpenRA.Mods.Common.Server lock (server.LobbyInfo) { // Kick command is always valid for the host - if (command.StartsWith("kick ")) + if (command.StartsWith("kick ", StringComparison.Ordinal) || command.StartsWith("vote_kick ", StringComparison.Ordinal)) return true; if (server.State == ServerState.GameStarted) @@ -804,14 +811,14 @@ namespace OpenRA.Mods.Common.Server return true; } - if (!server.CanKickClient(kickClient)) + if (server.State == ServerState.GameStarted && !kickClient.IsObserver && !server.HasClientWonOrLost(kickClient)) { server.SendLocalizedMessageTo(conn, NoKickGameStarted); return true; } Log.Write("server", $"Kicking client {kickClientID}."); - server.SendLocalizedMessage(Kicked, Translation.Arguments("admin", client.Name, "player", kickClient.Name)); + server.SendLocalizedMessage(AdminKicked, Translation.Arguments("admin", client.Name, "player", kickClient.Name)); server.SendOrderTo(kickConn, "ServerError", YouWereKicked); server.DropClient(kickConn); @@ -829,6 +836,62 @@ namespace OpenRA.Mods.Common.Server } } + static bool VoteKick(S server, Connection conn, Session.Client client, string s) + { + lock (server.LobbyInfo) + { + var split = s.Split(' '); + if (split.Length != 2) + { + server.SendLocalizedMessageTo(conn, MalformedCommand, Translation.Arguments("command", "vote_kick")); + return true; + } + + if (!server.Settings.EnableVoteKick) + { + server.SendLocalizedMessageTo(conn, VoteKickDisabled); + return true; + } + + var kickConn = Exts.TryParseInt32Invariant(split[0], out var kickClientID) + ? server.Conns.SingleOrDefault(c => server.GetClient(c)?.Index == kickClientID) : null; + + if (kickConn == null) + { + server.SendLocalizedMessageTo(conn, KickNone); + return true; + } + + var kickClient = server.GetClient(kickConn); + if (client == kickClient) + { + server.SendLocalizedMessageTo(conn, NoKickSelf); + return true; + } + + if (!bool.TryParse(split[1], out var vote)) + { + server.SendLocalizedMessageTo(conn, MalformedCommand, Translation.Arguments("command", "vote_kick")); + return true; + } + + if (server.VoteKickTracker.VoteKick(conn, client, kickConn, kickClient, kickClientID, vote)) + { + Log.Write("server", $"Kicking client {kickClientID}."); + server.SendLocalizedMessage(Kicked, Translation.Arguments("player", kickClient.Name)); + server.SendOrderTo(kickConn, "ServerError", YouWereKicked); + server.DropClient(kickConn); + + server.SyncLobbyClients(); + server.SyncLobbySlots(); + } + + return true; + } + } + + void OpenRA.Server.ITick.Tick(S server) => server.VoteKickTracker.Tick(); + static bool MakeAdmin(S server, Connection conn, Session.Client client, string s) { lock (server.LobbyInfo) diff --git a/OpenRA.Mods.Common/Widgets/Logic/Ingame/GameInfoStatsLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/Ingame/GameInfoStatsLogic.cs index 760a2df564..c91e35b4ae 100644 --- a/OpenRA.Mods.Common/Widgets/Logic/Ingame/GameInfoStatsLogic.cs +++ b/OpenRA.Mods.Common/Widgets/Logic/Ingame/GameInfoStatsLogic.cs @@ -49,6 +49,9 @@ namespace OpenRA.Mods.Common.Widgets.Logic [TranslationReference] const string Gone = "label-client-state-disconnected"; + [TranslationReference] + const string KickTooltip = "button-kick-player"; + [TranslationReference("player")] const string KickTitle = "dialog-kick.title"; @@ -58,6 +61,30 @@ namespace OpenRA.Mods.Common.Widgets.Logic [TranslationReference] const string KickAccept = "dialog-kick.confirm"; + [TranslationReference] + const string KickVoteTooltip = "button-vote-kick-player"; + + [TranslationReference("player")] + const string VoteKickTitle = "dialog-vote-kick.title"; + + [TranslationReference] + const string VoteKickPrompt = "dialog-vote-kick.prompt"; + + [TranslationReference("bots")] + const string VoteKickPromptBreakBots = "dialog-vote-kick.prompt-break-bots"; + + [TranslationReference] + const string VoteKickVoteStart = "dialog-vote-kick.vote-start"; + + [TranslationReference] + const string VoteKickVoteFor = "dialog-vote-kick.vote-for"; + + [TranslationReference] + const string VoteKickVoteAgainst = "dialog-vote-kick.vote-against"; + + [TranslationReference] + const string VoteKickVoteCancel = "dialog-vote-kick.vote-cancel"; + [ObjectCreator.UseCtor] public GameInfoStatsLogic(Widget widget, ModData modData, World world, OrderManager orderManager, WorldRenderer worldRenderer, Action hideMenu) { @@ -106,6 +133,8 @@ namespace OpenRA.Mods.Common.Widgets.Logic var spectatorTemplate = playerPanel.Get("SPECTATOR_TEMPLATE"); var unmuteTooltip = TranslationProvider.GetString(Unmute); var muteTooltip = TranslationProvider.GetString(Mute); + var kickTooltip = TranslationProvider.GetString(KickTooltip); + var voteKickTooltip = TranslationProvider.GetString(KickVoteTooltip); playerPanel.RemoveChildren(); var teams = world.Players.Where(p => !p.NonCombatant && p.Playable) @@ -114,22 +143,78 @@ namespace OpenRA.Mods.Common.Widgets.Logic .GroupBy(p => (world.LobbyInfo.ClientWithIndex(p.Player.ClientIndex) ?? new Session.Client()).Team) .OrderByDescending(g => g.Sum(gg => gg.PlayerStatistics?.Experience ?? 0)); - void KickAction(Session.Client client) + void KickAction(Session.Client client, Func isVoteKick) { hideMenu(true); - ConfirmationDialogs.ButtonPrompt(modData, - title: KickTitle, - titleArguments: Translation.Arguments("player", client.Name), - text: KickPrompt, - onConfirm: () => + if (isVoteKick()) + { + var botsCount = 0; + if (client.IsAdmin) + botsCount = world.Players.Count(p => p.IsBot && p.WinState == WinState.Undefined); + + if (UnitOrders.KickVoteTarget == null) { - orderManager.IssueOrder(Order.Command($"kick {client.Index} {false}")); - hideMenu(false); - }, - onCancel: () => hideMenu(false), - confirmText: KickAccept); + ConfirmationDialogs.ButtonPrompt(modData, + title: VoteKickTitle, + titleArguments: Translation.Arguments("player", client.Name), + text: botsCount > 0 ? VoteKickPromptBreakBots : VoteKickPrompt, + textArguments: Translation.Arguments("bots", botsCount), + onConfirm: () => + { + orderManager.IssueOrder(Order.Command($"vote_kick {client.Index} {true}")); + hideMenu(false); + }, + confirmText: VoteKickVoteStart, + onCancel: () => hideMenu(false)); + return; + } + + ConfirmationDialogs.ButtonPrompt(modData, + title: VoteKickTitle, + titleArguments: Translation.Arguments("player", client.Name), + text: botsCount > 0 ? VoteKickPromptBreakBots : VoteKickPrompt, + textArguments: Translation.Arguments("bots", botsCount), + onConfirm: () => + { + orderManager.IssueOrder(Order.Command($"vote_kick {client.Index} {true}")); + hideMenu(false); + }, + confirmText: VoteKickVoteFor, + onOther: () => + { + Ui.CloseWindow(); + orderManager.IssueOrder(Order.Command($"vote_kick {client.Index} {false}")); + hideMenu(false); + }, + otherText: VoteKickVoteAgainst, + onCancel: () => hideMenu(false), + cancelText: VoteKickVoteCancel); + } + else + { + ConfirmationDialogs.ButtonPrompt(modData, + title: KickTitle, + titleArguments: Translation.Arguments("player", client.Name), + text: KickPrompt, + onConfirm: () => + { + orderManager.IssueOrder(Order.Command($"kick {client.Index} {false}")); + hideMenu(false); + }, + confirmText: KickAccept, + onCancel: () => hideMenu(false)); + } } + var localClient = orderManager.LocalClient; + var localPlayer = localClient == null ? null : world.Players.FirstOrDefault(player => player.ClientIndex == localClient.Index); + bool LocalPlayerCanKick() => localClient != null + && (Game.IsHost || ((!orderManager.LocalClient.IsObserver) && localPlayer.WinState == WinState.Undefined)); + bool CanClientBeKicked(Session.Client client, Func isVoteKick) => + client.Index != localClient.Index && client.State != Session.ClientState.Disconnected + && (!client.IsAdmin || orderManager.LobbyInfo.GlobalSettings.Dedicated) + && (!isVoteKick() || UnitOrders.KickVoteTarget == null || UnitOrders.KickVoteTarget == client.Index); + foreach (var t in teams) { if (teams.Count() > 1) @@ -182,8 +267,10 @@ namespace OpenRA.Mods.Common.Widgets.Logic muteCheckbox.GetTooltipText = () => muteCheckbox.IsChecked() ? unmuteTooltip : muteTooltip; var kickButton = item.Get("KICK"); - kickButton.IsVisible = () => Game.IsHost && client.Index != orderManager.LocalClient?.Index && client.State != Session.ClientState.Disconnected && pp.WinState != WinState.Undefined && !pp.IsBot; - kickButton.OnClick = () => KickAction(client); + bool IsVoteKick() => !Game.IsHost || pp.WinState == WinState.Undefined; + kickButton.IsVisible = () => !pp.IsBot && LocalPlayerCanKick() && CanClientBeKicked(client, IsVoteKick); + kickButton.OnClick = () => KickAction(client, IsVoteKick); + kickButton.GetTooltipText = () => IsVoteKick() ? voteKickTooltip : kickTooltip; playerPanel.AddChild(item); } @@ -217,8 +304,10 @@ namespace OpenRA.Mods.Common.Widgets.Logic }; var kickButton = item.Get("KICK"); - kickButton.IsVisible = () => Game.IsHost && client.Index != orderManager.LocalClient?.Index && client.State != Session.ClientState.Disconnected; - kickButton.OnClick = () => KickAction(client); + bool IsVoteKick() => !Game.IsHost; + kickButton.IsVisible = () => LocalPlayerCanKick() && CanClientBeKicked(client, IsVoteKick); + kickButton.OnClick = () => KickAction(client, IsVoteKick); + kickButton.GetTooltipText = () => IsVoteKick() ? voteKickTooltip : kickTooltip; var muteCheckbox = item.Get("MUTE"); muteCheckbox.IsChecked = () => TextNotificationsManager.MutedPlayers[client.Index]; diff --git a/mods/cnc/chrome/ingame-infostats.yaml b/mods/cnc/chrome/ingame-infostats.yaml index 9b103d4203..6273053db1 100644 --- a/mods/cnc/chrome/ingame-infostats.yaml +++ b/mods/cnc/chrome/ingame-infostats.yaml @@ -139,7 +139,6 @@ Container@SKIRMISH_STATS: Height: 25 Background: checkbox-toggle TooltipContainer: TOOLTIP_CONTAINER - TooltipText: Kick this player Children: Image: ImageCollection: lobby-bits @@ -182,7 +181,6 @@ Container@SKIRMISH_STATS: Height: 25 Background: checkbox-toggle TooltipContainer: TOOLTIP_CONTAINER - TooltipText: Kick this player Children: Image: ImageCollection: lobby-bits diff --git a/mods/common/chrome/ingame-infostats.yaml b/mods/common/chrome/ingame-infostats.yaml index eb911296b8..77d2c080b1 100644 --- a/mods/common/chrome/ingame-infostats.yaml +++ b/mods/common/chrome/ingame-infostats.yaml @@ -136,7 +136,6 @@ Container@SKIRMISH_STATS: Height: 25 Background: checkbox-toggle TooltipContainer: TOOLTIP_CONTAINER - TooltipText: Kick this player Children: Image: ImageCollection: lobby-bits @@ -179,7 +178,6 @@ Container@SKIRMISH_STATS: Height: 25 Background: checkbox-toggle TooltipContainer: TOOLTIP_CONTAINER - TooltipText: Kick this player Children: Image: ImageCollection: lobby-bits diff --git a/mods/common/languages/en.ftl b/mods/common/languages/en.ftl index ce3bca263c..1adcfe4503 100644 --- a/mods/common/languages/en.ftl +++ b/mods/common/languages/en.ftl @@ -33,7 +33,8 @@ notification-admin-change-configuration = Only the host can change the configura notification-changed-map = { $player } changed the map to { $map } notification-option-changed = { $player } changed { $name } to { $value }. notification-you-were-kicked = You have been kicked from the server. -notification-kicked = { $admin } kicked { $player } from the server. +notification-admin-kicked = { $admin } kicked { $player } from the server. +notification-kicked = { $player } was kicked from the server. notification-temp-ban = { $admin } temporarily banned { $player } from the server. notification-admin-transfer-admin = Only admins can transfer admin to another player. notification-admin-move-spectators = Only the host can move players to spectators. @@ -95,6 +96,14 @@ notification-chat-temp-disabled = *[other] Chat is disabled. Please try again in { $remaining } seconds. } +## VoteKickTracker +notification-unable-to-start-a-vote = Unable to start a vote. +notification-insufficient-votes-to-kick = Insufficient votes to kick player { $kickee }. +notification-kick-already-voted = You have already voted. +notification-vote-kick-started = Player { $kicker } has started a vote to kick player { $kickee }. +notification-vote-kick-in-progress = { $percentage }% of players have voted to kick player { $kickee }. +notification-vote-kick-ended = Vote to kick player { $kickee } has failed. + ## ActorEditLogic label-duplicate-actor-id = Duplicate Actor ID label-actor-id = Enter an Actor ID @@ -149,12 +158,29 @@ label-mission-failed = Failed label-client-state-disconnected = Gone label-mute-player = Mute this player label-unmute-player = Unmute this player +button-kick-player = Kick this player +button-vote-kick-player = Vote to kick this player dialog-kick = .title = Kick { $player }? - .prompt = They will not be able to rejoin this game. + .prompt = This player will not be able to rejoin the game. .confirm = Kick +dialog-vote-kick = + .title = Vote to kick { $player }? + .prompt = This player will not be able to rejoin the game. + .prompt-break-bots = + { $bots -> + [one] Kicking the game admin will also kick 1 bot. + *[other] Kicking the game admin will also kick { $bots } bots. + } + .vote-start = Start Vote + .vote-for = Vote For + .vote-against = Vote Against + .vote-cancel = Abstain + +notification-vote-kick-disabled = Vote kick is disabled on this server. + ## GameTimerLogic label-paused = Paused label-max-speed = Max Speed