From 3ba610b53534e9da2da9ea2a843f2f47c7b2fbdc Mon Sep 17 00:00:00 2001 From: Paul Chote Date: Mon, 25 Dec 2017 13:20:13 +0000 Subject: [PATCH] Implement new master server ping protocol. --- OpenRA.Game/Network/GameServer.cs | 203 +++++++++++++++--- .../ServerTraits/MasterServerPinger.cs | 63 +----- .../Widgets/Logic/MultiplayerLogic.cs | 16 +- 3 files changed, 191 insertions(+), 91 deletions(-) diff --git a/OpenRA.Game/Network/GameServer.cs b/OpenRA.Game/Network/GameServer.cs index f8e3e64024..165fc1c38f 100644 --- a/OpenRA.Game/Network/GameServer.cs +++ b/OpenRA.Game/Network/GameServer.cs @@ -9,62 +9,209 @@ */ #endregion +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using OpenRA.Graphics; + namespace OpenRA.Network { + public class GameClient + { + public readonly string Name; + public readonly HSLColor Color; + public readonly string Faction; + public readonly int Team; + public readonly int SpawnPoint; + public readonly bool IsAdmin; + public readonly bool IsSpectator; + public readonly bool IsBot; + + public GameClient() { } + + public GameClient(Session.Client c) + { + Name = c.Name; + Color = c.Color; + Faction = c.Faction; + Team = c.Team; + SpawnPoint = c.SpawnPoint; + IsAdmin = c.IsAdmin; + IsSpectator = c.Slot == null && c.Bot == null; + IsBot = c.Bot != null; + } + } + public class GameServer { - public readonly int Id = 0; + static readonly string[] SerializeFields = { "Name", "Address", "Mod", "Version", "Map", "State", "MaxPlayers", "Protected" }; + public const int ProtocolVersion = 2; + + /// Online game number or -1 for LAN games + public readonly int Id = -1; + + /// Name of the server public readonly string Name = null; + + /// ip:port string to connect to. public readonly string Address = null; + + /// Port of the server + public readonly int Port = 0; + + /// The current state of the server (waiting/playing/completed) public readonly int State = 0; - public readonly int Players = 0; + + /// The number of slots available for non-bot players public readonly int MaxPlayers = 0; - public readonly int Bots = 0; - public readonly int Spectators = 0; + + /// UID of the map public readonly string Map = null; - public readonly string Mods = ""; - public readonly int TTL = 0; + + /// Mod ID + public readonly string Mod = ""; + + /// Mod Version + public readonly string Version = ""; + + /// Password protected public readonly bool Protected = false; + + /// UTC datetime string when the game changed to the Playing state public readonly string Started = null; + /// Number of non-spectator, non-bot players. Only defined if GameServer is parsed from yaml. + public readonly int Players = 0; + + /// Number of bot players. Only defined if GameServer is parsed from yaml. + public readonly int Bots = 0; + + /// Number of spectators. Only defined if GameServer is parsed from yaml. + public readonly int Spectators = 0; + + /// Number of seconds that the game has been in the Playing state. Only defined if GameServer is parsed from yaml. + public readonly int PlayTime = -1; + + /// Can we join this server (after switching mods if required)? Only defined if GameServer is parsed from yaml. + [FieldLoader.Ignore] public readonly bool IsCompatible = false; + + /// Can we join this server (after switching mods if required)? Only defined if GameServer is parsed from yaml. + [FieldLoader.Ignore] public readonly bool IsJoinable = false; + /// Label to display in the multiplayer browser. Only defined if GameServer is parsed from yaml. + [FieldLoader.Ignore] public readonly string ModLabel = ""; - public readonly string ModId = ""; - public readonly string ModVersion = ""; + + [FieldLoader.LoadUsing("LoadClients")] + public readonly GameClient[] Clients; + + static object LoadClients(MiniYaml yaml) + { + var clients = new List(); + var clientsNode = yaml.Nodes.FirstOrDefault(n => n.Key == "Clients"); + if (clientsNode != null) + { + var regex = new Regex(@"Client@\d+"); + foreach (var client in clientsNode.Value.Nodes) + if (regex.IsMatch(client.Key)) + clients.Add(FieldLoader.Load(client.Value)); + } + + return clients.ToArray(); + } public GameServer(MiniYaml yaml) { FieldLoader.Load(this, yaml); - Manifest mod; - ExternalMod external; - var modVersion = Mods.Split('@'); - - ModLabel = "Unknown mod: {0}".F(Mods); - if (modVersion.Length == 2) + // Games advertised using the old API used a single Mods field + if (Mod == null || Version == null) { - ModId = modVersion[0]; - ModVersion = modVersion[1]; - - var externalKey = ExternalMod.MakeKey(modVersion[0], modVersion[1]); - if (Game.ExternalMods.TryGetValue(externalKey, out external) - && external.Version == modVersion[1]) + var modsNode = yaml.Nodes.FirstOrDefault(n => n.Key == "Mods"); + if (modsNode != null) { - ModLabel = "{0} ({1})".F(external.Title, external.Version); - IsCompatible = true; - } - else if (Game.Mods.TryGetValue(modVersion[0], out mod)) - { - // Use internal mod data to populate the section header, but - // on-connect switching must use the external mod plumbing. - ModLabel = "{0} ({1})".F(mod.Metadata.Title, modVersion[1]); + var modVersion = modsNode.Value.Value.Split('@'); + Mod = modVersion[0]; + Version = modVersion[1]; } } + // Games advertised using the old API calculated the play time locally + if (State == 2 && PlayTime < 0) + { + DateTime startTime; + if (DateTime.TryParse(Started, out startTime)) + PlayTime = (int)(DateTime.UtcNow - startTime).TotalSeconds; + } + + Manifest mod; + ExternalMod external; + + var externalKey = ExternalMod.MakeKey(Mod, Version); + if (Game.ExternalMods.TryGetValue(externalKey, out external) && external.Version == Version) + { + ModLabel = "{0} ({1})".F(external.Title, external.Version); + IsCompatible = true; + } + else if (Game.Mods.TryGetValue(Mod, out mod)) + { + // Use internal mod data to populate the section header, but + // on-connect switching must use the external mod plumbing. + ModLabel = "{0} ({1})".F(mod.Metadata.Title, Version); + } + else + ModLabel = "Unknown mod: {0}".F(Mod); + var mapAvailable = Game.Settings.Game.AllowDownloading || Game.ModData.MapCache[Map].Status == MapStatus.Available; IsJoinable = IsCompatible && State == 1 && mapAvailable; } + + public GameServer(Server.Server server) + { + Name = server.Settings.Name; + + // IP address will be replaced with a real value by the master server / receiving LAN client + Address = "0.0.0.0:" + server.Settings.ListenPort.ToString(); + State = (int)server.State; + MaxPlayers = server.LobbyInfo.Slots.Count(s => !s.Value.Closed) - server.LobbyInfo.Clients.Count(c1 => c1.Bot != null); + Map = server.Map.Uid; + Mod = server.ModData.Manifest.Id; + Version = server.ModData.Manifest.Metadata.Version; + Protected = !string.IsNullOrEmpty(server.Settings.Password); + Clients = server.LobbyInfo.Clients.Select(c => new GameClient(c)).ToArray(); + } + + public string ToPOSTData(bool lanGame) + { + var root = new List() { new MiniYamlNode("Protocol", ProtocolVersion.ToString()) }; + foreach (var field in SerializeFields) + root.Add(FieldSaver.SaveField(this, field)); + + if (lanGame) + { + // Add fields that are normally generated by the master server + // LAN games overload the Id with a GUID string (rather than an ID) to allow deduplication + root.Add(new MiniYamlNode("Id", Platform.SessionGUID.ToString())); + root.Add(new MiniYamlNode("Players", Clients.Count(c => !c.IsBot && !c.IsSpectator).ToString())); + root.Add(new MiniYamlNode("Spectators", Clients.Count(c => c.IsSpectator).ToString())); + root.Add(new MiniYamlNode("Bots", Clients.Count(c => c.IsBot).ToString())); + + // Included for backwards compatibility with older clients that don't support separated Mod/Version. + root.Add(new MiniYamlNode("Mods", Mod + "@" + Version)); + } + + var clientsNode = new MiniYaml(""); + var i = 0; + foreach (var c in Clients) + clientsNode.Nodes.Add(new MiniYamlNode("Client@" + (i++).ToString(), FieldSaver.Save(c))); + + root.Add(new MiniYamlNode("Clients", clientsNode)); + return new MiniYaml("", root) + .ToLines("Game") + .JoinWith("\n"); + } } } diff --git a/OpenRA.Mods.Common/ServerTraits/MasterServerPinger.cs b/OpenRA.Mods.Common/ServerTraits/MasterServerPinger.cs index 81d72c68cf..f498d11395 100644 --- a/OpenRA.Mods.Common/ServerTraits/MasterServerPinger.cs +++ b/OpenRA.Mods.Common/ServerTraits/MasterServerPinger.cs @@ -16,6 +16,7 @@ using System.Net; using System.Text; using System.Text.RegularExpressions; using BeaconLib; +using OpenRA.Network; using OpenRA.Server; using S = OpenRA.Server.Server; @@ -88,27 +89,18 @@ namespace OpenRA.Mods.Common.Server void PublishGame(S server) { - var mod = server.ModData.Manifest; + // Cache the server info on the main thread to ensure data consistency + var gs = new GameServer(server); - // important to grab these on the main server thread, not in the worker we're about to spawn -- they may be modified - // by the main thread as clients join and leave. - var numPlayers = server.LobbyInfo.Clients.Where(c1 => c1.Bot == null && c1.Slot != null).Count(); - var numBots = server.LobbyInfo.Clients.Where(c1 => c1.Bot != null).Count(); - var numSpectators = server.LobbyInfo.Clients.Where(c1 => c1.Bot == null && c1.Slot == null).Count(); - var numSlots = server.LobbyInfo.Slots.Where(s => !s.Value.Closed).Count() - numBots; - var passwordProtected = !string.IsNullOrEmpty(server.Settings.Password); - var clients = server.LobbyInfo.Clients.Where(c1 => c1.Bot == null).Select(c => Convert.ToBase64String(Encoding.UTF8.GetBytes(c.Name))).ToArray(); + if (!isBusy && server.Settings.AdvertiseOnline) + UpdateMasterServer(server, gs.ToPOSTData(false)); - UpdateMasterServer(server, numPlayers, numSlots, numBots, numSpectators, mod, passwordProtected, clients); if (LanGameBeacon != null) - UpdateLANGameBeacon(server, numPlayers, numSlots, numBots, numSpectators, mod, passwordProtected); + LanGameBeacon.BeaconData = gs.ToPOSTData(true); } - void UpdateMasterServer(S server, int numPlayers, int numSlots, int numBots, int numSpectators, Manifest mod, bool passwordProtected, string[] clients) + void UpdateMasterServer(S server, string postData) { - if (isBusy || !server.Settings.AdvertiseOnline) - return; - lastPing = Game.RunTime; isBusy = true; @@ -116,31 +108,15 @@ namespace OpenRA.Mods.Common.Server { try { - var url = "ping?port={0}&name={1}&state={2}&players={3}&bots={4}&mods={5}&map={6}&maxplayers={7}&spectators={8}&protected={9}&clients={10}"; - if (isInitialPing) url += "&new=1"; - var serverList = server.ModData.Manifest.Get().ServerList; using (var wc = new WebClient()) { wc.Proxy = null; - var masterResponse = wc.DownloadData( - serverList + url.F( - server.Settings.ExternalPort, Uri.EscapeUriString(server.Settings.Name), - (int)server.State, - numPlayers, - numBots, - "{0}@{1}".F(mod.Id, mod.Metadata.Version), - server.LobbyInfo.GlobalSettings.Map, - numSlots, - numSpectators, - passwordProtected ? 1 : 0, - string.Join(",", clients))); + var masterResponseText = wc.UploadString(serverList + "ping", postData); if (isInitialPing) { - var masterResponseText = Encoding.UTF8.GetString(masterResponse); Log.Write("server", "Master server: " + masterResponseText); - var errorCode = 0; var errorMessage = string.Empty; @@ -182,28 +158,5 @@ namespace OpenRA.Mods.Common.Server a.BeginInvoke(null, null); } - - void UpdateLANGameBeacon(S server, int numPlayers, int numSlots, int numBots, int numSpectators, Manifest mod, bool passwordProtected) - { - var settings = server.Settings; - - // TODO: Serialize and send client names - var lanGameYaml = -@"Game: - Id: {0} - Name: {1} - Address: {2}:{3} - State: {4} - Players: {5} - MaxPlayers: {6} - Bots: {7} - Spectators: {8} - Map: {9} - Mods: {10}@{11} - Protected: {12}".F(Platform.SessionGUID, settings.Name, server.Ip, settings.ListenPort, (int)server.State, numPlayers, numSlots, numBots, numSpectators, - server.Map.Uid, mod.Id, mod.Metadata.Version, passwordProtected); - - LanGameBeacon.BeaconData = lanGameYaml; - } } } diff --git a/OpenRA.Mods.Common/Widgets/Logic/MultiplayerLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/MultiplayerLogic.cs index 1afe6db4d4..fd830e581b 100644 --- a/OpenRA.Mods.Common/Widgets/Logic/MultiplayerLogic.cs +++ b/OpenRA.Mods.Common/Widgets/Logic/MultiplayerLogic.cs @@ -334,7 +334,8 @@ namespace OpenRA.Mods.Common.Widgets.Logic Game.RunAfterTick(() => RefreshServerListInner(games)); }; - var queryURL = services.ServerList + "games?version={0}&mod={1}&modversion={2}".F( + var queryURL = services.ServerList + "games?protocol={0}&engine={1}&mod={2}&version={3}".F( + GameServer.ProtocolVersion, Uri.EscapeUriString(Game.EngineVersion), Uri.EscapeUriString(Game.ModData.Manifest.Id), Uri.EscapeUriString(Game.ModData.Manifest.Metadata.Version)); @@ -346,11 +347,11 @@ namespace OpenRA.Mods.Common.Widgets.Logic { // Games that we can't join are sorted last if (!testEntry.IsCompatible) - return testEntry.ModId == modData.Manifest.Id ? 1 : 0; + return testEntry.Mod == modData.Manifest.Id ? 1 : 0; // Games for the current mod+version are sorted first - if (testEntry.ModId == modData.Manifest.Id) - return testEntry.ModVersion == modData.Manifest.Metadata.Version ? 4 : 3; + if (testEntry.Mod == modData.Manifest.Id) + return testEntry.Version == modData.Manifest.Metadata.Version ? 4 : 3; // Followed by games for different mods that are joinable return 2; @@ -405,7 +406,7 @@ namespace OpenRA.Mods.Common.Widgets.Logic { nextServerRow = null; var rows = new List(); - var mods = games.GroupBy(g => g.Mods) + var mods = games.GroupBy(g => g.ModLabel) .OrderByDescending(g => GroupSortOrder(g.First())) .ThenByDescending(g => g.Count()); @@ -553,10 +554,9 @@ namespace OpenRA.Mods.Common.Widgets.Logic { var label = "In progress"; - DateTime startTime; - if (DateTime.TryParse(game.Started, out startTime)) + if (game.PlayTime > 0) { - var totalMinutes = Math.Ceiling((DateTime.UtcNow - startTime).TotalMinutes); + var totalMinutes = Math.Ceiling(game.PlayTime / 60.0); label += " for {0} minute{1}".F(totalMinutes, totalMinutes > 1 ? "s" : ""); }