diff --git a/OpenRA.Game/Network/LocalizedMessage.cs b/OpenRA.Game/Network/LocalizedMessage.cs new file mode 100644 index 0000000000..7cfffc8bb9 --- /dev/null +++ b/OpenRA.Game/Network/LocalizedMessage.cs @@ -0,0 +1,136 @@ +#region Copyright & License Information +/* + * Copyright 2007-2022 The OpenRA Developers (see AUTHORS) + * 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; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Fluent.Net; +using OpenRA.Widgets; + +namespace OpenRA.Network +{ + public class FluentArgument + { + [Flags] + public enum FluentArgumentType + { + String = 0, + Number = 1, + } + + public readonly string Key; + public readonly string Value; + public readonly FluentArgumentType Type; + + public FluentArgument() { } + + public FluentArgument(string key, object value) + { + Key = key; + Value = value.ToString(); + Type = GetFluentArgumentType(value); + } + + static FluentArgumentType GetFluentArgumentType(object value) + { + switch (value) + { + case byte _: + case sbyte _: + case short _: + case uint _: + case int _: + case long _: + case ulong _: + case float _: + case double _: + case decimal _: + return FluentArgumentType.Number; + default: + return FluentArgumentType.String; + } + } + } + + public class LocalizedMessage + { + public const int ProtocolVersion = 1; + + public readonly string Key; + + [FieldLoader.LoadUsing(nameof(LoadArguments))] + public readonly FluentArgument[] Arguments; + + static object LoadArguments(MiniYaml yaml) + { + var arguments = new List(); + var argumentsNode = yaml.Nodes.FirstOrDefault(n => n.Key == "Arguments"); + if (argumentsNode != null) + { + var regex = new Regex(@"Argument@\d+"); + foreach (var argument in argumentsNode.Value.Nodes) + if (regex.IsMatch(argument.Key)) + arguments.Add(FieldLoader.Load(argument.Value)); + } + + return arguments.ToArray(); + } + + static readonly string[] SerializeFields = { "Key" }; + + public LocalizedMessage(MiniYaml yaml) + { + FieldLoader.Load(this, yaml); + } + + public LocalizedMessage(string key, Dictionary arguments = null) + { + Key = key; + Arguments = arguments?.Select(a => new FluentArgument(a.Key, a.Value)).ToArray(); + } + + public string Serialize() + { + var root = new List() { new MiniYamlNode("Protocol", ProtocolVersion.ToString()) }; + foreach (var field in SerializeFields) + root.Add(FieldSaver.SaveField(this, field)); + + if (Arguments != null) + { + var argumentsNode = new MiniYaml(""); + var i = 0; + foreach (var argument in Arguments) + argumentsNode.Nodes.Add(new MiniYamlNode("Argument@" + i++, FieldSaver.Save(argument))); + + root.Add(new MiniYamlNode("Arguments", argumentsNode)); + } + + return new MiniYaml("", root) + .ToLines("LocalizedMessage") + .JoinWith("\n"); + } + + public string Translate() + { + var argumentDictionary = new Dictionary(); + foreach (var argument in Arguments) + { + if (argument.Type == FluentArgument.FluentArgumentType.Number) + argumentDictionary.Add(argument.Key, new FluentNumber(argument.Value)); + else + argumentDictionary.Add(argument.Key, new FluentString(argument.Value)); + } + + return Ui.Translate(Key, argumentDictionary); + } + } +} diff --git a/OpenRA.Game/Network/UnitOrders.cs b/OpenRA.Game/Network/UnitOrders.cs index c647891a19..93bf23730c 100644 --- a/OpenRA.Game/Network/UnitOrders.cs +++ b/OpenRA.Game/Network/UnitOrders.cs @@ -34,6 +34,22 @@ namespace OpenRA.Network TextNotificationsManager.AddSystemLine(order.TargetString); break; + // Client side translated server message + case "LocalizedMessage": + { + if (string.IsNullOrEmpty(order.TargetString)) + break; + + var yaml = MiniYaml.FromString(order.TargetString); + foreach (var node in yaml) + { + var localizedMessage = new LocalizedMessage(node.Value); + TextNotificationsManager.AddSystemLine(localizedMessage.Translate()); + } + + break; + } + case "DisableChatEntry": { // Order must originate from the server diff --git a/OpenRA.Game/Server/Connection.cs b/OpenRA.Game/Server/Connection.cs index 2c0bdb95cc..5478c082f8 100644 --- a/OpenRA.Game/Server/Connection.cs +++ b/OpenRA.Game/Server/Connection.cs @@ -171,7 +171,7 @@ namespace OpenRA.Server var sent = socket.Send(data, start, length - start, SocketFlags.None, out var error); if (error == SocketError.WouldBlock) { - Log.Write("server", "Non-blocking send of {0} bytes failed. Falling back to blocking send.", length - start); + Log.Write("server", $"Non-blocking send of {length - start} bytes failed. Falling back to blocking send."); socket.Blocking = true; sent = socket.Send(data, start, length - start, SocketFlags.None); socket.Blocking = false; diff --git a/OpenRA.Game/Server/ProtocolVersion.cs b/OpenRA.Game/Server/ProtocolVersion.cs index 4a5ffd8a1d..c03fceb379 100644 --- a/OpenRA.Game/Server/ProtocolVersion.cs +++ b/OpenRA.Game/Server/ProtocolVersion.cs @@ -77,6 +77,6 @@ namespace OpenRA.Server // The protocol for server and world orders // This applies after the handshake has completed, and is provided to support // alternative server implementations that wish to support multiple versions in parallel - public const int Orders = 18; + public const int Orders = 19; } } diff --git a/OpenRA.Game/Server/Server.cs b/OpenRA.Game/Server/Server.cs index 9ff4a90408..8109e3afff 100644 --- a/OpenRA.Game/Server/Server.cs +++ b/OpenRA.Game/Server/Server.cs @@ -21,11 +21,13 @@ using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; +using OpenRA; using OpenRA.FileFormats; using OpenRA.Network; using OpenRA.Primitives; using OpenRA.Support; using OpenRA.Traits; +using OpenRA.Widgets; namespace OpenRA.Server { @@ -45,8 +47,6 @@ namespace OpenRA.Server public sealed class Server { - public readonly string TwoHumansRequiredText = "This server requires at least two human players to start a match."; - public readonly MersenneTwister Random = new MersenneTwister(); public readonly ServerType Type; @@ -79,6 +79,72 @@ namespace OpenRA.Server readonly List worldPlayers = new List(); readonly Stopwatch pingUpdated = Stopwatch.StartNew(); + [TranslationReference] + static readonly string CustomRules = "custom-rules"; + + [TranslationReference] + static readonly string BotsDisabled = "bots-disabled"; + + [TranslationReference] + static readonly string TwoHumansRequired = "two-humans-required"; + + [TranslationReference] + static readonly string ErrorGameStarted = "error-game-started"; + + [TranslationReference] + static readonly string RequiresPassword = "requires-password"; + + [TranslationReference] + static readonly string IncorrectPassword = "incorrect-password"; + + [TranslationReference] + static readonly string IncompatibleMod = "incompatible-mod"; + + [TranslationReference] + static readonly string IncompatibleVersion = "incompatible-version"; + + [TranslationReference] + static readonly string IncompatibleProtocol = "incompatible-protocol"; + + [TranslationReference] + static readonly string Banned = "banned"; + + [TranslationReference] + static readonly string TempBanned = "temp-banned"; + + [TranslationReference] + static readonly string Full = "full"; + + [TranslationReference("player")] + static readonly string Joined = "joined"; + + [TranslationReference] + static readonly string RequiresForumAccount = "requires-forum-account"; + + [TranslationReference] + static readonly string NoPermission = "no-permission"; + + [TranslationReference("command")] + static readonly string UnknownServerCommand = "unknown-server-command"; + + [TranslationReference("remaining")] + static readonly string ChatDisabled = "chat-disabled"; + + [TranslationReference("player")] + static readonly string LobbyDisconnected = "lobby-disconnected"; + + [TranslationReference("player", "team")] + static readonly string PlayerDisconnected = "player-disconnected"; + + [TranslationReference("player")] + static readonly string ObserverDisconnected = "observer-disconnected"; + + [TranslationReference("player")] + public static readonly string NewAdmin = "new-admin"; + + [TranslationReference] + static readonly string YouWereKicked = "you-were-kicked"; + public ServerState State { get => internalState; @@ -177,7 +243,7 @@ namespace OpenRA.Server catch (Exception ex) { if (ex is SocketException || ex is ArgumentException) - Log.Write("server", "Failed to set socket option on {0}: {1}", endpoint.ToString(), ex.Message); + Log.Write("server", $"Failed to set socket option on {endpoint}: {ex.Message}"); else throw; } @@ -214,7 +280,7 @@ namespace OpenRA.Server catch (SocketException ex) { lastException = ex; - Log.Write("server", "Failed to listen on {0}: {1}", endpoint.ToString(), ex.Message); + Log.Write("server", $"Failed to listen on {endpoint}: {ex.Message}"); } } @@ -277,8 +343,8 @@ namespace OpenRA.Server foreach (var t in serverTraits.WithInterface()) t.ServerStarted(this); - Log.Write("server", "Initial mod: {0}", ModData.Manifest.Id); - Log.Write("server", "Initial map: {0}", LobbyInfo.GlobalSettings.Map); + Log.Write("server", $"Initial mod: {ModData.Manifest.Id}"); + Log.Write("server", $"Initial map: {LobbyInfo.GlobalSettings.Map}"); while (true) { @@ -380,9 +446,9 @@ namespace OpenRA.Server { if (State == ServerState.GameStarted) { - Log.Write("server", "Rejected connection from {0}; game is already started.", newConn.EndPoint); + Log.Write("server", $"Rejected connection from {newConn.EndPoint}; game is already started."); - SendOrderTo(newConn, "ServerError", "The game has already started"); + SendOrderTo(newConn, "ServerError", ErrorGameStarted); DropClient(newConn); return; } @@ -391,7 +457,7 @@ namespace OpenRA.Server if (!string.IsNullOrEmpty(Settings.Password) && handshake.Password != Settings.Password) { - var message = string.IsNullOrEmpty(handshake.Password) ? "Server requires a password" : "Incorrect password"; + var message = string.IsNullOrEmpty(handshake.Password) ? RequiresPassword : IncorrectPassword; SendOrderTo(newConn, "AuthenticationError", message); DropClient(newConn); return; @@ -416,29 +482,27 @@ namespace OpenRA.Server if (ModData.Manifest.Id != handshake.Mod) { - Log.Write("server", "Rejected connection from {0}; mods do not match.", - newConn.EndPoint); + Log.Write("server", $"Rejected connection from {newConn.EndPoint}; mods do not match."); - SendOrderTo(newConn, "ServerError", "Server is running an incompatible mod"); + SendOrderTo(newConn, "ServerError", IncompatibleMod); DropClient(newConn); return; } if (ModData.Manifest.Metadata.Version != handshake.Version) { - Log.Write("server", "Rejected connection from {0}; Not running the same version.", newConn.EndPoint); + Log.Write("server", $"Rejected connection from {newConn.EndPoint}; Not running the same version."); - SendOrderTo(newConn, "ServerError", "Server is running an incompatible version"); + SendOrderTo(newConn, "ServerError", IncompatibleVersion); DropClient(newConn); return; } if (handshake.OrdersProtocol != ProtocolVersion.Orders) { - Log.Write("server", "Rejected connection from {0}; incompatible Orders protocol version {1}.", - newConn.EndPoint, handshake.OrdersProtocol); + Log.Write("server", $"Rejected connection from {newConn.EndPoint}; incompatible Orders protocol version {handshake.OrdersProtocol}."); - SendOrderTo(newConn, "ServerError", "Server is running an incompatible protocol"); + SendOrderTo(newConn, "ServerError", IncompatibleProtocol); DropClient(newConn); return; } @@ -447,8 +511,9 @@ namespace OpenRA.Server var bans = Settings.Ban.Union(TempBans); if (bans.Contains(client.IPAddress)) { - Log.Write("server", "Rejected connection from {0}; Banned.", newConn.EndPoint); - SendOrderTo(newConn, "ServerError", $"You have been {(Settings.Ban.Contains(client.IPAddress) ? "banned" : "temporarily banned")} from the server"); + Log.Write("server", $"Rejected connection from {newConn.EndPoint}; Banned."); + var message = Settings.Ban.Contains(client.IPAddress) ? Banned : TempBanned; + SendOrderTo(newConn, "ServerError", message); DropClient(newConn); return; } @@ -458,11 +523,11 @@ namespace OpenRA.Server lock (LobbyInfo) { client.Slot = LobbyInfo.FirstEmptySlot(); - client.IsAdmin = !LobbyInfo.Clients.Any(c1 => c1.IsAdmin); + client.IsAdmin = !LobbyInfo.Clients.Any(c => c.IsAdmin); if (client.IsObserver && !LobbyInfo.GlobalSettings.AllowSpectators) { - SendOrderTo(newConn, "ServerError", "The game is full"); + SendOrderTo(newConn, "ServerError", Full); DropClient(newConn); return; } @@ -480,20 +545,20 @@ namespace OpenRA.Server if (!client.IsAdmin && Settings.JoinChatDelay > 0) DispatchOrdersToClient(newConn, 0, 0, new Order("DisableChatEntry", null, false) { ExtraData = (uint)Settings.JoinChatDelay }.Serialize()); - Log.Write("server", "Client {0}: Accepted connection from {1}.", newConn.PlayerIndex, newConn.EndPoint); + Log.Write("server", $"Client {newConn.PlayerIndex}: Accepted connection from {newConn.EndPoint}."); if (client.Fingerprint != null) - Log.Write("server", "Client {0}: Player fingerprint is {1}.", newConn.PlayerIndex, client.Fingerprint); + Log.Write("server", $"Client {newConn.PlayerIndex}: Player fingerprint is {client.Fingerprint}."); foreach (var t in serverTraits.WithInterface()) t.ClientJoined(this, newConn); SyncLobbyInfo(); - Log.Write("server", "{0} ({1}) has joined the game.", client.Name, newConn.EndPoint); + Log.Write("server", $"{client.Name} ({newConn.EndPoint}) has joined the game."); if (Type != ServerType.Local) - SendMessage($"{client.Name} has joined the game."); + SendLocalizedMessage(Joined, Translation.Arguments("player", client.Name)); if (Type == ServerType.Dedicated) { @@ -507,12 +572,12 @@ namespace OpenRA.Server } if ((LobbyInfo.GlobalSettings.MapStatus & Session.MapStatus.UnsafeCustomRules) != 0) - SendOrderTo(newConn, "Message", "This map contains custom rules. Game experience may change."); + SendLocalizedMessageTo(newConn, CustomRules); if (!LobbyInfo.GlobalSettings.EnableSingleplayer) - SendOrderTo(newConn, "Message", TwoHumansRequiredText); + SendLocalizedMessageTo(newConn, TwoHumansRequired); else if (Map.Players.Players.Where(p => p.Value.Playable).All(p => !p.Value.AllowBots)) - SendOrderTo(newConn, "Message", "Bots have been disabled on this map."); + SendLocalizedMessageTo(newConn, BotsDisabled); } }; @@ -543,29 +608,25 @@ namespace OpenRA.Server if (!profile.KeyRevoked && CryptoUtil.VerifySignature(parameters, newConn.AuthToken, handshake.AuthSignature)) { client.Fingerprint = handshake.Fingerprint; - Log.Write("server", "{0} authenticated as {1} (UID {2})", newConn.EndPoint, - profile.ProfileName, profile.ProfileID); + Log.Write("server", $"{newConn.EndPoint} authenticated as {profile.ProfileName} (UID {profile.ProfileID})"); } else if (profile.KeyRevoked) { profile = null; - Log.Write("server", "{0} failed to authenticate as {1} (key revoked)", newConn.EndPoint, handshake.Fingerprint); + Log.Write("server", $"{newConn.EndPoint} failed to authenticate as {handshake.Fingerprint} (key revoked)"); } else { profile = null; - Log.Write("server", "{0} failed to authenticate as {1} (signature verification failed)", - newConn.EndPoint, handshake.Fingerprint); + Log.Write("server", $"{newConn.EndPoint} failed to authenticate as {handshake.Fingerprint} (signature verification failed)"); } } else - Log.Write("server", "{0} failed to authenticate as {1} (invalid server response: `{2}` is not `Player`)", - newConn.EndPoint, handshake.Fingerprint, yaml.Key); + Log.Write("server", $"{newConn.EndPoint} failed to authenticate as {handshake.Fingerprint} (invalid server response: `{yaml.Key}` is not `Player`)"); } catch (Exception ex) { - Log.Write("server", "{0} failed to authenticate as {1} (exception occurred)", - newConn.EndPoint, handshake.Fingerprint); + Log.Write("server", $"{newConn.EndPoint} failed to authenticate as {handshake.Fingerprint} (exception occurred)"); Log.Write("server", ex.ToString()); } @@ -578,18 +639,18 @@ namespace OpenRA.Server if (notAuthenticated) { - Log.Write("server", "Rejected connection from {0}; Not authenticated.", newConn.EndPoint); - SendOrderTo(newConn, "ServerError", "Server requires players to have an OpenRA forum account"); + Log.Write("server", $"Rejected connection from {newConn.EndPoint}; Not authenticated."); + SendOrderTo(newConn, "ServerError", RequiresForumAccount); DropClient(newConn); } else if (blacklisted || notWhitelisted) { if (blacklisted) - Log.Write("server", "Rejected connection from {0}; In server blacklist.", newConn.EndPoint); + Log.Write("server", $"Rejected connection from {newConn.EndPoint}; In server blacklist."); else - Log.Write("server", "Rejected connection from {0}; Not in server whitelist.", newConn.EndPoint); + Log.Write("server", $"Rejected connection from {newConn.EndPoint}; Not in server whitelist."); - SendOrderTo(newConn, "ServerError", "You do not have permission to join this server"); + SendOrderTo(newConn, "ServerError", NoPermission); DropClient(newConn); } else @@ -601,8 +662,8 @@ namespace OpenRA.Server { if (Type == ServerType.Dedicated && (Settings.RequireAuthentication || Settings.ProfileIDWhitelist.Any())) { - Log.Write("server", "Rejected connection from {0}; Not authenticated.", newConn.EndPoint); - SendOrderTo(newConn, "ServerError", "Server requires players to have an OpenRA forum account"); + Log.Write("server", $"Rejected connection from {newConn.EndPoint}; Not authenticated."); + SendOrderTo(newConn, "ServerError", RequiresForumAccount); DropClient(newConn); } else @@ -611,7 +672,7 @@ namespace OpenRA.Server } catch (Exception ex) { - Log.Write("server", "Dropping connection {0} because an error occurred:", newConn.EndPoint); + Log.Write("server", $"Dropping connection {newConn.EndPoint} because an error occurred:"); Log.Write("server", ex.ToString()); DropClient(newConn); } @@ -663,8 +724,7 @@ namespace OpenRA.Server catch (Exception e) { DropClient(c); - Log.Write("server", "Dropping client {0} because dispatching orders failed: {1}", - client.ToString(CultureInfo.InvariantCulture), e); + Log.Write("server", $"Dropping client {client.ToString(CultureInfo.InvariantCulture)} because dispatching orders failed: {e}"); } } @@ -707,7 +767,7 @@ namespace OpenRA.Server void OutOfSync(int frame) { - Log.Write("server", "Out of sync detected at frame {0}, cancel replay recording", frame); + Log.Write("server", $"Out of sync detected at frame {frame}, cancel replay recording"); // Make sure the written file is not valid // TODO: storing a serverside replay on desync would be extremely useful @@ -870,6 +930,21 @@ namespace OpenRA.Server Console.WriteLine($"[{DateTime.Now.ToString(Settings.TimestampFormat)}] {text}"); } + public void SendLocalizedMessage(string key, Dictionary arguments = null) + { + var text = new LocalizedMessage(key, arguments).Serialize(); + DispatchServerOrdersToClients(Order.FromTargetString("LocalizedMessage", text, true)); + + if (Type == ServerType.Dedicated) + Console.WriteLine($"[{DateTime.Now.ToString(Settings.TimestampFormat)}] {Ui.Translate(key, arguments)}"); + } + + public void SendLocalizedMessageTo(Connection conn, string key, Dictionary arguments = null) + { + var text = new LocalizedMessage(key, arguments).Serialize(); + DispatchOrdersToClient(conn, 0, 0, Order.FromTargetString("LocalizedMessage", text, true).Serialize()); + } + void InterpretServerOrder(Connection conn, Order o) { lock (LobbyInfo) @@ -882,9 +957,7 @@ namespace OpenRA.Server ValidateClient(conn, o.TargetString); else { - Log.Write("server", "Rejected connection from {0}; Order `{1}` is not a `HandshakeResponse`.", - conn.EndPoint, o.OrderString); - + Log.Write("server", $"Rejected connection from {conn.EndPoint}; Order `{o.OrderString}` is not a `HandshakeResponse`."); DropClient(conn); } @@ -900,8 +973,8 @@ namespace OpenRA.Server if (handledBy == null) { - Log.Write("server", "Unknown server command: {0}", o.TargetString); - SendOrderTo(conn, "Message", $"Unknown server command: {o.TargetString}"); + Log.Write("server", $"Unknown server command: {o.TargetString}"); + SendLocalizedMessageTo(conn, UnknownServerCommand, Translation.Arguments("command", o.TargetString)); } break; @@ -914,7 +987,7 @@ namespace OpenRA.Server if (!isAdmin && connected < Settings.JoinChatDelay) { var remaining = (Settings.JoinChatDelay - connected + 999) / 1000; - SendOrderTo(conn, "Message", "Chat is disabled. Please try again in {0} seconds".F(remaining)); + SendLocalizedMessageTo(conn, ChatDisabled, Translation.Arguments("remaining", remaining)); } else DispatchOrdersToClients(conn, 0, o.Serialize()); @@ -1083,10 +1156,15 @@ namespace OpenRA.Server return; } - var suffix = ""; if (State == ServerState.GameStarted) - suffix = dropClient.IsObserver ? " (Spectator)" : dropClient.Team != 0 ? $" (Team {dropClient.Team})" : ""; - SendMessage($"{dropClient.Name}{suffix} has disconnected."); + { + if (dropClient.IsObserver) + SendLocalizedMessage(ObserverDisconnected, Translation.Arguments("player", dropClient.Name)); + else + SendLocalizedMessage(PlayerDisconnected, Translation.Arguments("player", dropClient.Name, "team", dropClient.Team)); + } + else + SendLocalizedMessage(LobbyDisconnected, Translation.Arguments("player", dropClient.Name)); LobbyInfo.Clients.RemoveAll(c => c.Index == toDrop.PlayerIndex); @@ -1103,7 +1181,7 @@ namespace OpenRA.Server if (nextAdmin != null) { nextAdmin.IsAdmin = true; - SendMessage($"{nextAdmin.Name} is now the admin."); + SendLocalizedMessage(NewAdmin, Translation.Arguments("player", nextAdmin.Name)); } } @@ -1201,12 +1279,12 @@ namespace OpenRA.Server { lock (LobbyInfo) { - Console.WriteLine("[{0}] Game started", DateTime.Now.ToString(Settings.TimestampFormat)); + Console.WriteLine($"[{DateTime.Now.ToString(Settings.TimestampFormat)}] Game started"); // Drop any players who are not ready foreach (var c in Conns.Where(c => !c.Validated || GetClient(c).IsInvalid).ToArray()) { - SendOrderTo(c, "ServerError", "You have been kicked from the server!"); + SendOrderTo(c, "ServerError", YouWereKicked); DropClient(c); } diff --git a/OpenRA.Game/Traits/TraitsInterfaces.cs b/OpenRA.Game/Traits/TraitsInterfaces.cs index 2202642a6d..1c558b48d5 100644 --- a/OpenRA.Game/Traits/TraitsInterfaces.cs +++ b/OpenRA.Game/Traits/TraitsInterfaces.cs @@ -545,9 +545,9 @@ namespace OpenRA.Traits IsLocked = locked; } - public virtual string ValueChangedMessage(string playerName, string newValue) + public virtual string Label(string value) { - return playerName + " changed " + Name + " to " + Values[newValue] + "."; + return Values[value]; } } @@ -562,9 +562,9 @@ namespace OpenRA.Traits public LobbyBooleanOption(string id, string name, string description, bool visible, int displayorder, bool defaultValue, bool locked) : base(id, name, description, visible, displayorder, new ReadOnlyDictionary(BoolValues), defaultValue.ToString(), locked) { } - public override string ValueChangedMessage(string playerName, string newValue) + public override string Label(string newValue) { - return playerName + " " + BoolValues[newValue].ToLowerInvariant() + " " + Name + "."; + return BoolValues[newValue].ToLowerInvariant(); } } diff --git a/OpenRA.Game/Translation.cs b/OpenRA.Game/Translation.cs index a47abe2b34..d89bd437c9 100644 --- a/OpenRA.Game/Translation.cs +++ b/OpenRA.Game/Translation.cs @@ -19,6 +19,19 @@ using OpenRA.FileSystem; namespace OpenRA { + [AttributeUsage(AttributeTargets.Field)] + public sealed class TranslationReferenceAttribute : Attribute + { + public readonly string[] RequiredVariableNames; + + public TranslationReferenceAttribute() { } + + public TranslationReferenceAttribute(params string[] requiredVariableNames) + { + RequiredVariableNames = requiredVariableNames; + } + } + public class Translation { readonly IEnumerable messageContexts; @@ -71,6 +84,15 @@ namespace OpenRA return key; } + public bool HasAttribute(string key) + { + foreach (var messageContext in messageContexts) + if (messageContext.HasMessage(key)) + return true; + + return false; + } + public string GetAttribute(string key, string attribute) { if (key == null) diff --git a/OpenRA.Mods.Common/Lint/CheckTranslationReference.cs b/OpenRA.Mods.Common/Lint/CheckTranslationReference.cs new file mode 100644 index 0000000000..8a6e54b4fb --- /dev/null +++ b/OpenRA.Mods.Common/Lint/CheckTranslationReference.cs @@ -0,0 +1,121 @@ +#region Copyright & License Information +/* + * Copyright 2007-2021 The OpenRA Developers (see AUTHORS) + * 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; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Fluent.Net; +using Fluent.Net.RuntimeAst; + +namespace OpenRA.Mods.Common.Lint +{ + class CheckTranslationReference : ILintPass + { + const BindingFlags Binding = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; + + readonly List referencedKeys = new List(); + readonly Dictionary referencedVariablesPerKey = new Dictionary(); + readonly List variableReferences = new List(); + + public void Run(Action emitError, Action emitWarning, ModData modData) + { + var language = "en"; + var translation = new Translation(language, modData.Manifest.Translations, modData.DefaultFileSystem); + + foreach (var modType in modData.ObjectCreator.GetTypes()) + { + foreach (var fieldInfo in modType.GetFields(Binding).Where(m => m.HasAttribute())) + { + if (fieldInfo.FieldType != typeof(string)) + emitError($"Translation attribute on non string field {fieldInfo.Name}."); + + var key = (string)fieldInfo.GetValue(string.Empty); + if (!translation.HasAttribute(key)) + emitError($"{key} not present in {language} translation."); + + var translationReference = fieldInfo.GetCustomAttributes(true)[0]; + if (translationReference.RequiredVariableNames != null && translationReference.RequiredVariableNames.Length > 0) + referencedVariablesPerKey.GetOrAdd(key, translationReference.RequiredVariableNames); + + referencedKeys.Add(key); + } + } + + foreach (var file in modData.Manifest.Translations) + { + var stream = modData.DefaultFileSystem.Open(file); + using (var reader = new StreamReader(stream)) + { + var runtimeParser = new RuntimeParser(); + var result = runtimeParser.GetResource(reader); + + foreach (var entry in result.Entries) + { + if (!referencedKeys.Contains(entry.Key)) + emitWarning($"Unused key `{entry.Key}` in {file}."); + + var message = entry.Value; + var node = message.Value; + variableReferences.Clear(); + if (node is Pattern pattern) + { + foreach (var element in pattern.Elements) + { + if (element is SelectExpression selectExpression) + { + foreach (var variant in selectExpression.Variants) + { + if (variant.Value is Pattern variantPattern) + { + foreach (var variantElement in variantPattern.Elements) + CheckVariableReference(variantElement, entry, emitWarning, file); + } + } + } + + CheckVariableReference(element, entry, emitWarning, file); + } + + if (referencedVariablesPerKey.ContainsKey(entry.Key)) + { + var referencedVariables = referencedVariablesPerKey[entry.Key]; + foreach (var referencedVariable in referencedVariables) + { + if (!variableReferences.Contains(referencedVariable)) + emitError($"Missing variable `{referencedVariable}` for key `{entry.Key}` in {file}."); + } + } + } + } + } + } + } + + void CheckVariableReference(Node element, KeyValuePair entry, Action emitWarning, string file) + { + if (element is VariableReference variableReference) + { + variableReferences.Add(variableReference.Name); + + if (referencedVariablesPerKey.ContainsKey(entry.Key)) + { + var referencedVariables = referencedVariablesPerKey[entry.Key]; + if (!referencedVariables.Contains(variableReference.Name)) + emitWarning($"Unused variable `{variableReference.Name}` for key `{entry.Key}` in {file}."); + } + else + emitWarning($"Unused variable `{variableReference.Name}` for key `{entry.Key}` in {file}."); + } + } + } +} diff --git a/OpenRA.Mods.Common/Lint/CheckTranslationSyntax.cs b/OpenRA.Mods.Common/Lint/CheckTranslationSyntax.cs new file mode 100644 index 0000000000..0ecb01809e --- /dev/null +++ b/OpenRA.Mods.Common/Lint/CheckTranslationSyntax.cs @@ -0,0 +1,50 @@ +#region Copyright & License Information +/* + * Copyright 2007-2021 The OpenRA Developers (see AUTHORS) + * 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; +using System.Collections.Generic; +using System.IO; +using Fluent.Net; +using Fluent.Net.Ast; + +namespace OpenRA.Mods.Common.Lint +{ + class CheckTranslationSyntax : ILintPass + { + public void Run(Action emitError, Action emitWarning, ModData modData) + { + foreach (var file in modData.Manifest.Translations) + { + var stream = modData.DefaultFileSystem.Open(file); + using (var reader = new StreamReader(stream)) + { + var ids = new List(); + var parser = new Parser(); + var resource = parser.Parse(reader); + foreach (var entry in resource.Body) + { + if (entry is Junk junk) + foreach (var annotation in junk.Annotations) + emitError($"{annotation.Code}: {annotation.Message} in {file} line {annotation.Span.Start.Line}"); + + if (entry is MessageTermBase message) + { + if (ids.Contains(message.Id.Name)) + emitWarning($"Duplicate ID `{message.Id.Name}` in {file} line {message.Span.Start.Line}"); + + ids.Add(message.Id.Name); + } + } + } + } + } + } +} diff --git a/OpenRA.Mods.Common/ServerTraits/LobbyCommands.cs b/OpenRA.Mods.Common/ServerTraits/LobbyCommands.cs index 93b39fd1ed..e23e7acb54 100644 --- a/OpenRA.Mods.Common/ServerTraits/LobbyCommands.cs +++ b/OpenRA.Mods.Common/ServerTraits/LobbyCommands.cs @@ -24,6 +24,132 @@ namespace OpenRA.Mods.Common.Server { public class LobbyCommands : ServerTrait, IInterpretCommand, INotifyServerStart, INotifyServerEmpty, IClientJoined { + [TranslationReference] + static readonly string CustomRules = "custom-rules"; + + [TranslationReference] + static readonly string OnlyHostStartGame = "only-only-host-start-game"; + + [TranslationReference] + static readonly string NoStartUntilRequiredSlotsFull = "no-start-until-required-slots-full"; + + [TranslationReference] + static readonly string TwoHumansRequired = "two-humans-required"; + + [TranslationReference] + static readonly string InsufficientEnabledSpawnPoints = "insufficient-enabled-spawnPoints"; + + [TranslationReference("command")] + static readonly string MalformedCommand = "malformed-command"; + + [TranslationReference] + static readonly string KickNone = "kick-none"; + + [TranslationReference] + static readonly string NoKickGameStarted = "no-kick-game-started"; + + [TranslationReference("admin", "player")] + static readonly string Kicked = "kicked"; + + [TranslationReference("admin", "player")] + static readonly string TempBan = "temp-ban"; + + [TranslationReference] + static readonly string NoTransferAdmin = "only-host-transfer-admin"; + + [TranslationReference] + static readonly string EmptySlot = "empty-slot"; + + [TranslationReference("admin", "player")] + static readonly string MoveSpectators = "move-spectators"; + + [TranslationReference("player", "name")] + static readonly string Nick = "nick"; + + [TranslationReference] + static readonly string StateUnchangedReady = "state-unchanged-ready"; + + [TranslationReference("command")] + static readonly string StateUnchangedGameStarted = "state-unchanged-game-started"; + + [TranslationReference("faction")] + static readonly string InvalidFactionSelected = "invalid-faction-selected"; + + [TranslationReference("factions")] + static readonly string SupportedFactions = "supported-factions"; + + [TranslationReference] + static readonly string RequiresHost = "requires-host"; + + [TranslationReference] + static readonly string InvalidBotSlot = "invalid-bot-slot"; + + [TranslationReference] + static readonly string InvalidBotType = "invalid-bot-type"; + + [TranslationReference] + static readonly string HostChangeMap = "only-host-change-map"; + + [TranslationReference] + static readonly string UnknownMap = "unknown-map"; + + [TranslationReference] + static readonly string SearchingMap = "searching-map"; + + [TranslationReference] + static readonly string NotAdmin = "only-host-change-configuration"; + + [TranslationReference] + static readonly string InvalidConfigurationCommand = "invalid-configuration-command"; + + [TranslationReference("option")] + static readonly string OptionLocked = "option-locked"; + + [TranslationReference("player", "map")] + static readonly string ChangedMap = "changed-map"; + + [TranslationReference] + static readonly string BotsDisabled = "bots-disabled"; + + [TranslationReference("player", "name", "value")] + static readonly string ValueChanged = "value-changed"; + + [TranslationReference] + static readonly string NoMoveSpectators = "only-host-move-spectators"; + + [TranslationReference] + static readonly string AdminOption = "admin-option"; + + [TranslationReference("raw")] + static readonly string NumberTeams = "number-teams"; + + [TranslationReference] + static readonly string AdminClearSpawn = "admin-clear-spawn"; + + [TranslationReference] + static readonly string SpawnOccupied = "spawn-occupied"; + + [TranslationReference] + static readonly string SpawnLocked = "spawn-locked"; + + [TranslationReference] + static readonly string AdminLobbyInfo = "admin-lobby-info"; + + [TranslationReference] + static readonly string InvalidLobbyInfo = "invalid-lobby-info"; + + [TranslationReference] + static readonly string AdminKick = "admin-kick"; + + [TranslationReference] + static readonly string SlotClosed = "slot-closed"; + + [TranslationReference("player")] + public static readonly string NewAdmin = "new-admin"; + + [TranslationReference] + static readonly string YouWereKicked = "you-were-kicked"; + readonly IDictionary> commandHandlers = new Dictionary> { { "state", State }, @@ -56,13 +182,13 @@ namespace OpenRA.Mods.Common.Server { if (!server.LobbyInfo.Slots.ContainsKey(arg)) { - Log.Write("server", "Invalid slot: {0}", arg); + Log.Write("server", $"Invalid slot: {arg}"); return false; } if (requiresHost && !client.IsAdmin) { - server.SendOrderTo(conn, "Message", "Only the host can do that."); + server.SendLocalizedMessageTo(conn, RequiresHost); return false; } @@ -70,22 +196,22 @@ namespace OpenRA.Mods.Common.Server } } - public static bool ValidateCommand(S server, Connection conn, Session.Client client, string cmd) + public static bool ValidateCommand(S server, Connection conn, Session.Client client, string command) { lock (server.LobbyInfo) { // Kick command is always valid for the host - if (cmd.StartsWith("kick ")) + if (command.StartsWith("kick ")) return true; if (server.State == ServerState.GameStarted) { - server.SendOrderTo(conn, "Message", $"Cannot change state when game started. ({cmd})"); + server.SendLocalizedMessageTo(conn, StateUnchangedGameStarted, Translation.Arguments("command", command)); return false; } - else if (client.State == Session.ClientState.Ready && !(cmd.StartsWith("state") || cmd == "startgame")) + else if (client.State == Session.ClientState.Ready && !(command.StartsWith("state") || command == "startgame")) { - server.SendOrderTo(conn, "Message", "Cannot change state when marked as ready."); + server.SendLocalizedMessageTo(conn, StateUnchangedReady); return false; } @@ -139,12 +265,13 @@ namespace OpenRA.Mods.Common.Server { if (!Enum.TryParse(s, false, out var state)) { - server.SendOrderTo(conn, "Message", "Malformed state command"); + server.SendLocalizedMessageTo(conn, MalformedCommand, Translation.Arguments("command", "state")); + return true; } client.State = state; - Log.Write("server", "Player @{0} is {1}", conn.EndPoint, client.State); + Log.Write("server", $"Player @{conn.EndPoint} is {client.State}"); server.SyncLobbyClients(); CheckAutoStart(server); @@ -159,26 +286,26 @@ namespace OpenRA.Mods.Common.Server { if (!client.IsAdmin) { - server.SendOrderTo(conn, "Message", "Only the host can start the game."); + server.SendOrderTo(conn, "Message", OnlyHostStartGame); return true; } if (server.LobbyInfo.Slots.Any(sl => sl.Value.Required && server.LobbyInfo.ClientInSlot(sl.Key) == null)) { - server.SendOrderTo(conn, "Message", "Unable to start the game until required slots are full."); + server.SendOrderTo(conn, "Message", NoStartUntilRequiredSlotsFull); return true; } if (!server.LobbyInfo.GlobalSettings.EnableSingleplayer && server.LobbyInfo.NonBotPlayers.Count() < 2) { - server.SendOrderTo(conn, "Message", server.TwoHumansRequiredText); + server.SendOrderTo(conn, "Message", TwoHumansRequired); return true; } if (LobbyUtils.InsufficientEnabledSpawnPoints(server.Map, server.LobbyInfo)) { - server.SendOrderTo(conn, "Message", "Unable to start the game until more spawn points are enabled."); + server.SendOrderTo(conn, "Message", InsufficientEnabledSpawnPoints); return true; } @@ -194,7 +321,7 @@ namespace OpenRA.Mods.Common.Server { if (!server.LobbyInfo.Slots.ContainsKey(s)) { - Log.Write("server", "Invalid slot: {0}", s); + Log.Write("server", $"Invalid slot: {s}"); return false; } @@ -231,7 +358,7 @@ namespace OpenRA.Mods.Common.Server return true; } - server.SendOrderTo(conn, "Message", "Malformed allow_spectate command"); + server.SendLocalizedMessageTo(conn, MalformedCommand, Translation.Arguments("command", "allow_spectate")); return true; } @@ -278,7 +405,7 @@ namespace OpenRA.Mods.Common.Server var occupantConn = server.Conns.FirstOrDefault(c => c.PlayerIndex == occupant.Index); if (occupantConn != null) { - server.SendOrderTo(occupantConn, "ServerError", "Your slot was closed by the host."); + server.SendOrderTo(conn, "ServerError", SlotClosed); server.DropClient(occupantConn); } } @@ -320,7 +447,7 @@ namespace OpenRA.Mods.Common.Server var parts = s.Split(' '); if (parts.Length < 3) { - server.SendOrderTo(conn, "Message", "Malformed slot_bot command"); + server.SendLocalizedMessageTo(conn, MalformedCommand, Translation.Arguments("command", "slot_bot")); return true; } @@ -331,14 +458,14 @@ namespace OpenRA.Mods.Common.Server var bot = server.LobbyInfo.ClientInSlot(parts[0]); if (!Exts.TryParseIntegerInvariant(parts[1], out var controllerClientIndex)) { - Log.Write("server", "Invalid bot controller client index: {0}", parts[1]); + Log.Write("server", $"Invalid bot controller client index: {parts[1]}"); return false; } // Invalid slot if (bot != null && bot.Bot == null) { - server.SendOrderTo(conn, "Message", "Can't add bots to a slot with another client."); + server.SendLocalizedMessageTo(conn, InvalidBotSlot); return true; } @@ -348,7 +475,7 @@ namespace OpenRA.Mods.Common.Server if (botInfo == null) { - server.SendOrderTo(conn, "Message", "Invalid bot type."); + server.SendLocalizedMessageTo(conn, InvalidBotType); return true; } @@ -400,7 +527,7 @@ namespace OpenRA.Mods.Common.Server { if (!client.IsAdmin) { - server.SendOrderTo(conn, "Message", "Only the host can change the map."); + server.SendLocalizedMessageTo(conn, HostChangeMap); return true; } @@ -477,15 +604,15 @@ namespace OpenRA.Mods.Common.Server server.SyncLobbyInfo(); - server.SendMessage($"{client.Name} changed the map to {server.Map.Title}."); + server.SendLocalizedMessage(ChangedMap, Translation.Arguments("player", client.Name, "map", server.Map.Title)); if ((server.LobbyInfo.GlobalSettings.MapStatus & Session.MapStatus.UnsafeCustomRules) != 0) - server.SendMessage("This map contains custom rules. Game experience may change."); + server.SendLocalizedMessage(CustomRules); if (!server.LobbyInfo.GlobalSettings.EnableSingleplayer) - server.SendMessage(server.TwoHumansRequiredText); + server.SendLocalizedMessage(TwoHumansRequired); else if (server.Map.Players.Players.Where(p => p.Value.Playable).All(p => !p.Value.AllowBots)) - server.SendMessage("Bots have been disabled on this map."); + server.SendLocalizedMessage(BotsDisabled); var briefing = MissionBriefingOrDefault(server); if (briefing != null) @@ -493,14 +620,14 @@ namespace OpenRA.Mods.Common.Server } }; - Action queryFailed = () => server.SendOrderTo(conn, "Message", "Map was not found on server."); + Action queryFailed = () => server.SendLocalizedMessageTo(conn, UnknownMap); var m = server.ModData.MapCache[s]; if (m.Status == MapStatus.Available || m.Status == MapStatus.DownloadAvailable) selectMap(m); else if (server.Settings.QueryMapRepository) { - server.SendOrderTo(conn, "Message", "Searching for map on the Resource Center..."); + server.SendLocalizedMessageTo(conn, SearchingMap); var mapRepository = server.ModData.Manifest.Get().MapRepository; var reported = false; server.ModData.MapCache.QueryRemoteMapDetails(mapRepository, new[] { s }, selectMap, _ => @@ -524,7 +651,7 @@ namespace OpenRA.Mods.Common.Server { if (!client.IsAdmin) { - server.SendOrderTo(conn, "Message", "Only the host can change the configuration."); + server.SendLocalizedMessageTo(conn, NotAdmin); return true; } @@ -541,13 +668,13 @@ namespace OpenRA.Mods.Common.Server if (split.Length < 2 || !options.TryGetValue(split[0], out var option) || !option.Values.ContainsKey(split[1])) { - server.SendOrderTo(conn, "Message", "Invalid configuration command."); + server.SendLocalizedMessageTo(conn, InvalidConfigurationCommand); return true; } if (option.IsLocked) { - server.SendOrderTo(conn, "Message", $"{option.Name} cannot be changed."); + server.SendLocalizedMessageTo(conn, OptionLocked, Translation.Arguments("option", option.Name)); return true; } @@ -558,7 +685,7 @@ namespace OpenRA.Mods.Common.Server oo.Value = oo.PreferredValue = split[1]; server.SyncLobbyGlobalSettings(); - server.SendMessage(option.ValueChangedMessage(client.Name, split[1])); + server.SendLocalizedMessage(ValueChanged, Translation.Arguments("player", client.Name, "name", option.Name, "value", option.Label(split[1]))); foreach (var c in server.LobbyInfo.Clients) c.State = Session.ClientState.NotReady; @@ -569,19 +696,19 @@ namespace OpenRA.Mods.Common.Server } } - static bool AssignTeams(S server, Connection conn, Session.Client client, string s) + static bool AssignTeams(S server, Connection conn, Session.Client client, string raw) { lock (server.LobbyInfo) { if (!client.IsAdmin) { - server.SendOrderTo(conn, "Message", "Only the host can set that option."); + server.SendLocalizedMessageTo(conn, AdminOption); return true; } - if (!Exts.TryParseIntegerInvariant(s, out var teamCount)) + if (!Exts.TryParseIntegerInvariant(raw, out var teamCount)) { - server.SendOrderTo(conn, "Message", $"Number of teams could not be parsed: {s}"); + server.SendLocalizedMessageTo(conn, NumberTeams, Translation.Arguments("raw", raw)); return true; } @@ -618,14 +745,14 @@ namespace OpenRA.Mods.Common.Server { if (!client.IsAdmin) { - server.SendOrderTo(conn, "Message", "Only the host can kick players."); + server.SendLocalizedMessageTo(conn, AdminKick); return true; } var split = s.Split(' '); if (split.Length < 2) { - server.SendOrderTo(conn, "Message", "Malformed kick command"); + server.SendLocalizedMessageTo(conn, MalformedCommand, Translation.Arguments("command", "kick")); return true; } @@ -634,26 +761,26 @@ namespace OpenRA.Mods.Common.Server var kickConn = server.Conns.SingleOrDefault(c => server.GetClient(c)?.Index == kickClientID); if (kickConn == null) { - server.SendOrderTo(conn, "Message", "No-one in that slot."); + server.SendLocalizedMessageTo(conn, KickNone); return true; } var kickClient = server.GetClient(kickConn); if (server.State == ServerState.GameStarted && !kickClient.IsObserver) { - server.SendOrderTo(conn, "Message", "Only spectators can be kicked after the game has started."); + server.SendLocalizedMessageTo(conn, NoKickGameStarted); return true; } - Log.Write("server", "Kicking client {0}.", kickClientID); - server.SendMessage($"{client.Name} kicked {kickClient.Name} from the server."); - server.SendOrderTo(kickConn, "ServerError", "You have been kicked from the server."); + Log.Write("server", $"Kicking client {kickClientID}."); + server.SendLocalizedMessage(Kicked, Translation.Arguments("admin", client.Name, "client", kickClient.Name)); + server.SendOrderTo(kickConn, "ServerError", YouWereKicked); server.DropClient(kickConn); if (bool.TryParse(split[1], out var tempBan) && tempBan) { - Log.Write("server", "Temporarily banning client {0} ({1}).", kickClientID, kickClient.IPAddress); - server.SendMessage($"{client.Name} temporarily banned {kickClient.Name} from the server."); + Log.Write("server", $"Temporarily banning client {kickClientID} ({kickClient.IPAddress})."); + server.SendLocalizedMessage(TempBan, Translation.Arguments("admin", client.Name, "client", kickClient.Name)); server.TempBans.Add(kickClient.IPAddress); } @@ -670,7 +797,7 @@ namespace OpenRA.Mods.Common.Server { if (!client.IsAdmin) { - server.SendOrderTo(conn, "Message", "Only admins can transfer admin to another player."); + server.SendLocalizedMessageTo(conn, NoTransferAdmin); return true; } @@ -679,7 +806,7 @@ namespace OpenRA.Mods.Common.Server if (newAdminConn == null) { - server.SendOrderTo(conn, "Message", "No-one in that slot."); + server.SendLocalizedMessageTo(conn, EmptySlot); return true; } @@ -693,7 +820,7 @@ namespace OpenRA.Mods.Common.Server foreach (var b in bots) b.BotControllerClientIndex = newAdminId; - server.SendMessage($"{newAdminClient.Name} is now the admin."); + server.SendLocalizedMessage(NewAdmin, Translation.Arguments("player", newAdminClient.Name)); Log.Write("server", $"{newAdminClient.Name} is now the admin."); server.SyncLobbyClients(); @@ -707,16 +834,15 @@ namespace OpenRA.Mods.Common.Server { if (!client.IsAdmin) { - server.SendOrderTo(conn, "Message", "Only the host can move players to spectators."); + server.SendLocalizedMessageTo(conn, NoMoveSpectators); return true; } Exts.TryParseIntegerInvariant(s, out var targetId); var targetConn = server.Conns.SingleOrDefault(c => server.GetClient(c)?.Index == targetId); - if (targetConn == null) { - server.SendOrderTo(conn, "Message", "No-one in that slot."); + server.SendLocalizedMessageTo(conn, EmptySlot); return true; } @@ -727,7 +853,7 @@ namespace OpenRA.Mods.Common.Server targetClient.Handicap = 0; targetClient.Color = Color.White; targetClient.State = Session.ClientState.NotReady; - server.SendMessage($"{client.Name} moved {targetClient.Name} to spectators."); + server.SendLocalizedMessage(MoveSpectators, Translation.Arguments("admin", client.Name, "player", targetClient.Name)); Log.Write("server", $"{client.Name} moved {targetClient.Name} to spectators."); server.SyncLobbyClients(); CheckAutoStart(server); @@ -744,8 +870,8 @@ namespace OpenRA.Mods.Common.Server if (sanitizedName == client.Name) return true; - Log.Write("server", "Player@{0} is now known as {1}.", conn.EndPoint, sanitizedName); - server.SendMessage($"{client.Name} is now known as {sanitizedName}."); + Log.Write("server", $"Player@{conn.EndPoint} is now known as {sanitizedName}."); + server.SendLocalizedMessage(Nick, Translation.Arguments("player", client.Name, "name", sanitizedName)); client.Name = sanitizedName; server.SyncLobbyClients(); @@ -771,14 +897,15 @@ namespace OpenRA.Mods.Common.Server var factions = server.Map.WorldActorInfo.TraitInfos() .Where(f => f.Selectable).Select(f => f.InternalName); - if (!factions.Contains(parts[1])) + var faction = parts[1]; + if (!factions.Contains(faction)) { - server.SendOrderTo(conn, "Message", $"Invalid faction selected: {parts[1]}"); - server.SendOrderTo(conn, "Message", $"Supported values: {factions.JoinWith(", ")}"); + server.SendLocalizedMessageTo(conn, InvalidFactionSelected, Translation.Arguments("faction", faction)); + server.SendLocalizedMessageTo(conn, SupportedFactions, Translation.Arguments("factions", factions.JoinWith(", "))); return true; } - targetClient.Faction = parts[1]; + targetClient.Faction = faction; server.SyncLobbyClients(); return true; @@ -858,7 +985,7 @@ namespace OpenRA.Mods.Common.Server var existingClient = server.LobbyInfo.Clients.FirstOrDefault(cc => cc.SpawnPoint == spawnPoint); if (client != existingClient && !client.IsAdmin) { - server.SendOrderTo(conn, "Message", "Only admins can clear spawn points."); + server.SendLocalizedMessageTo(conn, AdminClearSpawn); return true; } @@ -910,13 +1037,13 @@ namespace OpenRA.Mods.Common.Server if (!Exts.TryParseIntegerInvariant(parts[1], out var spawnPoint) || spawnPoint < 0 || spawnPoint > server.Map.SpawnPoints.Length) { - Log.Write("server", "Invalid spawn point: {0}", parts[1]); + Log.Write("server", $"Invalid spawn point: {parts[1]}"); return true; } if (server.LobbyInfo.Clients.Where(cc => cc != client).Any(cc => (cc.SpawnPoint == spawnPoint) && (cc.SpawnPoint != 0))) { - server.SendOrderTo(conn, "Message", "You cannot occupy the same spawn point as another player."); + server.SendLocalizedMessageTo(conn, SpawnOccupied); return true; } @@ -931,7 +1058,7 @@ namespace OpenRA.Mods.Common.Server if (spawnLockedByAnotherSlot) { - server.SendOrderTo(conn, "Message", "The spawn point is locked to another player slot."); + server.SendLocalizedMessageTo(conn, SpawnLocked); return true; } } @@ -978,7 +1105,7 @@ namespace OpenRA.Mods.Common.Server { if (!client.IsAdmin) { - server.SendOrderTo(conn, "Message", "Only the host can set lobby info"); + server.SendLocalizedMessageTo(conn, AdminLobbyInfo); return true; } @@ -989,7 +1116,7 @@ namespace OpenRA.Mods.Common.Server } catch (Exception) { - server.SendOrderTo(conn, "Message", "Invalid Lobby Info Sent"); + server.SendLocalizedMessageTo(conn, InvalidLobbyInfo); } return true; @@ -1079,8 +1206,8 @@ namespace OpenRA.Mods.Common.Server Action onError = message => { - if (connectionToEcho != null) - server.SendOrderTo(connectionToEcho, "Message", message); + if (connectionToEcho != null && message != null) + server.SendLocalizedMessageTo(connectionToEcho, message); }; var terrainColors = server.ModData.DefaultTerrainInfo[server.Map.TileSet].RestrictedPlayerColors; diff --git a/OpenRA.Mods.Common/ServerTraits/MasterServerPinger.cs b/OpenRA.Mods.Common/ServerTraits/MasterServerPinger.cs index fe4608e4bc..cbb5439f16 100644 --- a/OpenRA.Mods.Common/ServerTraits/MasterServerPinger.cs +++ b/OpenRA.Mods.Common/ServerTraits/MasterServerPinger.cs @@ -30,11 +30,24 @@ namespace OpenRA.Mods.Common.Server // 1 second (in milliseconds) minimum delay between pings const int RateLimitInterval = 1000; + [TranslationReference] + static readonly string NoPortForward = "no-port-forward"; + [TranslationReference] + static readonly string BlacklistedTitle = "blacklisted-title"; + [TranslationReference] + static readonly string InvalidErrorCode = "invalid-error-code"; + [TranslationReference] + static readonly string Connected = "master-server-connected"; + [TranslationReference] + static readonly string Error = "master-server-error"; + [TranslationReference] + static readonly string GameOffline = "game-offline"; + static readonly Beacon LanGameBeacon; static readonly Dictionary MasterServerErrors = new Dictionary() { - { 1, "Server port is not accessible from the internet." }, - { 2, "Server name contains a blacklisted word." } + { 1, NoPortForward }, + { 2, BlacklistedTitle } }; long lastPing = 0; @@ -143,25 +156,25 @@ namespace OpenRA.Mods.Common.Server var regex = new Regex(@"^\[(?-?\d+)\](?.*)"); var match = regex.Match(masterResponseText); errorMessage = match.Success && int.TryParse(match.Groups["code"].Value, out errorCode) ? - match.Groups["message"].Value.Trim() : "Failed to parse error message"; + match.Groups["message"].Value.Trim() : InvalidErrorCode; } isInitialPing = false; lock (masterServerMessages) { - masterServerMessages.Enqueue("Master server communication established."); + masterServerMessages.Enqueue(Connected); if (errorCode != 0) { // Hardcoded error messages take precedence over the server-provided messages if (!MasterServerErrors.TryGetValue(errorCode, out var message)) message = errorMessage; - masterServerMessages.Enqueue("Warning: " + message); + masterServerMessages.Enqueue(message); // Positive error codes indicate errors that prevent advertisement // Negative error codes are non-fatal warnings if (errorCode > 0) - masterServerMessages.Enqueue("Game has not been advertised online."); + masterServerMessages.Enqueue(GameOffline); } } } @@ -170,7 +183,7 @@ namespace OpenRA.Mods.Common.Server { Log.Write("server", ex.ToString()); lock (masterServerMessages) - masterServerMessages.Enqueue("Master server communication failed."); + masterServerMessages.Enqueue(Error); } isBusy = false; diff --git a/OpenRA.Mods.Common/ServerTraits/PlayerPinger.cs b/OpenRA.Mods.Common/ServerTraits/PlayerPinger.cs index 70264a08ce..470fba4e56 100644 --- a/OpenRA.Mods.Common/ServerTraits/PlayerPinger.cs +++ b/OpenRA.Mods.Common/ServerTraits/PlayerPinger.cs @@ -9,6 +9,7 @@ */ #endregion +using System.Collections.Generic; using System.Linq; using OpenRA.Server; using S = OpenRA.Server.Server; @@ -21,6 +22,18 @@ namespace OpenRA.Mods.Common.Server static readonly int ConnReportInterval = 20000; // Report every 20 seconds static readonly int ConnTimeout = 60000; // Drop unresponsive clients after 60 seconds + [TranslationReference] + static readonly string PlayerDropped = "player-dropped"; + + [TranslationReference("player")] + static readonly string ConnectionProblems = "connection-problems"; + + [TranslationReference("player")] + static readonly string Timeout = "timeout"; + + [TranslationReference("player", "timeout")] + static readonly string TimeoutIn = "timeout-in"; + long lastPing = 0; long lastConnReport = 0; bool isInitialPing = true; @@ -48,7 +61,7 @@ namespace OpenRA.Mods.Common.Server if (client == null) { server.DropClient(c); - server.SendMessage("A player has been dropped after timing out."); + server.SendLocalizedMessage(PlayerDropped); continue; } @@ -56,13 +69,13 @@ namespace OpenRA.Mods.Common.Server { if (!c.TimeoutMessageShown && c.TimeSinceLastResponse > PingInterval * 2) { - server.SendMessage(client.Name + " is experiencing connection problems."); + server.SendLocalizedMessage(ConnectionProblems, Translation.Arguments("player", client.Name)); c.TimeoutMessageShown = true; } } else { - server.SendMessage(client.Name + " has been dropped after timing out."); + server.SendLocalizedMessage(Timeout, Translation.Arguments("player", client.Name)); server.DropClient(c); } } @@ -79,7 +92,14 @@ namespace OpenRA.Mods.Common.Server { var client = server.GetClient(c); if (client != null) - server.SendMessage($"{client.Name} will be dropped in {(ConnTimeout - c.TimeSinceLastResponse) / 1000} seconds."); + { + var timeout = (ConnTimeout - c.TimeSinceLastResponse) / 1000; + server.SendLocalizedMessage(TimeoutIn, new Dictionary() + { + { "player", client.Name }, + { "timeout", timeout } + }); + } } } } diff --git a/OpenRA.Mods.Common/Traits/World/ColorPickerManager.cs b/OpenRA.Mods.Common/Traits/World/ColorPickerManager.cs index 988af4c670..7044a4ec43 100644 --- a/OpenRA.Mods.Common/Traits/World/ColorPickerManager.cs +++ b/OpenRA.Mods.Common/Traits/World/ColorPickerManager.cs @@ -60,6 +60,13 @@ namespace OpenRA.Mods.Common.Traits public Color Color; + [TranslationReference] + static readonly string PlayerColorTerrain = "player-color-terrain"; + [TranslationReference] + static readonly string PlayerColorPlayer = "player-color-player"; + [TranslationReference] + static readonly string InvalidPlayerColor = "invalid-player-color"; + bool TryGetBlockingColor((float R, float G, float B) color, List<(float R, float G, float B)> candidateBlockers, out (float R, float G, float B) closestBlocker) { var closestDistance = SimilarityThreshold; @@ -148,9 +155,9 @@ namespace OpenRA.Mods.Common.Traits { var linear = Color.FromAhsv(hue, sat, V).ToLinear(); if (TryGetBlockingColor(linear, terrainLinear, out var blocker)) - errorMessage = "Color was adjusted to be less similar to the terrain."; + errorMessage = PlayerColorTerrain; else if (TryGetBlockingColor(linear, playerLinear, out blocker)) - errorMessage = "Color was adjusted to be less similar to another player."; + errorMessage = PlayerColorPlayer; else { if (errorMessage != null) @@ -169,7 +176,7 @@ namespace OpenRA.Mods.Common.Traits } // Failed to find a solution within a reasonable time: return a random color without any validation - onError?.Invoke("Unable to determine a valid player color. A random color has been selected."); + onError?.Invoke(InvalidPlayerColor); return Color.FromAhsv(random.NextFloat(), float2.Lerp(HsvSaturationRange[0], HsvSaturationRange[1], random.NextFloat()), V); } } diff --git a/OpenRA.Mods.Common/Widgets/Logic/ConnectionLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/ConnectionLogic.cs index 90d9de3736..d69341e6aa 100644 --- a/OpenRA.Mods.Common/Widgets/Logic/ConnectionLogic.cs +++ b/OpenRA.Mods.Common/Widgets/Logic/ConnectionLogic.cs @@ -107,7 +107,7 @@ namespace OpenRA.Mods.Common.Widgets.Logic widget.Get("CONNECTING_DESC").GetText = () => $"Could not connect to {connection.Target}"; var connectionError = widget.Get("CONNECTION_ERROR"); - connectionError.GetText = () => orderManager.ServerError ?? connection.ErrorMessage ?? "Unknown error"; + connectionError.GetText = () => Ui.Translate(orderManager.ServerError) ?? connection.ErrorMessage ?? "Unknown error"; var panelTitle = widget.Get("TITLE"); panelTitle.GetText = () => orderManager.AuthenticationFailed ? "Password Required" : "Connection Failed"; diff --git a/mods/cnc/mod.yaml b/mods/cnc/mod.yaml index e8cabc00c7..526daa77ba 100644 --- a/mods/cnc/mod.yaml +++ b/mods/cnc/mod.yaml @@ -142,6 +142,9 @@ ChromeLayout: cnc|chrome/editor.yaml common|chrome/text-notifications.yaml +Translations: + common|languages/en.ftl + Voices: cnc|audio/voices.yaml diff --git a/mods/common/languages/en.ftl b/mods/common/languages/en.ftl new file mode 100644 index 0000000000..7c1296c571 --- /dev/null +++ b/mods/common/languages/en.ftl @@ -0,0 +1,86 @@ +## Server Orders + +custom-rules = This map contains custom rules. Game experience may change. +bots-disabled = Bots have been disabled on this map. +two-humans-required = This server requires at least two human players to start a match. +unknown-server-command = Unknown server command: { $command } +only-only-host-start-game = Only the host can start the game. +no-start-until-required-slots-full = Unable to start the game until required slots are full. +insufficient-enabled-spawnPoints = Unable to start the game until more spawn points are enabled. +malformed-command = Malformed { $command } command +chat-disabled = + { $remaining -> + [one] Chat is disabled. Please try again in { $remaining } second. + *[other] Chat is disabled. Please try again in { $remaining } seconds. + } +state-unchanged-ready = Cannot change state when marked as ready. +invalid-faction-selected = Invalid faction selected: { $faction } +supported-factions = Supported values: { $factions } +state-unchanged-game-started = Cannot change state when game started. ({ $command }) +requires-host = Only the host can do that. +invalid-bot-slot = Can't add bots to a slot with another client. +invalid-bot-type = Invalid bot type. +only-host-change-map = Only the host can change the map. +lobby-disconnected = { $player } has left. +player-disconnected = + { $team -> + [0] { $player } has disconnected. + *[other] { $player } (Team { $team }) has disconnected. + } +observer-disconnected = { $player } (Spectator) has disconnected. +unknown-map = Map was not found on server. +searching-map = Searching for map on the Resource Center... +only-host-change-configuration = Only the host can change the configuration. +changed-map = { $player } changed the map to { $map } +value-changed = { $player } changed { $name } to { $value }. +you-were-kicked = You have been kicked from the server. +kicked = { $admin } kicked { $player } from the server. +temp-ban = { $admin } temporarily banned { $player } from the server. +only-host-transfer-admin = Only admins can transfer admin to another player. +only-host-move-spectators = Only the host can move players to spectators. +empty-slot = No-one in that slot. +move-spectators = { $admin } moved { $player } to spectators. +nick = { $player } is now known as { $name }. +player-dropped = A player has been dropped after timing out. +connection-problems = { $player } is experiencing connection problems. +timeout = { $player } has been dropped after timing out. +timeout-in = + { $timeout -> + [one] { $player } will be dropped in { $timeout } second. + *[other] { $player } will be dropped in { $timeout } seconds. + } +error-game-started = The game has already started. +requires-password = Server requires a password. +incorrect-password = Incorrect password. +incompatible-mod = Server is running an incompatible mod. +incompatible-version = Server is running an incompatible version. +incompatible-protocol = Server is running an incompatible protocol. +banned = You have been banned from the server. +temp-banned = You have been temporarily banned from the server. +full = The game is full. +joined = { $player } has joined the game. +new-admin = { $player } is now the admin. +option-locked = { $option } cannot be changed. +invalid-configuration-command = Invalid configuration command. +admin-option = Only the host can set that option. +number-teams = Number of teams could not be parsed: { $raw } +admin-kick = Only the host can kick players. +kick-none = No-one in that slot. +no-kick-game-started = Only spectators can be kicked after the game has started. +admin-clear-spawn = Only admins can clear spawn points. +spawn-occupied = You cannot occupy the same spawn point as another player. +spawn-locked = The spawn point is locked to another player slot. +admin-lobby-info = Only the host can set lobby info. +invalid-lobby-info = Invalid lobby info sent. +player-color-terrain = Color was adjusted to be less similar to the terrain. +player-color-player = Color was adjusted to be less similar to another player. +invalid-player-color = Unable to determine a valid player color. A random color has been selected. +invalid-error-code = Failed to parse error message. +master-server-connected = Master server communication established. +master-server-error = "Master server communication failed." +game-offline = Game has not been advertised online. +no-port-forward = Server port is not accessible from the internet. +blacklisted-title = Server name contains a blacklisted word. +requires-forum-account = Server requires players to have an OpenRA forum account. +no-permission = You do not have permission to join this server. +slot-closed = Your slot was closed by the host. diff --git a/mods/d2k/mod.yaml b/mods/d2k/mod.yaml index 55589c573e..691d9157f0 100644 --- a/mods/d2k/mod.yaml +++ b/mods/d2k/mod.yaml @@ -121,6 +121,9 @@ ChromeLayout: common|chrome/gamesave-loading.yaml common|chrome/text-notifications.yaml +Translations: + common|languages/en.ftl + Weapons: d2k|weapons/debris.yaml d2k|weapons/smallguns.yaml diff --git a/mods/ra/mod.yaml b/mods/ra/mod.yaml index c390f11083..4fafd5edbb 100644 --- a/mods/ra/mod.yaml +++ b/mods/ra/mod.yaml @@ -138,6 +138,9 @@ ChromeLayout: common|chrome/playerprofile.yaml common|chrome/text-notifications.yaml +Translations: + common|languages/en.ftl + Weapons: ra|weapons/explosions.yaml ra|weapons/ballistics.yaml @@ -155,9 +158,6 @@ Notifications: Music: ra|audio/music.yaml -Translations: - ra|languages/english.yaml - Hotkeys: common|hotkeys/game.yaml common|hotkeys/observer.yaml diff --git a/mods/ts/mod.yaml b/mods/ts/mod.yaml index 845a65ebd3..ca85bb627b 100644 --- a/mods/ts/mod.yaml +++ b/mods/ts/mod.yaml @@ -183,6 +183,9 @@ ChromeLayout: common|chrome/editor.yaml common|chrome/text-notifications.yaml +Translations: + common|languages/en.ftl + Voices: ts|audio/voices.yaml