diff --git a/OpenRA.Game/Game.cs b/OpenRA.Game/Game.cs index 520455b944..0f1ea0ac27 100644 --- a/OpenRA.Game/Game.cs +++ b/OpenRA.Game/Game.cs @@ -72,10 +72,10 @@ namespace OpenRA return om; } - static string TimestampedFilename(bool includemilliseconds = false) + public static string TimestampedFilename(bool includemilliseconds = false, string extra = "") { var format = includemilliseconds ? "yyyy-MM-ddTHHmmssfffZ" : "yyyy-MM-ddTHHmmssZ"; - return ModData.Manifest.Id + "-" + DateTime.UtcNow.ToString(format, CultureInfo.InvariantCulture); + return ModData.Manifest.Id + extra + "-" + DateTime.UtcNow.ToString(format, CultureInfo.InvariantCulture); } static void JoinInner(OrderManager om) diff --git a/OpenRA.Game/Network/ReplayRecorder.cs b/OpenRA.Game/Network/ReplayRecorder.cs index 37261b72bb..47657435ae 100644 --- a/OpenRA.Game/Network/ReplayRecorder.cs +++ b/OpenRA.Game/Network/ReplayRecorder.cs @@ -10,6 +10,7 @@ #endregion using System; +using System.Collections.Generic; using System.IO; using System.Linq; using OpenRA.FileFormats; @@ -85,6 +86,14 @@ namespace OpenRA.Network writer.Write(data); } + public void ReceiveFrame(int clientID, int frame, byte[] data) + { + var ms = new MemoryStream(4 + data.Length); + ms.WriteArray(BitConverter.GetBytes(frame)); + ms.WriteArray(data); + Receive(clientID, ms.GetBuffer()); + } + bool disposed; public void Dispose() diff --git a/OpenRA.Game/Server/Server.cs b/OpenRA.Game/Server/Server.cs index 4392e72ef8..eaf01d86af 100644 --- a/OpenRA.Game/Server/Server.cs +++ b/OpenRA.Game/Server/Server.cs @@ -18,9 +18,11 @@ using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; +using OpenRA.FileFormats; using OpenRA.Network; using OpenRA.Primitives; using OpenRA.Support; +using OpenRA.Traits; namespace OpenRA.Server { @@ -70,6 +72,10 @@ namespace OpenRA.Server volatile ActionQueue delayedActions = new ActionQueue(); int waitingForAuthenticationCallback = 0; + ReplayRecorder recorder; + GameInformation gameInfo; + readonly List worldPlayers = new List(); + public ServerState State { get { return internalState; } @@ -123,6 +129,42 @@ namespace OpenRA.Server { foreach (var t in serverTraits.WithInterface()) t.GameEnded(this); + + recorder?.Dispose(); + recorder = null; + } + + // Craft a fake handshake request/response because that's the + // only way to expose the Version and OrdersProtocol. + public void RecordFakeHandshake() + { + var request = new HandshakeRequest + { + Mod = ModData.Manifest.Id, + Version = ModData.Manifest.Metadata.Version, + }; + + recorder.ReceiveFrame(0, 0, new Order("HandshakeRequest", null, false) + { + Type = OrderType.Handshake, + IsImmediate = true, + TargetString = request.Serialize(), + }.Serialize()); + + var response = new HandshakeResponse() + { + Mod = ModData.Manifest.Id, + Version = ModData.Manifest.Metadata.Version, + OrdersProtocol = ProtocolVersion.Orders, + Client = new Session.Client(), + }; + + recorder.ReceiveFrame(0, 0, new Order("HandshakeResponse", null, false) + { + Type = OrderType.Handshake, + IsImmediate = true, + TargetString = response.Serialize(), + }.Serialize()); } public Server(List endpoints, ServerSettings settings, ModData modData, ServerType type) @@ -198,6 +240,15 @@ namespace OpenRA.Server } }; + if (Settings.RecordReplays && Type == ServerType.Dedicated) + { + recorder = new ReplayRecorder(() => { return Game.TimestampedFilename(extra: "-Server"); }); + + // We only need one handshake to initialize the replay. + // Add it now, then ignore the redundant handshakes from each client + RecordFakeHandshake(); + } + new Thread(_ => { foreach (var t in serverTraits.WithInterface()) @@ -633,6 +684,9 @@ namespace OpenRA.Server var from = conn != null ? conn.PlayerIndex : 0; foreach (var c in Conns.Except(conn).ToList()) DispatchOrdersToClient(c, from, frame, data); + + if (recorder != null) + recorder.ReceiveFrame(from, frame, data); } public void DispatchOrders(Connection conn, int frame, byte[] data) @@ -1034,12 +1088,44 @@ namespace OpenRA.Server // TODO: Enable for multiplayer (non-dedicated servers only) once the lobby UI has been created LobbyInfo.GlobalSettings.GameSavesEnabled = Type != ServerType.Dedicated && LobbyInfo.NonBotClients.Count() == 1; + // Player list for win/loss tracking + // HACK: NonCombatant and non-Playable players are set to null to simplify replay tracking + // The null padding is needed to keep the player indexes in sync with world.Players on the clients + // This will need to change if future code wants to use worldPlayers for other purposes + foreach (var cmpi in Map.Rules.Actors["world"].TraitInfos()) + cmpi.CreateServerPlayers(Map, LobbyInfo, worldPlayers); + + if (recorder != null) + { + gameInfo = new GameInformation + { + Mod = Game.ModData.Manifest.Id, + Version = Game.ModData.Manifest.Metadata.Version, + MapUid = Map.Uid, + MapTitle = Map.Title, + StartTimeUtc = DateTime.UtcNow, + }; + + // Replay metadata should only include the playable players + foreach (var p in worldPlayers) + if (p != null) + gameInfo.Players.Add(p); + + recorder.Metadata = new ReplayMetadata(gameInfo); + } + SyncLobbyInfo(); State = ServerState.GameStarted; + var disconnectData = new[] { (byte)OrderType.Disconnect }; foreach (var c in Conns) + { foreach (var d in Conns) - DispatchOrdersToClient(c, d.PlayerIndex, int.MaxValue, new[] { (byte)OrderType.Disconnect }); + DispatchOrdersToClient(c, d.PlayerIndex, int.MaxValue, disconnectData); + + if (recorder != null) + recorder.ReceiveFrame(c.PlayerIndex, int.MaxValue, disconnectData); + } if (GameSave == null && LobbyInfo.GlobalSettings.GameSavesEnabled) GameSave = new GameSave(); diff --git a/OpenRA.Game/Settings.cs b/OpenRA.Game/Settings.cs index ccbb3700d2..7713c22b96 100644 --- a/OpenRA.Game/Settings.cs +++ b/OpenRA.Game/Settings.cs @@ -88,6 +88,9 @@ namespace OpenRA [Desc("Allow clients to see the country of other clients.")] public bool EnableGeoIP = true; + [Desc("For dedicated servers only, save replays for all games played.")] + public bool RecordReplays = false; + public ServerSettings Clone() { return (ServerSettings)MemberwiseClone(); diff --git a/launch-dedicated.cmd b/launch-dedicated.cmd index d9067012c3..372b6b7bb5 100644 --- a/launch-dedicated.cmd +++ b/launch-dedicated.cmd @@ -7,6 +7,7 @@ set Mod=ra set ListenPort=1234 set AdvertiseOnline=True set Password="" +set RecordReplays=False set RequireAuthentication=False set ProfileIDBlacklist="" @@ -21,6 +22,6 @@ set SupportDir="" :loop -OpenRA.Server.exe Game.Mod=%Mod% Server.Name=%Name% Server.ListenPort=%ListenPort% Server.AdvertiseOnline=%AdvertiseOnline% Server.EnableSingleplayer=%EnableSingleplayer% Server.Password=%Password% Server.RequireAuthentication=%RequireAuthentication% Server.ProfileIDBlacklist=%ProfileIDBlacklist% Server.ProfileIDWhitelist=%ProfileIDWhitelist% Server.EnableSyncReports=%EnableSyncReports% Server.EnableGeoIP=%EnableGeoIP% Server.ShareAnonymizedIPs=%ShareAnonymizedIPs% Engine.SupportDir=%SupportDir% +OpenRA.Server.exe Game.Mod=%Mod% Server.Name=%Name% Server.ListenPort=%ListenPort% Server.AdvertiseOnline=%AdvertiseOnline% Server.EnableSingleplayer=%EnableSingleplayer% Server.Password=%Password% Server.RecordReplays=%RecordReplays% Server.RequireAuthentication=%RequireAuthentication% Server.ProfileIDBlacklist=%ProfileIDBlacklist% Server.ProfileIDWhitelist=%ProfileIDWhitelist% Server.EnableSyncReports=%EnableSyncReports% Server.EnableGeoIP=%EnableGeoIP% Server.ShareAnonymizedIPs=%ShareAnonymizedIPs% Engine.SupportDir=%SupportDir% goto loop diff --git a/launch-dedicated.sh b/launch-dedicated.sh index 31ef313d75..611ca6c830 100755 --- a/launch-dedicated.sh +++ b/launch-dedicated.sh @@ -11,6 +11,7 @@ Mod="${Mod:-"ra"}" ListenPort="${ListenPort:-"1234"}" AdvertiseOnline="${AdvertiseOnline:-"True"}" Password="${Password:-""}" +RecordReplays="${RecordReplays:-"False"}" RequireAuthentication="${RequireAuthentication:-"False"}" ProfileIDBlacklist="${ProfileIDBlacklist:-""}" @@ -30,6 +31,7 @@ while true; do Server.AdvertiseOnline="$AdvertiseOnline" \ Server.EnableSingleplayer="$EnableSingleplayer" \ Server.Password="$Password" \ + Server.RecordReplays="$RecordReplays" \ Server.GeoIPDatabase="$GeoIPDatabase" \ Server.RequireAuthentication="$RequireAuthentication" \ Server.ProfileIDBlacklist="$ProfileIDBlacklist" \