From c6d2dd6c7bc1b1f979f3a9d917c411d0adb5ad0d Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Sun, 23 Jul 2023 16:02:23 +0200 Subject: [PATCH] Automatic server replay recordings (#18235) Co-authored-by: Moony --- .../GameTicking/GameTicker.Replays.cs | 104 ++++++++++++++++++ .../GameTicking/GameTicker.RoundFlow.cs | 4 + Content.Server/GameTicking/GameTicker.cs | 2 + Content.Shared/CCVar/CCVars.cs | 31 +++++- 4 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 Content.Server/GameTicking/GameTicker.Replays.cs diff --git a/Content.Server/GameTicking/GameTicker.Replays.cs b/Content.Server/GameTicking/GameTicker.Replays.cs new file mode 100644 index 0000000000..a9df697647 --- /dev/null +++ b/Content.Server/GameTicking/GameTicker.Replays.cs @@ -0,0 +1,104 @@ +using Content.Shared.CCVar; +using Robust.Shared; +using Robust.Shared.ContentPack; +using Robust.Shared.Replays; +using Robust.Shared.Utility; + +namespace Content.Server.GameTicking; + +public sealed partial class GameTicker +{ + [Dependency] private readonly IReplayRecordingManager _replays = default!; + [Dependency] private readonly IResourceManager _resourceManager = default!; + + private ISawmill _sawmillReplays = default!; + + private void InitializeReplays() + { + _replays.RecordingFinished += ReplaysOnRecordingFinished; + } + + /// + /// A round has started: start recording replays if auto record is enabled. + /// + private void ReplayStartRound() + { + if (!_cfg.GetCVar(CCVars.ReplayAutoRecord)) + return; + + if (_replays.IsRecording) + { + _sawmillReplays.Warning("Already an active replay recording before the start of the round, not starting automatic recording."); + return; + } + + _sawmillReplays.Debug($"Starting replay recording for round {RoundId}"); + + var finalPath = GetAutoReplayPath(); + var recordPath = finalPath; + var tempDir = _cfg.GetCVar(CCVars.ReplayAutoRecordTempDir); + ResPath? moveToPath = null; + + if (!string.IsNullOrEmpty(tempDir)) + { + var baseReplayPath = new ResPath(_cfg.GetCVar(CVars.ReplayDirectory)).ToRootedPath(); + moveToPath = baseReplayPath / finalPath; + + var fileName = finalPath.Filename; + recordPath = new ResPath(tempDir) / fileName; + + _sawmillReplays.Debug($"Replay will record in temporary position: {recordPath}"); + } + + var recordState = new ReplayRecordState(moveToPath); + + if (!_replays.TryStartRecording(_resourceManager.UserData, recordPath.ToString(), state: recordState)) + { + _sawmillReplays.Error("Can't start automatic replay recording!"); + } + } + + /// + /// A round has ended: stop recording replays and make sure they're moved to the correct spot. + /// + private void ReplayEndRound() + { + if (_replays.ActiveRecordingState is ReplayRecordState) + { + _replays.StopRecording(); + } + } + + private void ReplaysOnRecordingFinished(ReplayRecordingFinished data) + { + if (data.State is not ReplayRecordState state) + return; + + if (state.MoveToPath != null) + { + _sawmillReplays.Info($"Moving replay into final position: {state.MoveToPath}"); + + _taskManager.BlockWaitOnTask(_replays.WaitWriteTasks()); + data.Directory.Rename(data.Path, state.MoveToPath.Value); + } + } + + private ResPath GetAutoReplayPath() + { + var cfgValue = _cfg.GetCVar(CCVars.ReplayAutoRecordName); + + var time = DateTime.UtcNow; + + var interpolated = cfgValue + .Replace("{year}", time.Year.ToString("D4")) + .Replace("{month}", time.Month.ToString("D2")) + .Replace("{day}", time.Day.ToString("D2")) + .Replace("{hour}", time.Hour.ToString("D2")) + .Replace("{minute}", time.Minute.ToString("D2")) + .Replace("{round}", RoundId.ToString()); + + return new ResPath(interpolated); + } + + private sealed record ReplayRecordState(ResPath? MoveToPath); +} diff --git a/Content.Server/GameTicking/GameTicker.RoundFlow.cs b/Content.Server/GameTicking/GameTicker.RoundFlow.cs index 433a2b91c8..fff054bcd7 100644 --- a/Content.Server/GameTicking/GameTicker.RoundFlow.cs +++ b/Content.Server/GameTicking/GameTicker.RoundFlow.cs @@ -170,6 +170,8 @@ namespace Content.Server.GameTicking _startingRound = true; + ReplayStartRound(); + DebugTools.Assert(RunLevel == GameRunLevel.PreRoundLobby); _sawmill.Info("Starting round!"); @@ -365,6 +367,8 @@ namespace Content.Server.GameTicking if (DummyTicker) return; + ReplayEndRound(); + // Handle restart for server update if (_serverUpdates.RoundEnded()) return; diff --git a/Content.Server/GameTicking/GameTicker.cs b/Content.Server/GameTicking/GameTicker.cs index b03dfcabec..9554e00989 100644 --- a/Content.Server/GameTicking/GameTicker.cs +++ b/Content.Server/GameTicking/GameTicker.cs @@ -56,6 +56,7 @@ namespace Content.Server.GameTicking DebugTools.Assert(!_postInitialized); _sawmill = _logManager.GetSawmill("ticker"); + _sawmillReplays = _logManager.GetSawmill("ticker.replays"); // Initialize the other parts of the game ticker. InitializeStatusShell(); @@ -67,6 +68,7 @@ namespace Content.Server.GameTicking DebugTools.Assert(_prototypeManager.Index(FallbackOverflowJob).Name == FallbackOverflowJobName, "Overflow role does not have the correct name!"); InitializeGameRules(); + InitializeReplays(); _initialized = true; } diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs index bc15793227..1738aec4b4 100644 --- a/Content.Shared/CCVar/CCVars.cs +++ b/Content.Shared/CCVar/CCVars.cs @@ -307,8 +307,8 @@ namespace Content.Shared.CCVar CVarDef.Create("game.alert_level_change_delay", 30, CVar.SERVERONLY); /// - /// How many times per second artifacts when the round is over. - /// If set to 0, they won't activate (on a timer) when the round ends. + /// How many times per second artifacts when the round is over. + /// If set to 0, they won't activate (on a timer) when the round ends. /// public static readonly CVarDef ArtifactRoundEndTimer = CVarDef.Create("game.artifact_round_end_timer", 0.5f, CVar.NOTIFY | CVar.REPLICATED); @@ -1676,5 +1676,32 @@ namespace Content.Shared.CCVar /// public static readonly CVarDef ReplayRecordAdminChat = CVarDef.Create("replay.record_admin_chat", false, CVar.ARCHIVE); + + /// + /// Automatically record full rounds as replays. + /// + public static readonly CVarDef ReplayAutoRecord = + CVarDef.Create("replay.auto_record", false, CVar.SERVERONLY); + + /// + /// The file name to record automatic replays to. The path is relative to . + /// + /// + /// + /// If the path includes slashes, directories will be automatically created if necessary. + /// + /// + /// A number of substitutions can be used to automatically fill in the file name: {year}, {month}, {day}, {hour}, {minute}, {round}. + /// + /// + public static readonly CVarDef ReplayAutoRecordName = + CVarDef.Create("replay.auto_record_name", "{year}_{month}_{day}-{hour}_{minute}-round_{round}.zip", CVar.SERVERONLY); + + /// + /// Path that, if provided, automatic replays are initially recorded in. + /// When the recording is done, the file is moved into its final destination. + /// + public static readonly CVarDef ReplayAutoRecordTempDir = + CVarDef.Create("replay.auto_record_temp_dir", "", CVar.SERVERONLY); } }