From b741d333102db9ccd948610e4125e4bed8929baf Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 2 Nov 2026 13:40:04 -0500 Subject: [PATCH 1/7] fix: loading a round backup no longer records false stats --- .../FiveStack.Services/GameBackUpRounds.cs | 63 ++++---- .../src/FiveStack.Services/MatchEvents.cs | 11 +- .../src/FiveStackServiceCollection.cs | 2 + .../test/RoundRestorePublishTests.cs | 143 ++++++++++++++++++ .../test/RoundRestoreWiringTests.cs | 97 ++++++++++++ .../FiveStack.Services/GameBackUpRounds.cs | 36 ++--- .../src/FiveStack.Services/MatchEvents.cs | 11 +- apps/swiftly/src/FiveStack.cs | 1 + apps/swiftly/test/RoundRestorePublishTests.cs | 143 ++++++++++++++++++ apps/swiftly/test/RoundRestoreWiringTests.cs | 97 ++++++++++++ .../RoundRestoreGateUtility.cs | 18 +++ .../FiveStack.Utilities/RoundRestoreState.cs | 30 ++++ 12 files changed, 603 insertions(+), 49 deletions(-) create mode 100644 apps/counterstrikesharp/test/RoundRestorePublishTests.cs create mode 100644 apps/counterstrikesharp/test/RoundRestoreWiringTests.cs create mode 100644 apps/swiftly/test/RoundRestorePublishTests.cs create mode 100644 apps/swiftly/test/RoundRestoreWiringTests.cs create mode 100644 shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs diff --git a/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs b/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs index dd113fb2..ded71132 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs @@ -13,7 +13,7 @@ namespace FiveStack; public class GameBackUpRounds { - private int? _resetRound; + private readonly RoundRestoreState _restore; // A server that came back behind the backend, holding the round it can // restore until every player is back to vote on it. Meanwhile nothing is @@ -38,9 +38,11 @@ public GameBackUpRounds( MatchService matchService, IServiceProvider serviceProvider, EnvironmentService environmentService, - IStringLocalizer localizer + IStringLocalizer localizer, + RoundRestoreState restore ) { + _restore = restore; _logger = logger; _matchEvents = matchEvents; _gameServer = gameServer; @@ -77,7 +79,7 @@ public void Setup() public bool IsResettingRound() { - return _resetRound != null || _pendingRestore != null; + return _restore.InProgress || _pendingRestore != null; } public bool IsHoldingForRestore() @@ -116,7 +118,7 @@ public void OfferPendingRestore() RequestRestoreBackupRound(round, null, true, recovering: true); // Refused for another reason: keep holding for the next player. - if (_resetRound == null) + if (!_restore.InProgress) { _pendingRestore = round; } @@ -236,7 +238,7 @@ public void RequestRestoreBackupRound( if (player != null || vote == true) { - _resetRound = round; + _restore.StartVote(); restoreRoundVote = _serviceProvider.GetRequiredService(typeof(VoteSystem)) as VoteSystem; @@ -252,7 +254,7 @@ public void RequestRestoreBackupRound( () => { _logger.LogInformation("restore round vote passed"); - _resetRound = null; + _restore.End(); string? refusal = RestoreRefusal(round); if (refusal != null) @@ -279,7 +281,7 @@ public void RequestRestoreBackupRound( _logger.LogInformation("restore round vote failed"); restoreRoundVote = null; - _resetRound = null; + _restore.End(); // Declined after a restart: the map starts again from 0-0 and // the rounds the backend recorded are set aside. @@ -378,7 +380,7 @@ public void SendRestoreRoundToBackend(int round) public void RestoreRound(int round) { - if (_resetRound != null) + if (_restore.InProgress) { _logger.LogWarning($"Restore already in progress, ignoring RestoreRound({round})"); return; @@ -415,34 +417,35 @@ public void RestoreRound(int round) File.WriteAllText(backupRoundFilePath, backupRound.backup_file); _pendingRestore = null; - _resetRound = round; + _restore.StartLoad(); _logger.LogInformation($"Loading backup round file {backupRoundFileName}"); - Server.NextFrame(async () => + Server.NextFrame(() => { - _gameServer.SendCommands([$"mp_backup_restore_load_file {backupRoundFileName}"]); - _matchService.GetCurrentMatch()?.PauseMatch(); + _ = Task.Delay(5 * 1000) + .ContinueWith(_ => + Server.NextFrame(() => + { + _restore.End(); + SetBackupWriting(true); - await Task.Delay(5 * 1000); + _logger.LogInformation($"Sending Message for Round {round}"); - Server.NextFrame(() => - { - _resetRound = null; - SetBackupWriting(true); - - _logger.LogInformation($"Sending Message for Round {round}"); - - _gameServer.Message( - HudDestination.Alert, - _localizer[ - "backup.round_restored", - ChatColors.Red, - round, - CommandUtility.PublicChatTrigger - ] + _gameServer.Message( + HudDestination.Alert, + _localizer[ + "backup.round_restored", + ChatColors.Red, + round, + CommandUtility.PublicChatTrigger + ] + ); + }) ); - }); + + _gameServer.SendCommands([$"mp_backup_restore_load_file {backupRoundFileName}"]); + _matchService.GetCurrentMatch()?.PauseMatch(); }); } @@ -456,7 +459,7 @@ public void RestoreRound(int round) return RoundRestoreGateUtility.Refusal( match != null && !match.options.CanRestoreRounds(), - _resetRound != null, + _restore.InProgress, _matchService.GetCurrentMatch()?.GetCurrentMapStatus() ?? eMapStatus.Unknown, round, matchMap != null diff --git a/apps/counterstrikesharp/src/FiveStack.Services/MatchEvents.cs b/apps/counterstrikesharp/src/FiveStack.Services/MatchEvents.cs index f45f2fe6..a34642b9 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/MatchEvents.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/MatchEvents.cs @@ -46,14 +46,17 @@ EventData> Payload private readonly MatchService _matchService; private readonly EnvironmentService _environmentService; private readonly GameServer _gameServer; + private readonly RoundRestoreState _restore; public MatchEvents( ILogger logger, EnvironmentService environmentService, MatchService matchService, - GameServer gameServer + GameServer gameServer, + RoundRestoreState restore ) { + _restore = restore; _logger = logger; _matchService = matchService; _environmentService = environmentService; @@ -150,6 +153,12 @@ public void PublishMapStatus(eMapStatus status, Guid? winningLineupId) public void PublishGameEvent(string Event, Dictionary Data) { + if (RoundRestoreGateUtility.DroppedDuringLoad(Event, _restore.LoadingBackup)) + { + _logger.LogInformation($"Game event '{Event}' dropped: a backup round is loading"); + return; + } + if (_environmentService.IsOfflineMode()) { return; diff --git a/apps/counterstrikesharp/src/FiveStackServiceCollection.cs b/apps/counterstrikesharp/src/FiveStackServiceCollection.cs index 77957669..acd4d4bc 100644 --- a/apps/counterstrikesharp/src/FiveStackServiceCollection.cs +++ b/apps/counterstrikesharp/src/FiveStackServiceCollection.cs @@ -1,4 +1,5 @@ using CounterStrikeSharp.API.Core; +using FiveStack.Utilities; using Microsoft.Extensions.DependencyInjection; namespace FiveStack; @@ -9,6 +10,7 @@ public void ConfigureServices(IServiceCollection serviceCollection) { serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); diff --git a/apps/counterstrikesharp/test/RoundRestorePublishTests.cs b/apps/counterstrikesharp/test/RoundRestorePublishTests.cs new file mode 100644 index 00000000..72dabe1d --- /dev/null +++ b/apps/counterstrikesharp/test/RoundRestorePublishTests.cs @@ -0,0 +1,143 @@ +using System.Collections.Concurrent; +using System.Reflection; +using System.Runtime.CompilerServices; +using FiveStack; +using FiveStack.Entities; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +// Runs the plugin's own MatchEvents. With no socket a published event stops in +// _pendingMessages, and the publish queue keeps its order, so once the marker +// has arrived everything sent before it has been dealt with. +public class RoundRestorePublishTests : IAsyncLifetime +{ + private const string Marker = "restoreRound"; + + private readonly RoundRestoreState _restore = new(); + private readonly MatchEvents _events; + + public RoundRestorePublishTests() + { + MatchManager match = Bare(); + Set(match, "_matchData", new MatchData { id = Guid.NewGuid() }); + Set(match, "_activeMapId", (Guid?)Guid.NewGuid()); + + MatchService matchService = Bare(); + Set(matchService, "_currentMatch", match); + + _events = new MatchEvents( + NullLogger.Instance, + Bare(), + matchService, + null!, + _restore + ); + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public Task DisposeAsync() + { + return _events.Disconnect(); + } + + private static T Bare() + { + return (T)RuntimeHelpers.GetUninitializedObject(typeof(T)); + } + + private static void Set(object target, string field, object? value) + { + target + .GetType() + .GetField(field, BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(target, value); + } + + private List Published() + { + var pending = (ConcurrentDictionary< + Guid, + (MatchEvents.EventData> Event, DateTime Timestamp) + >) + typeof(MatchEvents) + .GetField("_pendingMessages", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(_events)!; + + return pending.Values.Select(entry => (string)entry.Event.data!["event"]).ToList(); + } + + private async Task> PublishThenSettle(string gameEvent) + { + _events.PublishGameEvent(gameEvent, new Dictionary()); + _events.PublishGameEvent(Marker, new Dictionary()); + + DateTime giveUp = DateTime.UtcNow.AddSeconds(5); + + while (!Published().Contains(Marker)) + { + Assert.True(DateTime.UtcNow < giveUp, "the marker never reached the publish step"); + await Task.Delay(5); + } + + return Published(); + } + + [Theory] + [InlineData("kill")] + [InlineData("assist")] + [InlineData("damage")] + [InlineData("utility")] + [InlineData("flash")] + [InlineData("objective")] + public async Task AStatEventIsNotPublishedWhileABackupLoads(string gameEvent) + { + _restore.StartLoad(); + + Assert.DoesNotContain(gameEvent, await PublishThenSettle(gameEvent)); + } + + [Theory] + [InlineData("kill")] + [InlineData("damage")] + [InlineData("objective")] + public async Task ARestoreVoteKeepsPublishingTheRoundBeingPlayed(string gameEvent) + { + _restore.StartVote(); + + Assert.Contains(gameEvent, await PublishThenSettle(gameEvent)); + } + + [Fact] + public async Task AKillIsPublishedOnceTheLoadHasEnded() + { + _restore.StartLoad(); + _restore.End(); + + Assert.Contains("kill", await PublishThenSettle("kill")); + } + + [Fact] + public async Task ALoadAfterAPassedVoteStopsPublishing() + { + _restore.StartVote(); + _restore.End(); + _restore.StartLoad(); + + Assert.DoesNotContain("kill", await PublishThenSettle("kill")); + } + + [Theory] + [InlineData("score")] + [InlineData("mapStatus")] + public async Task ALoadLeavesEventsThatAreNotRoundStatsAlone(string gameEvent) + { + _restore.StartLoad(); + + Assert.Contains(gameEvent, await PublishThenSettle(gameEvent)); + } +} diff --git a/apps/counterstrikesharp/test/RoundRestoreWiringTests.cs b/apps/counterstrikesharp/test/RoundRestoreWiringTests.cs new file mode 100644 index 00000000..dfa9c595 --- /dev/null +++ b/apps/counterstrikesharp/test/RoundRestoreWiringTests.cs @@ -0,0 +1,97 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using Xunit; + +// RoundRestorePublishTests hands MatchEvents a state of its own, so it cannot +// see whether the plugin wires the same one in or calls it from the right place. +// That code needs the engine, so these read it. +public class RoundRestoreWiringTests +{ + private static readonly string[] BuildOutput = ["bin", "obj", "build"]; + + private static string SourceDir([CallerFilePath] string testFile = "") + { + return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(testFile)!, "..", "src")); + } + + private static string Strip(string source) + { + return Regex.Replace(source, @"//[^\n]*", ""); + } + + private static string Service(string file) + { + return Strip(File.ReadAllText(Path.Combine(SourceDir(), "FiveStack.Services", file))); + } + + private static string Method(string source, string signature) + { + int from = source.IndexOf(signature, StringComparison.Ordinal); + + Assert.True(from >= 0, $"{signature} is gone"); + + int to = source.IndexOf("\n }\n", from, StringComparison.Ordinal); + + Assert.True(to > from, $"the end of {signature} was not found"); + + return source[from..to]; + } + + [Fact] + public void ThePluginHasOneRestoreState() + { + IEnumerable sources = Directory + .EnumerateFiles(SourceDir(), "*.cs", SearchOption.AllDirectories) + .Where(file => + !Path.GetRelativePath(SourceDir(), file) + .Split(Path.DirectorySeparatorChar) + .Any(BuildOutput.Contains) + ) + .Select(file => Strip(File.ReadAllText(file))); + + Assert.Single(sources, source => source.Contains("AddSingleton()")); + } + + [Theory] + [InlineData("MatchEvents.cs", "public MatchEvents(")] + [InlineData("GameBackUpRounds.cs", "public GameBackUpRounds(")] + public void BothSidesAreHandedThatState(string file, string constructor) + { + Assert.Contains("RoundRestoreState restore", Method(Service(file), constructor)); + } + + [Fact] + public void ALoadIsMarkedAsALoad() + { + string restore = Method(Service("GameBackUpRounds.cs"), "public void RestoreRound(int round)"); + + Assert.Contains("_restore.StartLoad(", restore); + Assert.DoesNotContain("_restore.StartVote(", restore); + } + + [Fact] + public void AVoteIsNotMarkedAsALoad() + { + string request = Method( + Service("GameBackUpRounds.cs"), + "public void RequestRestoreBackupRound(" + ); + + Assert.Contains("_restore.StartVote(", request); + Assert.DoesNotContain("_restore.StartLoad(", request); + } + + [Fact] + public void TheFilterComesBeforeAnythingElseInThePublish() + { + string publish = Method(Service("MatchEvents.cs"), "public void PublishGameEvent("); + int filter = publish.IndexOf( + "RoundRestoreGateUtility.DroppedDuringLoad(Event, _restore.LoadingBackup)", + StringComparison.Ordinal + ); + int offline = publish.IndexOf("IsOfflineMode", StringComparison.Ordinal); + + Assert.True(filter >= 0, "PublishGameEvent does not filter"); + Assert.True(filter < offline, "the filter runs after the first way out"); + } +} diff --git a/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs b/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs index acd3d752..ebf2b857 100644 --- a/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs +++ b/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs @@ -13,7 +13,7 @@ namespace FiveStack; public class GameBackUpRounds { - private int? _resetRound; + private readonly RoundRestoreState _restore; // A server that came back behind the backend, holding the round it can // restore until every player is back to vote on it. Meanwhile nothing is @@ -40,9 +40,11 @@ public GameBackUpRounds( MatchService matchService, IServiceProvider serviceProvider, EnvironmentService environmentService, - ILocalizer localizer + ILocalizer localizer, + RoundRestoreState restore ) { + _restore = restore; _core = core; _logger = logger; _matchEvents = matchEvents; @@ -133,7 +135,7 @@ public void Setup() public bool IsResettingRound() { - return _resetRound != null || _pendingRestore != null; + return _restore.InProgress || _pendingRestore != null; } public bool IsHoldingForRestore() @@ -175,7 +177,7 @@ public void OfferPendingRestore() RequestRestoreBackupRound(round, null, true, recovering: true); // Refused for another reason: keep holding for the next player. - if (_resetRound == null) + if (!_restore.InProgress) { _pendingRestore = round; } @@ -433,7 +435,7 @@ public void RequestRestoreBackupRound( if (player != null || vote == true) { - _resetRound = round; + _restore.StartVote(); restoreRoundVote = _serviceProvider.GetRequiredService(typeof(VoteSystem)) as VoteSystem; @@ -449,7 +451,7 @@ public void RequestRestoreBackupRound( () => { _logger.LogInformation("restore round vote passed"); - _resetRound = null; + _restore.End(); string? refusal = RestoreRefusal(round); if (refusal != null) @@ -476,7 +478,7 @@ public void RequestRestoreBackupRound( _logger.LogInformation("restore round vote failed"); restoreRoundVote = null; - _resetRound = null; + _restore.End(); // Declined after a restart: the map starts again from 0-0 and // the rounds the backend recorded are set aside. @@ -611,7 +613,7 @@ public void SendRestoreRoundToBackend(int round) public void RestoreRound(int round) { - if (_resetRound != null) + if (_restore.InProgress) { _logger.LogWarning($"Restore already in progress, ignoring RestoreRound({round})"); return; @@ -657,7 +659,7 @@ public void RestoreRound(int round) backupRoundFileName ); - _resetRound = round; + _restore.StartLoad(); _logger.LogInformation($"Loading backup round file {backupRoundFileName}"); @@ -676,22 +678,17 @@ public void RestoreRound(int round) "Failed writing restore backup round file {File}", backupRoundFileName ); - _core.Scheduler.NextTick(() => _resetRound = null); + _core.Scheduler.NextTick(() => _restore.End()); return; } _core.Scheduler.NextTick(() => { - _gameServer.SendCommands( - [$"mp_backup_restore_load_file {backupRoundFileName}"] - ); - _matchService.GetCurrentMatch()?.PauseMatch(); - TimerUtility.AddTimer( 5, () => { - _resetRound = null; + _restore.End(); SetBackupWriting(true); _logger.LogInformation($"Sending Message for Round {round}"); @@ -707,6 +704,11 @@ public void RestoreRound(int round) ); } ); + + _gameServer.SendCommands( + [$"mp_backup_restore_load_file {backupRoundFileName}"] + ); + _matchService.GetCurrentMatch()?.PauseMatch(); }); }); } @@ -721,7 +723,7 @@ public void RestoreRound(int round) return RoundRestoreGateUtility.Refusal( match != null && !match.options.CanRestoreRounds(), - _resetRound != null, + _restore.InProgress, _matchService.GetCurrentMatch()?.GetCurrentMapStatus() ?? eMapStatus.Unknown, round, matchMap != null diff --git a/apps/swiftly/src/FiveStack.Services/MatchEvents.cs b/apps/swiftly/src/FiveStack.Services/MatchEvents.cs index 6fc615ad..6efca4ac 100644 --- a/apps/swiftly/src/FiveStack.Services/MatchEvents.cs +++ b/apps/swiftly/src/FiveStack.Services/MatchEvents.cs @@ -46,14 +46,17 @@ EventData> Payload private readonly MatchService _matchService; private readonly EnvironmentService _environmentService; private readonly GameServer _gameServer; + private readonly RoundRestoreState _restore; public MatchEvents( ILogger logger, EnvironmentService environmentService, MatchService matchService, - GameServer gameServer + GameServer gameServer, + RoundRestoreState restore ) { + _restore = restore; _logger = logger; _matchService = matchService; _environmentService = environmentService; @@ -156,6 +159,12 @@ public void PublishMapStatus(eMapStatus status, Guid? winningLineupId) public void PublishGameEvent(string Event, Dictionary Data) { + if (RoundRestoreGateUtility.DroppedDuringLoad(Event, _restore.LoadingBackup)) + { + _logger.LogInformation($"Game event '{Event}' dropped: a backup round is loading"); + return; + } + if (_environmentService.IsOfflineMode()) { _logger.LogWarning($"Game event '{Event}' dropped: offline mode"); diff --git a/apps/swiftly/src/FiveStack.cs b/apps/swiftly/src/FiveStack.cs index 5a9b8975..6eeb1ef1 100644 --- a/apps/swiftly/src/FiveStack.cs +++ b/apps/swiftly/src/FiveStack.cs @@ -79,6 +79,7 @@ public override void Load(bool hotReload) .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/apps/swiftly/test/RoundRestorePublishTests.cs b/apps/swiftly/test/RoundRestorePublishTests.cs new file mode 100644 index 00000000..72dabe1d --- /dev/null +++ b/apps/swiftly/test/RoundRestorePublishTests.cs @@ -0,0 +1,143 @@ +using System.Collections.Concurrent; +using System.Reflection; +using System.Runtime.CompilerServices; +using FiveStack; +using FiveStack.Entities; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +// Runs the plugin's own MatchEvents. With no socket a published event stops in +// _pendingMessages, and the publish queue keeps its order, so once the marker +// has arrived everything sent before it has been dealt with. +public class RoundRestorePublishTests : IAsyncLifetime +{ + private const string Marker = "restoreRound"; + + private readonly RoundRestoreState _restore = new(); + private readonly MatchEvents _events; + + public RoundRestorePublishTests() + { + MatchManager match = Bare(); + Set(match, "_matchData", new MatchData { id = Guid.NewGuid() }); + Set(match, "_activeMapId", (Guid?)Guid.NewGuid()); + + MatchService matchService = Bare(); + Set(matchService, "_currentMatch", match); + + _events = new MatchEvents( + NullLogger.Instance, + Bare(), + matchService, + null!, + _restore + ); + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public Task DisposeAsync() + { + return _events.Disconnect(); + } + + private static T Bare() + { + return (T)RuntimeHelpers.GetUninitializedObject(typeof(T)); + } + + private static void Set(object target, string field, object? value) + { + target + .GetType() + .GetField(field, BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(target, value); + } + + private List Published() + { + var pending = (ConcurrentDictionary< + Guid, + (MatchEvents.EventData> Event, DateTime Timestamp) + >) + typeof(MatchEvents) + .GetField("_pendingMessages", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(_events)!; + + return pending.Values.Select(entry => (string)entry.Event.data!["event"]).ToList(); + } + + private async Task> PublishThenSettle(string gameEvent) + { + _events.PublishGameEvent(gameEvent, new Dictionary()); + _events.PublishGameEvent(Marker, new Dictionary()); + + DateTime giveUp = DateTime.UtcNow.AddSeconds(5); + + while (!Published().Contains(Marker)) + { + Assert.True(DateTime.UtcNow < giveUp, "the marker never reached the publish step"); + await Task.Delay(5); + } + + return Published(); + } + + [Theory] + [InlineData("kill")] + [InlineData("assist")] + [InlineData("damage")] + [InlineData("utility")] + [InlineData("flash")] + [InlineData("objective")] + public async Task AStatEventIsNotPublishedWhileABackupLoads(string gameEvent) + { + _restore.StartLoad(); + + Assert.DoesNotContain(gameEvent, await PublishThenSettle(gameEvent)); + } + + [Theory] + [InlineData("kill")] + [InlineData("damage")] + [InlineData("objective")] + public async Task ARestoreVoteKeepsPublishingTheRoundBeingPlayed(string gameEvent) + { + _restore.StartVote(); + + Assert.Contains(gameEvent, await PublishThenSettle(gameEvent)); + } + + [Fact] + public async Task AKillIsPublishedOnceTheLoadHasEnded() + { + _restore.StartLoad(); + _restore.End(); + + Assert.Contains("kill", await PublishThenSettle("kill")); + } + + [Fact] + public async Task ALoadAfterAPassedVoteStopsPublishing() + { + _restore.StartVote(); + _restore.End(); + _restore.StartLoad(); + + Assert.DoesNotContain("kill", await PublishThenSettle("kill")); + } + + [Theory] + [InlineData("score")] + [InlineData("mapStatus")] + public async Task ALoadLeavesEventsThatAreNotRoundStatsAlone(string gameEvent) + { + _restore.StartLoad(); + + Assert.Contains(gameEvent, await PublishThenSettle(gameEvent)); + } +} diff --git a/apps/swiftly/test/RoundRestoreWiringTests.cs b/apps/swiftly/test/RoundRestoreWiringTests.cs new file mode 100644 index 00000000..dfa9c595 --- /dev/null +++ b/apps/swiftly/test/RoundRestoreWiringTests.cs @@ -0,0 +1,97 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using Xunit; + +// RoundRestorePublishTests hands MatchEvents a state of its own, so it cannot +// see whether the plugin wires the same one in or calls it from the right place. +// That code needs the engine, so these read it. +public class RoundRestoreWiringTests +{ + private static readonly string[] BuildOutput = ["bin", "obj", "build"]; + + private static string SourceDir([CallerFilePath] string testFile = "") + { + return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(testFile)!, "..", "src")); + } + + private static string Strip(string source) + { + return Regex.Replace(source, @"//[^\n]*", ""); + } + + private static string Service(string file) + { + return Strip(File.ReadAllText(Path.Combine(SourceDir(), "FiveStack.Services", file))); + } + + private static string Method(string source, string signature) + { + int from = source.IndexOf(signature, StringComparison.Ordinal); + + Assert.True(from >= 0, $"{signature} is gone"); + + int to = source.IndexOf("\n }\n", from, StringComparison.Ordinal); + + Assert.True(to > from, $"the end of {signature} was not found"); + + return source[from..to]; + } + + [Fact] + public void ThePluginHasOneRestoreState() + { + IEnumerable sources = Directory + .EnumerateFiles(SourceDir(), "*.cs", SearchOption.AllDirectories) + .Where(file => + !Path.GetRelativePath(SourceDir(), file) + .Split(Path.DirectorySeparatorChar) + .Any(BuildOutput.Contains) + ) + .Select(file => Strip(File.ReadAllText(file))); + + Assert.Single(sources, source => source.Contains("AddSingleton()")); + } + + [Theory] + [InlineData("MatchEvents.cs", "public MatchEvents(")] + [InlineData("GameBackUpRounds.cs", "public GameBackUpRounds(")] + public void BothSidesAreHandedThatState(string file, string constructor) + { + Assert.Contains("RoundRestoreState restore", Method(Service(file), constructor)); + } + + [Fact] + public void ALoadIsMarkedAsALoad() + { + string restore = Method(Service("GameBackUpRounds.cs"), "public void RestoreRound(int round)"); + + Assert.Contains("_restore.StartLoad(", restore); + Assert.DoesNotContain("_restore.StartVote(", restore); + } + + [Fact] + public void AVoteIsNotMarkedAsALoad() + { + string request = Method( + Service("GameBackUpRounds.cs"), + "public void RequestRestoreBackupRound(" + ); + + Assert.Contains("_restore.StartVote(", request); + Assert.DoesNotContain("_restore.StartLoad(", request); + } + + [Fact] + public void TheFilterComesBeforeAnythingElseInThePublish() + { + string publish = Method(Service("MatchEvents.cs"), "public void PublishGameEvent("); + int filter = publish.IndexOf( + "RoundRestoreGateUtility.DroppedDuringLoad(Event, _restore.LoadingBackup)", + StringComparison.Ordinal + ); + int offline = publish.IndexOf("IsOfflineMode", StringComparison.Ordinal); + + Assert.True(filter >= 0, "PublishGameEvent does not filter"); + Assert.True(filter < offline, "the filter runs after the first way out"); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs b/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs index 9e185c2b..1936f35d 100644 --- a/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs @@ -58,6 +58,24 @@ string matchType return null; } + // Every stat row the api keeps for a round comes from one of these. A + // backup load outlasts what the api clears when it restores a round, so + // whatever the engine reports while it runs would stay in the stats. + private static readonly HashSet RoundStatEvents = new() + { + "kill", + "assist", + "damage", + "utility", + "flash", + "objective", + }; + + public static bool DroppedDuringLoad(string gameEvent, bool loadingBackup) + { + return loadingBackup && RoundStatEvents.Contains(gameEvent); + } + // A map's backups are appended as rounds are played, so a round played // again after a restore is listed twice and the newer backup is the one // that matches what was replayed. A round the api marked deleted was diff --git a/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs b/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs new file mode 100644 index 00000000..fd436ec4 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs @@ -0,0 +1,30 @@ +namespace FiveStack.Utilities +{ + // A restore vote leaves the round running, because mp_pause_match waits for the + // next freezetime, so its kills are real. A backup load outlasts what the api + // clears when it restores a round, so anything reported then would stay in the + // stats. + public class RoundRestoreState + { + public bool InProgress { get; private set; } + + public bool LoadingBackup { get; private set; } + + public void StartVote() + { + InProgress = true; + } + + public void StartLoad() + { + InProgress = true; + LoadingBackup = true; + } + + public void End() + { + InProgress = false; + LoadingBackup = false; + } + } +} From 2f20bb1c237953861d8eda228460af59c2997f40 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 2 Nov 2026 16:56:45 -0500 Subject: [PATCH 2/7] fix: a passed recovery vote keeps the match held until its round is loaded --- .../src/FiveStack.Commands/Match.cs | 2 + .../FiveStack.Services/GameBackUpRounds.cs | 130 +++++++- .../src/FiveStack.Services/MatchManager.cs | 7 +- .../src/FiveStack.Services/VoteSystem.cs | 15 + .../test/RestoreVoteTests.cs | 49 +++ .../test/RestoreWiringTests.cs | 206 ++++++++++++ .../test/RoundRestoreStateTests.cs | 296 ++++++++++++++++++ apps/swiftly/src/FiveStack.Commands/Match.cs | 2 + .../FiveStack.Services/GameBackUpRounds.cs | 134 ++++++-- .../src/FiveStack.Services/MatchManager.cs | 7 +- .../src/FiveStack.Services/VoteSystem.cs | 15 + .../src/FiveStack.Utilities/TimerUtility.cs | 8 +- apps/swiftly/test/OpenVoteTests.cs | 55 ++++ apps/swiftly/test/RestoreVoteTests.cs | 49 +++ apps/swiftly/test/RestoreWiringTests.cs | 206 ++++++++++++ apps/swiftly/test/RoundRestoreStateTests.cs | 296 ++++++++++++++++++ .../RoundRestoreGateUtility.cs | 14 + .../FiveStack.Utilities/RoundRestoreState.cs | 101 +++++- 18 files changed, 1536 insertions(+), 56 deletions(-) create mode 100644 apps/counterstrikesharp/test/RestoreVoteTests.cs create mode 100644 apps/counterstrikesharp/test/RestoreWiringTests.cs create mode 100644 apps/counterstrikesharp/test/RoundRestoreStateTests.cs create mode 100644 apps/swiftly/test/OpenVoteTests.cs create mode 100644 apps/swiftly/test/RestoreVoteTests.cs create mode 100644 apps/swiftly/test/RestoreWiringTests.cs create mode 100644 apps/swiftly/test/RoundRestoreStateTests.cs diff --git a/apps/counterstrikesharp/src/FiveStack.Commands/Match.cs b/apps/counterstrikesharp/src/FiveStack.Commands/Match.cs index 3e653012..e2bde509 100644 --- a/apps/counterstrikesharp/src/FiveStack.Commands/Match.cs +++ b/apps/counterstrikesharp/src/FiveStack.Commands/Match.cs @@ -35,6 +35,8 @@ public void SetMatchState(CCSPlayerController? player, CommandInfo command) ); return; } + + _gameBackupRounds.AbandonRestore(); } match?.UpdateMapStatus(status); diff --git a/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs b/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs index ded71132..e79157fe 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs @@ -15,10 +15,6 @@ public class GameBackUpRounds { private readonly RoundRestoreState _restore; - // A server that came back behind the backend, holding the round it can - // restore until every player is back to vote on it. Meanwhile nothing is - // played, published or written over the backups being restored. - private int? _pendingRestore; private readonly MatchEvents _matchEvents; private readonly GameServer _gameServer; private readonly MatchService _matchService; @@ -79,12 +75,12 @@ public void Setup() public bool IsResettingRound() { - return _restore.InProgress || _pendingRestore != null; + return _restore.Resetting; } public bool IsHoldingForRestore() { - return _pendingRestore != null; + return _restore.HoldingRounds; } public void SetBackupWriting(bool enabled) @@ -96,12 +92,12 @@ public void SetBackupWriting(bool enabled) // player is back. Called when the match loads and as players connect. public void OfferPendingRestore() { - if (_pendingRestore == null || restoreRoundVote != null) + if (!_restore.CanOfferVote || restoreRoundVote != null) { return; } - int round = _pendingRestore.Value; + int round = _restore.HeldRound!.Value; int connectedPlayers = MatchUtility.Players().Count; int expectedPlayers = _matchService.GetCurrentMatch()?.GetExpectedPlayerCount() ?? 10; @@ -114,13 +110,13 @@ public void OfferPendingRestore() return; } - _pendingRestore = null; + _restore.ReleaseHold(); RequestRestoreBackupRound(round, null, true, recovering: true); // Refused for another reason: keep holding for the next player. if (!_restore.InProgress) { - _pendingRestore = round; + _restore.Hold(round); } } @@ -164,12 +160,95 @@ public bool CheckForBackupRestore(eMapStatus status) _logger.LogInformation( $"Backup round {highestNumber} is ahead of current round {totalRoundsPlayed}, holding the match for a restore vote" ); - _pendingRestore = highestNumber; + HoldForRestore(highestNumber); + + return false; + } + + private void HoldForRestore(int round) + { + _restore.Hold(round); SetBackupWriting(false); _gameServer.SendCommands(["mp_pause_match"]); OfferPendingRestore(); + } - return false; + // A load that is owed and not coming goes back to the hold, and never to a + // replay: only players deciding that gives up rounds the backend recorded. + private void ReturnToHold(int round) + { + eMapStatus status = + _matchService.GetCurrentMatch()?.GetCurrentMapStatus() ?? eMapStatus.Unknown; + + if (!RoundRestoreGateUtility.IsInPlay(status)) + { + SetBackupWriting(!IsHoldingForRestore()); + return; + } + + _logger.LogWarning($"Round {round} was not restored, holding the match for another vote"); + HoldForRestore(round); + } + + private void ReturnOwedToHold() + { + if (_restore.FailedLoad() is int round) + { + ReturnToHold(round); + } + } + + private Guid? ActiveMapId() + { + MatchManager? matchManager = _matchService.GetCurrentMatch(); + + return matchManager?.GetActiveMapId() + ?? matchManager?.GetMatchData()?.current_match_map_id; + } + + private void OweLoad(int round) + { + int owing = _restore.StartOwing(round, ActiveMapId()); + + TimerUtility.AddTimer(RoundRestoreState.OwedSeconds, () => OwedLoadTimedOut(owing)); + } + + private void OwedLoadTimedOut(int owing) + { + OwedLoadTimeout timeout = _restore.TimedOut(owing, ActiveMapId()); + + if (!timeout.Expired) + { + return; + } + + if (timeout.HoldRound is int round) + { + ReturnToHold(round); + return; + } + + SetBackupWriting(!IsHoldingForRestore()); + } + + public void TellWhyResumeWaits() + { + string? refusal = RoundRestoreGateUtility.ResumeRefusal(_restore.OwedRound); + + if (refusal != null) + { + _gameServer.Message(HudDestination.Alert, refusal); + } + } + + // Restart Map starts the map over, so whatever a restore was protecting is + // gone and the restore with it. + public void AbandonRestore() + { + restoreRoundVote?.Abandon(); + restoreRoundVote = null; + _restore.Abandon(); + SetBackupWriting(true); } // A match whose rounds can't be restored replays the map from warmup, but @@ -255,6 +334,7 @@ public void RequestRestoreBackupRound( { _logger.LogInformation("restore round vote passed"); _restore.End(); + restoreRoundVote = null; string? refusal = RestoreRefusal(round); if (refusal != null) @@ -266,14 +346,27 @@ public void RequestRestoreBackupRound( HudDestination.Alert, $" Restore round blocked: {refusal}." ); - restoreRoundVote = null; if (recovering) { - _pendingRestore = round; + _restore.Hold(round); } return; } + if ( + RoundRestoreGateUtility.OwesLoad( + recovering, + _environmentService.IsOfflineMode() + ) + ) + { + OweLoad(round); + } + else if (recovering) + { + SetBackupWriting(true); + } + SendRestoreRoundToBackend(round); }, () => @@ -380,7 +473,7 @@ public void SendRestoreRoundToBackend(int round) public void RestoreRound(int round) { - if (_restore.InProgress) + if (_restore.VotingOrLoading) { _logger.LogWarning($"Restore already in progress, ignoring RestoreRound({round})"); return; @@ -388,6 +481,7 @@ public void RestoreRound(int round) if (!CanRestoreRound(round)) { + ReturnOwedToHold(); return; } @@ -396,6 +490,7 @@ public void RestoreRound(int round) if (match == null || matchMap == null) { + ReturnOwedToHold(); return; } @@ -404,6 +499,7 @@ public void RestoreRound(int round) if (backupRound == null) { _logger.LogWarning($"missing backup round: {round}"); + ReturnOwedToHold(); return; } @@ -416,7 +512,7 @@ public void RestoreRound(int round) File.WriteAllText(backupRoundFilePath, backupRound.backup_file); - _pendingRestore = null; + _restore.ReleaseHold(); _restore.StartLoad(); _logger.LogInformation($"Loading backup round file {backupRoundFileName}"); @@ -459,7 +555,7 @@ public void RestoreRound(int round) return RoundRestoreGateUtility.Refusal( match != null && !match.options.CanRestoreRounds(), - _restore.InProgress, + _restore.VotingOrLoading, _matchService.GetCurrentMatch()?.GetCurrentMapStatus() ?? eMapStatus.Unknown, round, matchMap != null diff --git a/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs b/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs index 0b970f07..1d125b92 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs @@ -305,6 +305,7 @@ public void ResumeMatch(string? message = null, bool skipUpdate = false) if (_backUpManagement.IsResettingRound()) { _logger.LogInformation("Resetting round, cannot resume match"); + _backUpManagement.TellWhyResumeWaits(); return; } @@ -318,12 +319,6 @@ public void ResumeMatch(string? message = null, bool skipUpdate = false) _timeoutSystem.resumeVote = null; } - if (_backUpManagement.restoreRoundVote != null) - { - _backUpManagement.restoreRoundVote.CancelVote(); - _backUpManagement.restoreRoundVote = null; - } - _timeoutSystem.ClearPendingTeamResumes(); if (!IsPaused()) diff --git a/apps/counterstrikesharp/src/FiveStack.Services/VoteSystem.cs b/apps/counterstrikesharp/src/FiveStack.Services/VoteSystem.cs index 75200b4d..65bbf57e 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/VoteSystem.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/VoteSystem.cs @@ -90,8 +90,23 @@ public void StartVote( SendVoteMessage(); } + // Over with no outcome: neither callback runs. + public void Abandon() + { + _voteFailedCallback = null; + _voteSuccessCallback = null; + CancelVote(); + } + public void CancelVote() { + // The failure callback can replay a map from 0-0, so a vote that has + // already passed must not reach it. + if (!IsVoteActive()) + { + return; + } + _voteStartTime = null; _voteTimeout = null; KillTimers(); diff --git a/apps/counterstrikesharp/test/RestoreVoteTests.cs b/apps/counterstrikesharp/test/RestoreVoteTests.cs new file mode 100644 index 00000000..397bd3d0 --- /dev/null +++ b/apps/counterstrikesharp/test/RestoreVoteTests.cs @@ -0,0 +1,49 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using FiveStack; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +// A recovery vote's failure callback replays the map from 0-0, which sets every +// round the backend recorded aside. It must only ever run for a vote that lost. +public class RestoreVoteTests +{ + private int _failures; + private int _passes; + + private static void Set(object target, string field, object? value) + { + target + .GetType() + .GetField(field, BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(target, value); + } + + private VoteSystem FinishedVote() + { + VoteSystem vote = (VoteSystem)RuntimeHelpers.GetUninitializedObject(typeof(VoteSystem)); + + Set(vote, "_logger", NullLogger.Instance); + Set(vote, "_voteFailedCallback", (Action)(() => _failures++)); + Set(vote, "_voteSuccessCallback", (Action)(() => _passes++)); + + return vote; + } + + [Fact] + public void AFinishedVoteCannotBeCancelledIntoItsFailure() + { + FinishedVote().CancelVote(); + + Assert.Equal(0, _failures); + } + + [Fact] + public void AbandoningAFinishedVoteRunsNoCallback() + { + FinishedVote().Abandon(); + + Assert.Equal(0, _failures); + Assert.Equal(0, _passes); + } +} diff --git a/apps/counterstrikesharp/test/RestoreWiringTests.cs b/apps/counterstrikesharp/test/RestoreWiringTests.cs new file mode 100644 index 00000000..9d0dc4b5 --- /dev/null +++ b/apps/counterstrikesharp/test/RestoreWiringTests.cs @@ -0,0 +1,206 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using Xunit; + +// What the state decides is tested in RoundRestoreStateTests. Calling it needs +// the engine, so these read the plugin. +public class RestoreWiringTests +{ + private static string SourceDir([CallerFilePath] string testFile = "") + { + return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(testFile)!, "..", "src")); + } + + private static string Code(params string[] path) + { + return Regex.Replace( + File.ReadAllText(Path.Combine(new[] { SourceDir() }.Concat(path).ToArray())), + @"//[^\n]*", + "" + ); + } + + private static string Backups() + { + return Code("FiveStack.Services", "GameBackUpRounds.cs"); + } + + private static string Method(string source, string signature) + { + int from = source.IndexOf(signature, StringComparison.Ordinal); + + Assert.True(from >= 0, $"{signature} is gone"); + + int to = source.IndexOf("\n }\n", from, StringComparison.Ordinal); + + Assert.True(to > from, $"the end of {signature} was not found"); + + return source[from..to]; + } + + private static string Between(string source, string start, string end) + { + int from = source.IndexOf(start, StringComparison.Ordinal); + int to = source.IndexOf(end, from + 1, StringComparison.Ordinal); + + Assert.True(from >= 0 && to > from, $"{start} has moved"); + + return source[from..to]; + } + + private static string PassedVote() + { + return Between( + Method(Backups(), "public void RequestRestoreBackupRound("), + "\"restore round vote passed\"", + "\"restore round vote failed\"" + ); + } + + [Fact] + public void AVoteIsLetGoAsSoonAsItPasses() + { + string passed = PassedVote(); + + Assert.Contains("restoreRoundVote = null;", Between(passed, "passed", "RestoreRefusal(round)")); + } + + [Fact] + public void APassedVoteAsksWhetherItOwesALoadBeforeSendingIt() + { + string passed = PassedVote(); + Match owes = Regex.Match( + passed, + @"RoundRestoreGateUtility\.OwesLoad\(\s*recovering,\s*_environmentService\.IsOfflineMode\(\)\s*\)" + ); + int sent = passed.IndexOf("SendRestoreRoundToBackend(round);", StringComparison.Ordinal); + + Assert.True(owes.Success, "a passed vote does not ask"); + Assert.True(sent > owes.Index, "the answer can beat the debt"); + } + + [Fact] + public void AnOfflineRecoveryTurnsBackupsBackOnAndDeletesNothing() + { + string passed = PassedVote(); + + Assert.Contains("SetBackupWriting(true);", passed); + Assert.DoesNotContain("ReplayMap", passed); + } + + [Fact] + public void OnlyADeclinedVoteReplaysTheMap() + { + string vote = Method(Backups(), "public void RequestRestoreBackupRound("); + string declined = vote[vote.IndexOf("\"restore round vote failed\"", StringComparison.Ordinal)..]; + + Assert.Contains("ReplayMap(", declined); + Assert.DoesNotContain("ReplayMap", Method(Backups(), "private void OwedLoadTimedOut(int owing)")); + Assert.DoesNotContain("ReplayMap", Method(Backups(), "private void ReturnToHold(int round)")); + Assert.DoesNotContain("ReplayMap", Method(Backups(), "public void RestoreRound(int round)")); + } + + [Fact] + public void AnOwedLoadIsTimedFromTheMomentItIsOwed() + { + string owe = Method(Backups(), "private void OweLoad(int round)"); + + Assert.Contains("_restore.StartOwing(round, ActiveMapId())", owe); + Assert.Contains("RoundRestoreState.OwedSeconds", owe); + } + + [Fact] + public void TheAnswerIsNotGatedOnWhatWasAskedFor() + { + string restore = Method(Backups(), "public void RestoreRound(int round)"); + + Assert.Contains("if (_restore.VotingOrLoading)", restore); + Assert.DoesNotContain("round == 0", restore); + } + + [Fact] + public void EveryWayOutOfRestoreRoundSettlesAnOwedLoad() + { + string restore = Method(Backups(), "public void RestoreRound(int round)"); + string guarded = Between(restore, "CanRestoreRound(round)", "_restore.StartLoad("); + + MatchCollection exits = Regex.Matches(guarded, @"[^\n]*\n\s*return;"); + + Assert.Equal(3, exits.Count); + Assert.All(exits.Select(exit => exit.Value), exit => Assert.Contains("ReturnOwedToHold();", exit)); + } + + [Fact] + public void AHoldOnlyComesBackOnAMapStillBeingPlayed() + { + string hold = Method(Backups(), "private void ReturnToHold(int round)"); + int played = hold.IndexOf("RoundRestoreGateUtility.IsInPlay(", StringComparison.Ordinal); + int held = hold.IndexOf("HoldForRestore(round);", StringComparison.Ordinal); + + Assert.True(played >= 0 && held > played, "a hold can come back on a map that is over"); + } + + [Fact] + public void AnOwedLoadOnAnotherMapTurnsBackupsBackOnIfNothingHolds() + { + Assert.Contains( + "SetBackupWriting(!IsHoldingForRestore());", + Method(Backups(), "private void OwedLoadTimedOut(int owing)") + ); + } + + [Fact] + public void AHoldIsOnlyOfferedThroughTheState() + { + string offer = Method(Backups(), "public void OfferPendingRestore()"); + int asked = offer.IndexOf("_restore.CanOfferVote", StringComparison.Ordinal); + int taken = offer.IndexOf("_restore.ReleaseHold()", StringComparison.Ordinal); + + Assert.True(asked >= 0 && taken > asked, "a hold can be taken during a load"); + } + + [Fact] + public void RestartMapAbandonsTheRestore() + { + string abandon = Method(Backups(), "public void AbandonRestore()"); + + Assert.Contains("_restore.Abandon();", abandon); + Assert.Contains("SetBackupWriting(true);", abandon); + Assert.Contains(".Abandon();", abandon.Replace("_restore.Abandon();", "")); + Assert.DoesNotContain("CancelVote", abandon); + } + + [Fact] + public void RestartMapOnlyAbandonsOnceItWasAccepted() + { + string state = Between(Code("FiveStack.Commands", "Match.cs"), "SetMatchState(", "match?.UpdateMapStatus(status);"); + int refused = state.IndexOf("refusal != null", StringComparison.Ordinal); + int abandoned = state.IndexOf("_gameBackupRounds.AbandonRestore();", StringComparison.Ordinal); + + Assert.True(abandoned > refused, "a refused restart can drop a hold"); + } + + [Fact] + public void ResumingNoLongerReachesARestoreVote() + { + Assert.DoesNotContain("restoreRoundVote", Code("FiveStack.Services", "MatchManager.cs")); + } + + [Fact] + public void ResumeSaysWhyItIsWaiting() + { + string resume = Method(Code("FiveStack.Services", "MatchManager.cs"), "public void ResumeMatch("); + + Assert.Contains("TellWhyResumeWaits();", Between(resume, "IsResettingRound()", "return;")); + } + + [Fact] + public void AnOpenVoteStillRunsItsFailure() + { + string cancel = Method(Code("FiveStack.Services", "VoteSystem.cs"), "public void CancelVote()"); + int guarded = cancel.IndexOf("if (!IsVoteActive())", StringComparison.Ordinal); + int failed = cancel.IndexOf("_voteFailedCallback?.Invoke();", StringComparison.Ordinal); + + Assert.True(guarded >= 0 && failed > guarded, "CancelVote no longer fails an open vote"); + } +} diff --git a/apps/counterstrikesharp/test/RoundRestoreStateTests.cs b/apps/counterstrikesharp/test/RoundRestoreStateTests.cs new file mode 100644 index 00000000..36e0589c --- /dev/null +++ b/apps/counterstrikesharp/test/RoundRestoreStateTests.cs @@ -0,0 +1,296 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using FiveStack; +using FiveStack.Entities; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +// One debt: a recovery vote passed and the server is owed its load. Nothing else +// about a restore changes, so .reset, organizer restores and declined votes are +// pinned here as well as the new paths. +public class RoundRestoreStateTests +{ + private const int Round = 8; + + private static readonly Guid Map = Guid.NewGuid(); + private static readonly Guid OtherMap = Guid.NewGuid(); + + private readonly RoundRestoreState _state = new(); + + private GameBackUpRounds Backups() + { + GameBackUpRounds backups = (GameBackUpRounds) + RuntimeHelpers.GetUninitializedObject(typeof(GameBackUpRounds)); + + typeof(GameBackUpRounds) + .GetField("_restore", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(backups, _state); + + return backups; + } + + [Fact] + public void AnOwedLoadCannotBeResumedOver() + { + _state.StartOwing(Round, Map); + + Assert.Equal(Round, _state.OwedRound); + Assert.True(Backups().IsResettingRound()); + Assert.True(Backups().IsHoldingForRestore()); + } + + [Fact] + public void AnOwedLoadDoesNotStandInTheWayOfItsAnswer() + { + _state.StartOwing(Round, Map); + + Assert.False(_state.VotingOrLoading); + } + + [Fact] + public void TheAnswerSettlesTheDebt() + { + _state.StartOwing(Round, Map); + _state.StartLoad(); + + Assert.Null(_state.OwedRound); + Assert.True(_state.LoadingBackup); + Assert.True(Backups().IsResettingRound()); + + _state.End(); + + Assert.False(Backups().IsResettingRound()); + } + + [Fact] + public void AnOwedLoadThatCouldNotStartGoesBackToTheHold() + { + _state.StartOwing(Round, Map); + + Assert.Equal(Round, _state.FailedLoad()); + Assert.False(_state.InProgress); + } + + [Fact] + public void AFailedLoadNobodyWasOwedHoldsNothing() + { + Assert.Null(_state.FailedLoad()); + } + + [Fact] + public void AnOwedLoadNobodyAnsweredGoesBackToTheHold() + { + int owing = _state.StartOwing(Round, Map); + + Assert.Equal(new OwedLoadTimeout(true, Round), _state.TimedOut(owing, Map)); + Assert.False(_state.InProgress); + } + + [Fact] + public void ADebtThatNeverWentOutStillComesBackToTheHold() + { + int owing = _state.StartOwing(Round, null); + + Assert.Equal(new OwedLoadTimeout(true, Round), _state.TimedOut(owing, null)); + } + + [Fact] + public void ATimerOnAnotherMapEndsQuietly() + { + int owing = _state.StartOwing(Round, Map); + + Assert.Equal(new OwedLoadTimeout(true), _state.TimedOut(owing, OtherMap)); + Assert.False(_state.InProgress); + } + + [Fact] + public void ATimerFromAnEarlierDebtDoesNothing() + { + int earlier = _state.StartOwing(Round, Map); + _state.End(); + _state.StartOwing(Round, Map); + + Assert.Equal(default, _state.TimedOut(earlier, Map)); + Assert.Equal(Round, _state.OwedRound); + } + + [Fact] + public void ATimerAfterTheLoadStartedDoesNothing() + { + int owing = _state.StartOwing(Round, Map); + _state.StartLoad(); + + Assert.Equal(default, _state.TimedOut(owing, Map)); + Assert.True(_state.LoadingBackup); + } + + [Fact] + public void ATimerAfterRestartMapDoesNothing() + { + int owing = _state.StartOwing(Round, Map); + _state.Abandon(); + + Assert.Equal(default, _state.TimedOut(owing, Map)); + } + + [Fact] + public void RestartMapClearsAHold() + { + _state.Hold(Round); + _state.Abandon(); + + Assert.Null(_state.HeldRound); + Assert.False(Backups().IsResettingRound()); + Assert.False(Backups().IsHoldingForRestore()); + } + + [Fact] + public void RestartMapClearsAnOwedLoad() + { + _state.StartOwing(Round, Map); + _state.Abandon(); + + Assert.False(Backups().IsResettingRound()); + Assert.False(Backups().IsHoldingForRestore()); + } + + [Fact] + public void RestartMapClearsAVoteAndAHoldTogether() + { + _state.Hold(Round); + _state.StartVote(); + _state.Abandon(); + + Assert.False(Backups().IsResettingRound()); + } + + [Fact] + public void AHoldIsOfferedItsVoteWhenNothingElseIsRunning() + { + _state.Hold(Round); + + Assert.True(_state.CanOfferVote); + } + + [Fact] + public void NoHoldMeansNoVote() + { + Assert.False(_state.CanOfferVote); + } + + [Fact] + public void ALoadInProgressKeepsAHoldFromBeingOffered() + { + _state.Hold(Round); + _state.StartLoad(); + + Assert.False(_state.CanOfferVote); + Assert.Equal(Round, _state.HeldRound); + } + + [Fact] + public void AnOwedLoadKeepsAHoldFromBeingOffered() + { + _state.Hold(Round); + _state.StartOwing(Round, Map); + + Assert.False(_state.CanOfferVote); + } + + [Fact] + public void AVoteInProgressKeepsAHoldFromBeingOffered() + { + _state.Hold(Round); + _state.StartVote(); + + Assert.False(_state.CanOfferVote); + } + + [Fact] + public void TakingTheHoldHandsBackItsRound() + { + _state.Hold(Round); + + Assert.Equal(Round, _state.ReleaseHold()); + Assert.Null(_state.HeldRound); + } + + [Fact] + public void AnAnswerDuringAVoteOrALoadIsIgnoredAsOnMain() + { + _state.StartVote(); + Assert.True(_state.VotingOrLoading); + + _state.End(); + _state.StartLoad(); + Assert.True(_state.VotingOrLoading); + } + + [Fact] + public void AnAnswerWithNothingGoingOnIsLoadedAsOnMain() + { + Assert.False(_state.VotingOrLoading); + } + + [Fact] + public void ResetToRoundZeroStaysAsOnMain() + { + BackupRound zero = new() { round = 0, backup_file = "round00" }; + + Assert.Same(zero, RoundRestoreGateUtility.LatestBackup(new[] { zero }, 0)); + Assert.Null( + RoundRestoreGateUtility.Refusal( + false, + _state.VotingOrLoading, + eMapStatus.Live, + 0, + true, + 10, + 10, + "Competitive" + ) + ); + } + + [Fact] + public void AnOwedLoadDoesNotRefuseAnOrganizerRestore() + { + _state.StartOwing(Round, Map); + + Assert.Null( + RoundRestoreGateUtility.Refusal( + false, + _state.VotingOrLoading, + eMapStatus.Paused, + Round, + true, + 10, + 10, + "Competitive" + ) + ); + } + + [Theory] + [InlineData(true, false, true)] + [InlineData(false, false, false)] + [InlineData(true, true, false)] + [InlineData(false, true, false)] + public void OnlyARecoveryOnAServerWithAPanelIsOwedALoad(bool recovering, bool offline, bool owed) + { + Assert.Equal(owed, RoundRestoreGateUtility.OwesLoad(recovering, offline)); + } + + [Fact] + public void PlayersAreToldWhyResumeWaits() + { + Assert.Contains(Round.ToString(), RoundRestoreGateUtility.ResumeRefusal(Round)); + } + + [Fact] + public void NothingIsSaidWhenNoLoadIsOwed() + { + Assert.Null(RoundRestoreGateUtility.ResumeRefusal(null)); + } +} diff --git a/apps/swiftly/src/FiveStack.Commands/Match.cs b/apps/swiftly/src/FiveStack.Commands/Match.cs index d105ce1a..e1010f37 100644 --- a/apps/swiftly/src/FiveStack.Commands/Match.cs +++ b/apps/swiftly/src/FiveStack.Commands/Match.cs @@ -39,6 +39,8 @@ public void SetMatchState(ICommandContext context) context.Reply($"{RoundRestoreGateUtility.MatchStateRefusedPrefix} {refusal}"); return; } + + _gameBackupRounds.AbandonRestore(); } match?.UpdateMapStatus(status); diff --git a/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs b/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs index ebf2b857..33d8dec4 100644 --- a/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs +++ b/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs @@ -15,10 +15,6 @@ public class GameBackUpRounds { private readonly RoundRestoreState _restore; - // A server that came back behind the backend, holding the round it can - // restore until every player is back to vote on it. Meanwhile nothing is - // played, published or written over the backups being restored. - private int? _pendingRestore; private readonly ISwiftlyCore _core; private readonly MatchEvents _matchEvents; private readonly GameServer _gameServer; @@ -114,7 +110,7 @@ public void Setup() fileConVar.ValueAsString = prefix; } - SetBackupWriting(_pendingRestore == null); + SetBackupWriting(!IsHoldingForRestore()); // Read the values straight back from the engine to confirm the sets applied. string autoReadback = @@ -125,7 +121,7 @@ public void Setup() _logger.LogInformation( "Backup Setup: set mp_backup_round_file={Prefix}, mp_backup_round_auto={Writing} | readback auto={Auto} file={File} (files: {Dir}/{Prefix}_round.txt)", prefix, - _pendingRestore == null ? 1 : 0, + IsHoldingForRestore() ? 0 : 1, autoReadback, fileReadback, BackupDirectory, @@ -135,12 +131,12 @@ public void Setup() public bool IsResettingRound() { - return _restore.InProgress || _pendingRestore != null; + return _restore.Resetting; } public bool IsHoldingForRestore() { - return _pendingRestore != null; + return _restore.HoldingRounds; } private void SetBackupWriting(bool enabled) @@ -155,12 +151,12 @@ private void SetBackupWriting(bool enabled) // player is back. Called when the match loads and as players connect. public void OfferPendingRestore() { - if (_pendingRestore == null || restoreRoundVote != null) + if (!_restore.CanOfferVote || restoreRoundVote != null) { return; } - int round = _pendingRestore.Value; + int round = _restore.HeldRound!.Value; int connectedPlayers = MatchUtility.PlayerCount(); int expectedPlayers = _matchService.GetCurrentMatch()?.GetExpectedPlayerCount() ?? 10; @@ -173,13 +169,13 @@ public void OfferPendingRestore() return; } - _pendingRestore = null; + _restore.ReleaseHold(); RequestRestoreBackupRound(round, null, true, recovering: true); // Refused for another reason: keep holding for the next player. if (!_restore.InProgress) { - _pendingRestore = round; + _restore.Hold(round); } } @@ -268,12 +264,95 @@ public bool CheckForBackupRestore(eMapStatus status) _logger.LogInformation( $"Backup round {highestNumber} is ahead of current round {totalRoundsPlayed}, holding the match for a restore vote" ); - _pendingRestore = highestNumber; + HoldForRestore(highestNumber); + + return false; + } + + private void HoldForRestore(int round) + { + _restore.Hold(round); SetBackupWriting(false); _gameServer.SendCommands(["mp_pause_match"]); OfferPendingRestore(); + } - return false; + // A load that is owed and not coming goes back to the hold, and never to a + // replay: only players deciding that gives up rounds the backend recorded. + private void ReturnToHold(int round) + { + eMapStatus status = + _matchService.GetCurrentMatch()?.GetCurrentMapStatus() ?? eMapStatus.Unknown; + + if (!RoundRestoreGateUtility.IsInPlay(status)) + { + SetBackupWriting(!IsHoldingForRestore()); + return; + } + + _logger.LogWarning($"Round {round} was not restored, holding the match for another vote"); + HoldForRestore(round); + } + + private void ReturnOwedToHold() + { + if (_restore.FailedLoad() is int round) + { + ReturnToHold(round); + } + } + + private Guid? ActiveMapId() + { + MatchManager? matchManager = _matchService.GetCurrentMatch(); + + return matchManager?.GetActiveMapId() + ?? matchManager?.GetMatchData()?.current_match_map_id; + } + + private void OweLoad(int round) + { + int owing = _restore.StartOwing(round, ActiveMapId()); + + TimerUtility.AddTimer(RoundRestoreState.OwedSeconds, () => OwedLoadTimedOut(owing)); + } + + private void OwedLoadTimedOut(int owing) + { + OwedLoadTimeout timeout = _restore.TimedOut(owing, ActiveMapId()); + + if (!timeout.Expired) + { + return; + } + + if (timeout.HoldRound is int round) + { + ReturnToHold(round); + return; + } + + SetBackupWriting(!IsHoldingForRestore()); + } + + public void TellWhyResumeWaits() + { + string? refusal = RoundRestoreGateUtility.ResumeRefusal(_restore.OwedRound); + + if (refusal != null) + { + _gameServer.Message(MessageType.Alert, refusal); + } + } + + // Restart Map starts the map over, so whatever a restore was protecting is + // gone and the restore with it. + public void AbandonRestore() + { + restoreRoundVote?.Abandon(); + restoreRoundVote = null; + _restore.Abandon(); + SetBackupWriting(true); } // A match whose rounds can't be restored replays the map from warmup, but @@ -452,6 +531,7 @@ public void RequestRestoreBackupRound( { _logger.LogInformation("restore round vote passed"); _restore.End(); + restoreRoundVote = null; string? refusal = RestoreRefusal(round); if (refusal != null) @@ -463,14 +543,27 @@ public void RequestRestoreBackupRound( MessageType.Alert, $" Restore round blocked: {refusal}." ); - restoreRoundVote = null; if (recovering) { - _pendingRestore = round; + _restore.Hold(round); } return; } + if ( + RoundRestoreGateUtility.OwesLoad( + recovering, + _environmentService.IsOfflineMode() + ) + ) + { + OweLoad(round); + } + else if (recovering) + { + SetBackupWriting(true); + } + SendRestoreRoundToBackend(round); }, () => @@ -613,7 +706,7 @@ public void SendRestoreRoundToBackend(int round) public void RestoreRound(int round) { - if (_restore.InProgress) + if (_restore.VotingOrLoading) { _logger.LogWarning($"Restore already in progress, ignoring RestoreRound({round})"); return; @@ -621,6 +714,7 @@ public void RestoreRound(int round) if (!CanRestoreRound(round)) { + ReturnOwedToHold(); return; } @@ -635,6 +729,7 @@ public void RestoreRound(int round) match != null, matchMap != null ); + ReturnOwedToHold(); return; } @@ -647,10 +742,11 @@ public void RestoreRound(int round) round, string.Join(", ", matchMap.rounds.Select(r => r.round)) ); + ReturnOwedToHold(); return; } - _pendingRestore = null; + _restore.ReleaseHold(); string backupRoundFileName = $"restore-{MatchUtility.GetSafeMatchPrefix(match)}round{round.ToString().PadLeft(2, '0')}.txt"; @@ -723,7 +819,7 @@ public void RestoreRound(int round) return RoundRestoreGateUtility.Refusal( match != null && !match.options.CanRestoreRounds(), - _restore.InProgress, + _restore.VotingOrLoading, _matchService.GetCurrentMatch()?.GetCurrentMapStatus() ?? eMapStatus.Unknown, round, matchMap != null diff --git a/apps/swiftly/src/FiveStack.Services/MatchManager.cs b/apps/swiftly/src/FiveStack.Services/MatchManager.cs index 2ddac2bc..9d0a331a 100644 --- a/apps/swiftly/src/FiveStack.Services/MatchManager.cs +++ b/apps/swiftly/src/FiveStack.Services/MatchManager.cs @@ -304,6 +304,7 @@ public void ResumeMatch(string? message = null, bool skipUpdate = false) if (_backUpManagement.IsResettingRound()) { _logger.LogInformation("Resetting round, cannot resume match"); + _backUpManagement.TellWhyResumeWaits(); return; } @@ -317,12 +318,6 @@ public void ResumeMatch(string? message = null, bool skipUpdate = false) _timeoutSystem.resumeVote = null; } - if (_backUpManagement.restoreRoundVote != null) - { - _backUpManagement.restoreRoundVote.CancelVote(); - _backUpManagement.restoreRoundVote = null; - } - _timeoutSystem.ClearPendingTeamResumes(); if (!IsPaused()) diff --git a/apps/swiftly/src/FiveStack.Services/VoteSystem.cs b/apps/swiftly/src/FiveStack.Services/VoteSystem.cs index 2aee5bc1..b7c1601e 100644 --- a/apps/swiftly/src/FiveStack.Services/VoteSystem.cs +++ b/apps/swiftly/src/FiveStack.Services/VoteSystem.cs @@ -88,8 +88,23 @@ public void StartVote( SendVoteMessage(); } + // Over with no outcome: neither callback runs. + public void Abandon() + { + _voteFailedCallback = null; + _voteSuccessCallback = null; + CancelVote(); + } + public void CancelVote() { + // The failure callback can replay a map from 0-0, so a vote that has + // already passed must not reach it. + if (!IsVoteActive()) + { + return; + } + _voteStartTime = null; _voteTimeout = null; KillTimers(); diff --git a/apps/swiftly/src/FiveStack.Utilities/TimerUtility.cs b/apps/swiftly/src/FiveStack.Utilities/TimerUtility.cs index 00287d2d..e94fc7da 100644 --- a/apps/swiftly/src/FiveStack.Utilities/TimerUtility.cs +++ b/apps/swiftly/src/FiveStack.Utilities/TimerUtility.cs @@ -6,7 +6,7 @@ namespace FiveStack.Utilities { public static class TimerUtility { - private static ISwiftlyCore _core = null!; + private static ISwiftlyCore? _core; public static readonly List Timers = new List(); @@ -18,7 +18,7 @@ public static void Initialize(ISwiftlyCore core) public static CancellationTokenSource AddTimer(float interval, Action callback) { - CancellationTokenSource timer = _core.Scheduler.DelayBySeconds(interval, callback); + CancellationTokenSource timer = _core!.Scheduler.DelayBySeconds(interval, callback); Timers.Add(timer); return timer; } @@ -29,7 +29,7 @@ public static CancellationTokenSource Repeat(float interval, Action callback) // SwiftlyS2 never reschedules a repeating timer whose callback throws, // and still runs a timer's first pass if it was cancelled before then - timer = _core.Scheduler.RepeatBySeconds( + timer = _core!.Scheduler.RepeatBySeconds( interval, () => { @@ -44,7 +44,7 @@ public static CancellationTokenSource Repeat(float interval, Action callback) } catch (Exception ex) { - _core.Logger.LogError(ex, "repeating timer callback failed"); + _core!.Logger.LogError(ex, "repeating timer callback failed"); } } ); diff --git a/apps/swiftly/test/OpenVoteTests.cs b/apps/swiftly/test/OpenVoteTests.cs new file mode 100644 index 00000000..7510b13f --- /dev/null +++ b/apps/swiftly/test/OpenVoteTests.cs @@ -0,0 +1,55 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using FiveStack; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +// Only here: the other plugin's vote timers are native handles. +public class OpenVoteTests +{ + private int _failures; + private int _passes; + + private static void Set(object target, string field, object? value) + { + target + .GetType() + .GetField(field, BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(target, value); + } + + private VoteSystem OpenVote() + { + VoteSystem vote = (VoteSystem)RuntimeHelpers.GetUninitializedObject(typeof(VoteSystem)); + + Set(vote, "_logger", NullLogger.Instance); + Set(vote, "_voteFailedCallback", (Action)(() => _failures++)); + Set(vote, "_voteSuccessCallback", (Action)(() => _passes++)); + Set(vote, "_playerMessageTimer", new CancellationTokenSource()); + + return vote; + } + + [Fact] + public void CancellingAnOpenVoteRunsItsFailure() + { + VoteSystem vote = OpenVote(); + + vote.CancelVote(); + + Assert.Equal(1, _failures); + Assert.False(vote.IsVoteActive()); + } + + [Fact] + public void AbandoningAnOpenVoteRunsNeitherCallback() + { + VoteSystem vote = OpenVote(); + + vote.Abandon(); + + Assert.Equal(0, _failures); + Assert.Equal(0, _passes); + Assert.False(vote.IsVoteActive()); + } +} diff --git a/apps/swiftly/test/RestoreVoteTests.cs b/apps/swiftly/test/RestoreVoteTests.cs new file mode 100644 index 00000000..397bd3d0 --- /dev/null +++ b/apps/swiftly/test/RestoreVoteTests.cs @@ -0,0 +1,49 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using FiveStack; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +// A recovery vote's failure callback replays the map from 0-0, which sets every +// round the backend recorded aside. It must only ever run for a vote that lost. +public class RestoreVoteTests +{ + private int _failures; + private int _passes; + + private static void Set(object target, string field, object? value) + { + target + .GetType() + .GetField(field, BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(target, value); + } + + private VoteSystem FinishedVote() + { + VoteSystem vote = (VoteSystem)RuntimeHelpers.GetUninitializedObject(typeof(VoteSystem)); + + Set(vote, "_logger", NullLogger.Instance); + Set(vote, "_voteFailedCallback", (Action)(() => _failures++)); + Set(vote, "_voteSuccessCallback", (Action)(() => _passes++)); + + return vote; + } + + [Fact] + public void AFinishedVoteCannotBeCancelledIntoItsFailure() + { + FinishedVote().CancelVote(); + + Assert.Equal(0, _failures); + } + + [Fact] + public void AbandoningAFinishedVoteRunsNoCallback() + { + FinishedVote().Abandon(); + + Assert.Equal(0, _failures); + Assert.Equal(0, _passes); + } +} diff --git a/apps/swiftly/test/RestoreWiringTests.cs b/apps/swiftly/test/RestoreWiringTests.cs new file mode 100644 index 00000000..9d0dc4b5 --- /dev/null +++ b/apps/swiftly/test/RestoreWiringTests.cs @@ -0,0 +1,206 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using Xunit; + +// What the state decides is tested in RoundRestoreStateTests. Calling it needs +// the engine, so these read the plugin. +public class RestoreWiringTests +{ + private static string SourceDir([CallerFilePath] string testFile = "") + { + return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(testFile)!, "..", "src")); + } + + private static string Code(params string[] path) + { + return Regex.Replace( + File.ReadAllText(Path.Combine(new[] { SourceDir() }.Concat(path).ToArray())), + @"//[^\n]*", + "" + ); + } + + private static string Backups() + { + return Code("FiveStack.Services", "GameBackUpRounds.cs"); + } + + private static string Method(string source, string signature) + { + int from = source.IndexOf(signature, StringComparison.Ordinal); + + Assert.True(from >= 0, $"{signature} is gone"); + + int to = source.IndexOf("\n }\n", from, StringComparison.Ordinal); + + Assert.True(to > from, $"the end of {signature} was not found"); + + return source[from..to]; + } + + private static string Between(string source, string start, string end) + { + int from = source.IndexOf(start, StringComparison.Ordinal); + int to = source.IndexOf(end, from + 1, StringComparison.Ordinal); + + Assert.True(from >= 0 && to > from, $"{start} has moved"); + + return source[from..to]; + } + + private static string PassedVote() + { + return Between( + Method(Backups(), "public void RequestRestoreBackupRound("), + "\"restore round vote passed\"", + "\"restore round vote failed\"" + ); + } + + [Fact] + public void AVoteIsLetGoAsSoonAsItPasses() + { + string passed = PassedVote(); + + Assert.Contains("restoreRoundVote = null;", Between(passed, "passed", "RestoreRefusal(round)")); + } + + [Fact] + public void APassedVoteAsksWhetherItOwesALoadBeforeSendingIt() + { + string passed = PassedVote(); + Match owes = Regex.Match( + passed, + @"RoundRestoreGateUtility\.OwesLoad\(\s*recovering,\s*_environmentService\.IsOfflineMode\(\)\s*\)" + ); + int sent = passed.IndexOf("SendRestoreRoundToBackend(round);", StringComparison.Ordinal); + + Assert.True(owes.Success, "a passed vote does not ask"); + Assert.True(sent > owes.Index, "the answer can beat the debt"); + } + + [Fact] + public void AnOfflineRecoveryTurnsBackupsBackOnAndDeletesNothing() + { + string passed = PassedVote(); + + Assert.Contains("SetBackupWriting(true);", passed); + Assert.DoesNotContain("ReplayMap", passed); + } + + [Fact] + public void OnlyADeclinedVoteReplaysTheMap() + { + string vote = Method(Backups(), "public void RequestRestoreBackupRound("); + string declined = vote[vote.IndexOf("\"restore round vote failed\"", StringComparison.Ordinal)..]; + + Assert.Contains("ReplayMap(", declined); + Assert.DoesNotContain("ReplayMap", Method(Backups(), "private void OwedLoadTimedOut(int owing)")); + Assert.DoesNotContain("ReplayMap", Method(Backups(), "private void ReturnToHold(int round)")); + Assert.DoesNotContain("ReplayMap", Method(Backups(), "public void RestoreRound(int round)")); + } + + [Fact] + public void AnOwedLoadIsTimedFromTheMomentItIsOwed() + { + string owe = Method(Backups(), "private void OweLoad(int round)"); + + Assert.Contains("_restore.StartOwing(round, ActiveMapId())", owe); + Assert.Contains("RoundRestoreState.OwedSeconds", owe); + } + + [Fact] + public void TheAnswerIsNotGatedOnWhatWasAskedFor() + { + string restore = Method(Backups(), "public void RestoreRound(int round)"); + + Assert.Contains("if (_restore.VotingOrLoading)", restore); + Assert.DoesNotContain("round == 0", restore); + } + + [Fact] + public void EveryWayOutOfRestoreRoundSettlesAnOwedLoad() + { + string restore = Method(Backups(), "public void RestoreRound(int round)"); + string guarded = Between(restore, "CanRestoreRound(round)", "_restore.StartLoad("); + + MatchCollection exits = Regex.Matches(guarded, @"[^\n]*\n\s*return;"); + + Assert.Equal(3, exits.Count); + Assert.All(exits.Select(exit => exit.Value), exit => Assert.Contains("ReturnOwedToHold();", exit)); + } + + [Fact] + public void AHoldOnlyComesBackOnAMapStillBeingPlayed() + { + string hold = Method(Backups(), "private void ReturnToHold(int round)"); + int played = hold.IndexOf("RoundRestoreGateUtility.IsInPlay(", StringComparison.Ordinal); + int held = hold.IndexOf("HoldForRestore(round);", StringComparison.Ordinal); + + Assert.True(played >= 0 && held > played, "a hold can come back on a map that is over"); + } + + [Fact] + public void AnOwedLoadOnAnotherMapTurnsBackupsBackOnIfNothingHolds() + { + Assert.Contains( + "SetBackupWriting(!IsHoldingForRestore());", + Method(Backups(), "private void OwedLoadTimedOut(int owing)") + ); + } + + [Fact] + public void AHoldIsOnlyOfferedThroughTheState() + { + string offer = Method(Backups(), "public void OfferPendingRestore()"); + int asked = offer.IndexOf("_restore.CanOfferVote", StringComparison.Ordinal); + int taken = offer.IndexOf("_restore.ReleaseHold()", StringComparison.Ordinal); + + Assert.True(asked >= 0 && taken > asked, "a hold can be taken during a load"); + } + + [Fact] + public void RestartMapAbandonsTheRestore() + { + string abandon = Method(Backups(), "public void AbandonRestore()"); + + Assert.Contains("_restore.Abandon();", abandon); + Assert.Contains("SetBackupWriting(true);", abandon); + Assert.Contains(".Abandon();", abandon.Replace("_restore.Abandon();", "")); + Assert.DoesNotContain("CancelVote", abandon); + } + + [Fact] + public void RestartMapOnlyAbandonsOnceItWasAccepted() + { + string state = Between(Code("FiveStack.Commands", "Match.cs"), "SetMatchState(", "match?.UpdateMapStatus(status);"); + int refused = state.IndexOf("refusal != null", StringComparison.Ordinal); + int abandoned = state.IndexOf("_gameBackupRounds.AbandonRestore();", StringComparison.Ordinal); + + Assert.True(abandoned > refused, "a refused restart can drop a hold"); + } + + [Fact] + public void ResumingNoLongerReachesARestoreVote() + { + Assert.DoesNotContain("restoreRoundVote", Code("FiveStack.Services", "MatchManager.cs")); + } + + [Fact] + public void ResumeSaysWhyItIsWaiting() + { + string resume = Method(Code("FiveStack.Services", "MatchManager.cs"), "public void ResumeMatch("); + + Assert.Contains("TellWhyResumeWaits();", Between(resume, "IsResettingRound()", "return;")); + } + + [Fact] + public void AnOpenVoteStillRunsItsFailure() + { + string cancel = Method(Code("FiveStack.Services", "VoteSystem.cs"), "public void CancelVote()"); + int guarded = cancel.IndexOf("if (!IsVoteActive())", StringComparison.Ordinal); + int failed = cancel.IndexOf("_voteFailedCallback?.Invoke();", StringComparison.Ordinal); + + Assert.True(guarded >= 0 && failed > guarded, "CancelVote no longer fails an open vote"); + } +} diff --git a/apps/swiftly/test/RoundRestoreStateTests.cs b/apps/swiftly/test/RoundRestoreStateTests.cs new file mode 100644 index 00000000..36e0589c --- /dev/null +++ b/apps/swiftly/test/RoundRestoreStateTests.cs @@ -0,0 +1,296 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using FiveStack; +using FiveStack.Entities; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +// One debt: a recovery vote passed and the server is owed its load. Nothing else +// about a restore changes, so .reset, organizer restores and declined votes are +// pinned here as well as the new paths. +public class RoundRestoreStateTests +{ + private const int Round = 8; + + private static readonly Guid Map = Guid.NewGuid(); + private static readonly Guid OtherMap = Guid.NewGuid(); + + private readonly RoundRestoreState _state = new(); + + private GameBackUpRounds Backups() + { + GameBackUpRounds backups = (GameBackUpRounds) + RuntimeHelpers.GetUninitializedObject(typeof(GameBackUpRounds)); + + typeof(GameBackUpRounds) + .GetField("_restore", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(backups, _state); + + return backups; + } + + [Fact] + public void AnOwedLoadCannotBeResumedOver() + { + _state.StartOwing(Round, Map); + + Assert.Equal(Round, _state.OwedRound); + Assert.True(Backups().IsResettingRound()); + Assert.True(Backups().IsHoldingForRestore()); + } + + [Fact] + public void AnOwedLoadDoesNotStandInTheWayOfItsAnswer() + { + _state.StartOwing(Round, Map); + + Assert.False(_state.VotingOrLoading); + } + + [Fact] + public void TheAnswerSettlesTheDebt() + { + _state.StartOwing(Round, Map); + _state.StartLoad(); + + Assert.Null(_state.OwedRound); + Assert.True(_state.LoadingBackup); + Assert.True(Backups().IsResettingRound()); + + _state.End(); + + Assert.False(Backups().IsResettingRound()); + } + + [Fact] + public void AnOwedLoadThatCouldNotStartGoesBackToTheHold() + { + _state.StartOwing(Round, Map); + + Assert.Equal(Round, _state.FailedLoad()); + Assert.False(_state.InProgress); + } + + [Fact] + public void AFailedLoadNobodyWasOwedHoldsNothing() + { + Assert.Null(_state.FailedLoad()); + } + + [Fact] + public void AnOwedLoadNobodyAnsweredGoesBackToTheHold() + { + int owing = _state.StartOwing(Round, Map); + + Assert.Equal(new OwedLoadTimeout(true, Round), _state.TimedOut(owing, Map)); + Assert.False(_state.InProgress); + } + + [Fact] + public void ADebtThatNeverWentOutStillComesBackToTheHold() + { + int owing = _state.StartOwing(Round, null); + + Assert.Equal(new OwedLoadTimeout(true, Round), _state.TimedOut(owing, null)); + } + + [Fact] + public void ATimerOnAnotherMapEndsQuietly() + { + int owing = _state.StartOwing(Round, Map); + + Assert.Equal(new OwedLoadTimeout(true), _state.TimedOut(owing, OtherMap)); + Assert.False(_state.InProgress); + } + + [Fact] + public void ATimerFromAnEarlierDebtDoesNothing() + { + int earlier = _state.StartOwing(Round, Map); + _state.End(); + _state.StartOwing(Round, Map); + + Assert.Equal(default, _state.TimedOut(earlier, Map)); + Assert.Equal(Round, _state.OwedRound); + } + + [Fact] + public void ATimerAfterTheLoadStartedDoesNothing() + { + int owing = _state.StartOwing(Round, Map); + _state.StartLoad(); + + Assert.Equal(default, _state.TimedOut(owing, Map)); + Assert.True(_state.LoadingBackup); + } + + [Fact] + public void ATimerAfterRestartMapDoesNothing() + { + int owing = _state.StartOwing(Round, Map); + _state.Abandon(); + + Assert.Equal(default, _state.TimedOut(owing, Map)); + } + + [Fact] + public void RestartMapClearsAHold() + { + _state.Hold(Round); + _state.Abandon(); + + Assert.Null(_state.HeldRound); + Assert.False(Backups().IsResettingRound()); + Assert.False(Backups().IsHoldingForRestore()); + } + + [Fact] + public void RestartMapClearsAnOwedLoad() + { + _state.StartOwing(Round, Map); + _state.Abandon(); + + Assert.False(Backups().IsResettingRound()); + Assert.False(Backups().IsHoldingForRestore()); + } + + [Fact] + public void RestartMapClearsAVoteAndAHoldTogether() + { + _state.Hold(Round); + _state.StartVote(); + _state.Abandon(); + + Assert.False(Backups().IsResettingRound()); + } + + [Fact] + public void AHoldIsOfferedItsVoteWhenNothingElseIsRunning() + { + _state.Hold(Round); + + Assert.True(_state.CanOfferVote); + } + + [Fact] + public void NoHoldMeansNoVote() + { + Assert.False(_state.CanOfferVote); + } + + [Fact] + public void ALoadInProgressKeepsAHoldFromBeingOffered() + { + _state.Hold(Round); + _state.StartLoad(); + + Assert.False(_state.CanOfferVote); + Assert.Equal(Round, _state.HeldRound); + } + + [Fact] + public void AnOwedLoadKeepsAHoldFromBeingOffered() + { + _state.Hold(Round); + _state.StartOwing(Round, Map); + + Assert.False(_state.CanOfferVote); + } + + [Fact] + public void AVoteInProgressKeepsAHoldFromBeingOffered() + { + _state.Hold(Round); + _state.StartVote(); + + Assert.False(_state.CanOfferVote); + } + + [Fact] + public void TakingTheHoldHandsBackItsRound() + { + _state.Hold(Round); + + Assert.Equal(Round, _state.ReleaseHold()); + Assert.Null(_state.HeldRound); + } + + [Fact] + public void AnAnswerDuringAVoteOrALoadIsIgnoredAsOnMain() + { + _state.StartVote(); + Assert.True(_state.VotingOrLoading); + + _state.End(); + _state.StartLoad(); + Assert.True(_state.VotingOrLoading); + } + + [Fact] + public void AnAnswerWithNothingGoingOnIsLoadedAsOnMain() + { + Assert.False(_state.VotingOrLoading); + } + + [Fact] + public void ResetToRoundZeroStaysAsOnMain() + { + BackupRound zero = new() { round = 0, backup_file = "round00" }; + + Assert.Same(zero, RoundRestoreGateUtility.LatestBackup(new[] { zero }, 0)); + Assert.Null( + RoundRestoreGateUtility.Refusal( + false, + _state.VotingOrLoading, + eMapStatus.Live, + 0, + true, + 10, + 10, + "Competitive" + ) + ); + } + + [Fact] + public void AnOwedLoadDoesNotRefuseAnOrganizerRestore() + { + _state.StartOwing(Round, Map); + + Assert.Null( + RoundRestoreGateUtility.Refusal( + false, + _state.VotingOrLoading, + eMapStatus.Paused, + Round, + true, + 10, + 10, + "Competitive" + ) + ); + } + + [Theory] + [InlineData(true, false, true)] + [InlineData(false, false, false)] + [InlineData(true, true, false)] + [InlineData(false, true, false)] + public void OnlyARecoveryOnAServerWithAPanelIsOwedALoad(bool recovering, bool offline, bool owed) + { + Assert.Equal(owed, RoundRestoreGateUtility.OwesLoad(recovering, offline)); + } + + [Fact] + public void PlayersAreToldWhyResumeWaits() + { + Assert.Contains(Round.ToString(), RoundRestoreGateUtility.ResumeRefusal(Round)); + } + + [Fact] + public void NothingIsSaidWhenNoLoadIsOwed() + { + Assert.Null(RoundRestoreGateUtility.ResumeRefusal(null)); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs b/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs index 1936f35d..f2227a37 100644 --- a/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs @@ -12,6 +12,20 @@ public static class RoundRestoreGateUtility // Same for match_state, which the api sends to restart a map from warmup. public const string MatchStateRefusedPrefix = "match_state refused:"; + // Only a server behind the backend has anything to protect, and offline + // nothing would ever answer. + public static bool OwesLoad(bool recovering, bool offline) + { + return recovering && !offline; + } + + public static string? ResumeRefusal(int? owedRound) + { + return owedRound == null + ? null + : $" Round {owedRound} is still being restored, the match can't resume yet."; + } + public static string? Refusal( bool restoresDisabled, bool alreadyRestoring, diff --git a/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs b/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs index fd436ec4..b5d7e8f4 100644 --- a/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs +++ b/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs @@ -1,30 +1,123 @@ namespace FiveStack.Utilities { + public readonly record struct OwedLoadTimeout(bool Expired, int? HoldRound = null); + // A restore vote leaves the round running, because mp_pause_match waits for the // next freezetime, so its kills are real. A backup load outlasts what the api // clears when it restores a round, so anything reported then would stay in the // stats. + // + // A server that came back behind the backend is held on the round it can + // restore until every player is back to vote on it. Meanwhile nothing is + // played, published or written over the backups being restored. A passed vote + // ends the hold before the load has started, and the api replies nothing to + // the command, so the load is owed until it starts: resuming before then would + // write round 1 over the round 1 the backend already has. public class RoundRestoreState { - public bool InProgress { get; private set; } + // Long, because a slow answer is not a lost one: the event is resent until + // the api has handled it, so a panel deploy only delays it. Going back to + // the hold early costs a second vote. Waiting costs a few paused minutes. + public const int OwedSeconds = 300; + + private bool _voting; + private int _owing; + private int? _owedRound; + private Guid? _owedMap; + private int? _loadOwed; + + public int? HeldRound { get; private set; } + + public int? OwedRound => _owedRound; public bool LoadingBackup { get; private set; } + public bool VotingOrLoading => _voting || LoadingBackup; + + public bool InProgress => VotingOrLoading || _owedRound != null; + + public bool Resetting => InProgress || HeldRound != null; + + public bool HoldingRounds => HeldRound != null || _owedRound != null; + + // Taking a hold to vote on it while something else is running loses it: + // the vote is refused, and nothing puts the hold back. + public bool CanOfferVote => HeldRound != null && !InProgress; + + public void Hold(int round) + { + HeldRound = round; + } + + public int? ReleaseHold() + { + int? held = HeldRound; + HeldRound = null; + + return held; + } + public void StartVote() { - InProgress = true; + End(); + _voting = true; + } + + public int StartOwing(int round, Guid? map) + { + End(); + _owedRound = round; + _owedMap = map; + + return ++_owing; } public void StartLoad() { - InProgress = true; + _loadOwed = _owedRound; + _owedRound = null; + _voting = false; LoadingBackup = true; } + // The round to hold again, if a load was owed. + public int? FailedLoad() + { + int? owed = _owedRound ?? _loadOwed; + + End(); + + return owed; + } + + public OwedLoadTimeout TimedOut(int owing, Guid? map) + { + if (owing != _owing || _owedRound == null) + { + return default; + } + + int round = _owedRound.Value; + bool sameMap = _owedMap == map; + + End(); + + return new OwedLoadTimeout(true, sameMap ? round : null); + } + public void End() { - InProgress = false; + _voting = false; + _owedRound = null; + _owedMap = null; + _loadOwed = null; LoadingBackup = false; } + + public void Abandon() + { + End(); + HeldRound = null; + } } } From 80653a793999a8dd78fc3af8d34ffc8f5a2cef54 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 2 Nov 2026 16:59:25 -0500 Subject: [PATCH 3/7] fix: a round that ends during a failed restore vote is no longer lost --- .../src/FiveStack.Events/RoundEnd.cs | 4 +- .../src/FiveStack.Events/RoundStart.cs | 2 +- .../FiveStack.Services/GameBackUpRounds.cs | 16 +- .../test/DeclinedVoteRoundTests.cs | 183 ++++++++++++++++++ .../test/PendingRoundResultTests.cs | 64 ++++++ .../test/RestoreWiringTests.cs | 16 ++ apps/swiftly/src/FiveStack.Events/RoundEnd.cs | 4 +- .../src/FiveStack.Events/RoundStart.cs | 2 +- .../FiveStack.Services/GameBackUpRounds.cs | 16 +- apps/swiftly/test/DeclinedVoteRoundTests.cs | 183 ++++++++++++++++++ apps/swiftly/test/RestoreWiringTests.cs | 16 ++ .../RoundRestoreGateUtility.cs | 9 + .../FiveStack.Utilities/RoundRestoreState.cs | 12 +- 13 files changed, 517 insertions(+), 10 deletions(-) create mode 100644 apps/counterstrikesharp/test/DeclinedVoteRoundTests.cs create mode 100644 apps/counterstrikesharp/test/PendingRoundResultTests.cs create mode 100644 apps/swiftly/test/DeclinedVoteRoundTests.cs diff --git a/apps/counterstrikesharp/src/FiveStack.Events/RoundEnd.cs b/apps/counterstrikesharp/src/FiveStack.Events/RoundEnd.cs index b231f780..93e45d9a 100644 --- a/apps/counterstrikesharp/src/FiveStack.Events/RoundEnd.cs +++ b/apps/counterstrikesharp/src/FiveStack.Events/RoundEnd.cs @@ -35,7 +35,7 @@ public HookResult OnRoundOfficiallyEnded(EventRoundOfficiallyEnded @event, GameE match.UpdateMapStatus(eMapStatus.Overtime); } - if (_gameBackupRounds.IsResettingRound()) + if (_gameBackupRounds.HoldsRoundResults()) { _logger.LogInformation("OnRoundOfficiallyEnded skipping capture: restoring round"); return HookResult.Continue; @@ -55,7 +55,7 @@ public HookResult OnRoundOfficiallyEnded(EventRoundOfficiallyEnded @event, GameE [GameEventHandler] public HookResult OnRoundEnd(EventRoundEnd @event, GameEventInfo info) { - if (_gameBackupRounds.IsResettingRound()) + if (_gameBackupRounds.HoldsRoundResults()) { _logger.LogInformation( $"OnRoundEnd ignored (restoring round): message={@event.Message}" diff --git a/apps/counterstrikesharp/src/FiveStack.Events/RoundStart.cs b/apps/counterstrikesharp/src/FiveStack.Events/RoundStart.cs index f00504bf..643a4659 100644 --- a/apps/counterstrikesharp/src/FiveStack.Events/RoundStart.cs +++ b/apps/counterstrikesharp/src/FiveStack.Events/RoundStart.cs @@ -35,7 +35,7 @@ public HookResult OnRoundStart(EventRoundStart @event, GameEventInfo info) return HookResult.Continue; } - if (_gameBackupRounds.IsResettingRound()) + if (_gameBackupRounds.HoldsRoundResults()) { // A restart can unpause the round the hold paused. if (_gameBackupRounds.IsHoldingForRestore()) diff --git a/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs b/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs index e79157fe..4c4f80f4 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs @@ -39,6 +39,7 @@ RoundRestoreState restore ) { _restore = restore; + _restore.LoadStarted += matchEvents.ClearPendingRoundResult; _logger = logger; _matchEvents = matchEvents; _gameServer = gameServer; @@ -78,6 +79,15 @@ public bool IsResettingRound() return _restore.Resetting; } + public bool HoldsRoundResults() + { + return RoundRestoreGateUtility.HoldsRoundResults( + IsResettingRound(), + restoreRoundVote?.IsVoteActive() == true, + _restore.HoldingRounds + ); + } + public bool IsHoldingForRestore() { return _restore.HoldingRounds; @@ -317,7 +327,7 @@ public void RequestRestoreBackupRound( if (player != null || vote == true) { - _restore.StartVote(); + _restore.StartVote(recovering); restoreRoundVote = _serviceProvider.GetRequiredService(typeof(VoteSystem)) as VoteSystem; @@ -353,6 +363,10 @@ public void RequestRestoreBackupRound( return; } + // A round that ended during the vote is the one being undone, + // and the next round start can come before the load does. + _matchEvents.ClearPendingRoundResult(); + if ( RoundRestoreGateUtility.OwesLoad( recovering, diff --git a/apps/counterstrikesharp/test/DeclinedVoteRoundTests.cs b/apps/counterstrikesharp/test/DeclinedVoteRoundTests.cs new file mode 100644 index 00000000..5e7961f8 --- /dev/null +++ b/apps/counterstrikesharp/test/DeclinedVoteRoundTests.cs @@ -0,0 +1,183 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using FiveStack; +using FiveStack.Utilities; +using Xunit; + +// A .reset vote leaves the round running until the next freezetime, so a round +// that ends during one is real unless the vote passes. A recovery vote is the +// other way round: the server is behind the backend, and has nothing to report. +public class DeclinedVoteRoundTests +{ + private const int Round = 4; + + private readonly RoundRestoreState _restore = new(); + + private static string SourceDir([CallerFilePath] string testFile = "") + { + return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(testFile)!, "..", "src")); + } + + private static string Code(params string[] path) + { + return Regex.Replace( + File.ReadAllText(Path.Combine(new[] { SourceDir() }.Concat(path).ToArray())), + @"//[^\n]*", + "" + ); + } + + private static void Set(object target, string field, object? value) + { + target + .GetType() + .GetField(field, BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(target, value); + } + + private static VoteSystem FinishedVote() + { + return (VoteSystem)RuntimeHelpers.GetUninitializedObject(typeof(VoteSystem)); + } + + private static VoteSystem OpenVote() + { + VoteSystem vote = FinishedVote(); + FieldInfo timer = typeof(VoteSystem).GetField( + "_playerMessageTimer", + BindingFlags.NonPublic | BindingFlags.Instance + )!; + + timer.SetValue(vote, RuntimeHelpers.GetUninitializedObject(timer.FieldType)); + + return vote; + } + + private GameBackUpRounds Backups(VoteSystem? vote = null) + { + GameBackUpRounds backups = (GameBackUpRounds) + RuntimeHelpers.GetUninitializedObject(typeof(GameBackUpRounds)); + + Set(backups, "_restore", _restore); + backups.restoreRoundVote = vote; + + return backups; + } + + [Fact] + public void ARoundEndingDuringAResetVoteIsKept() + { + _restore.StartVote(); + + Assert.False(Backups(OpenVote()).HoldsRoundResults()); + } + + [Fact] + public void NothingIsHeldWithNoRestoreGoingOn() + { + Assert.False(Backups().HoldsRoundResults()); + } + + [Fact] + public void ARecoveryVoteHoldsResultsWhileItIsOpen() + { + _restore.StartVote(recovering: true); + + Assert.True(Backups(OpenVote()).HoldsRoundResults()); + } + + [Fact] + public void ARecoveryVoteKeepsTheBackupsHeld() + { + _restore.StartVote(recovering: true); + + Assert.True(Backups(OpenVote()).IsHoldingForRestore()); + } + + [Fact] + public void AResetVoteHoldsNoBackups() + { + _restore.StartVote(); + + Assert.False(Backups(OpenVote()).IsHoldingForRestore()); + } + + [Fact] + public void ARestoreHeldForPlayersHoldsResults() + { + _restore.Hold(Round); + + Assert.True(Backups().HoldsRoundResults()); + } + + [Fact] + public void ALoadingBackupHoldsResults() + { + _restore.StartLoad(); + + Assert.True(Backups().HoldsRoundResults()); + } + + [Fact] + public void ALoadAfterAVoteFinishedHoldsResults() + { + _restore.StartLoad(); + + Assert.True(Backups(FinishedVote()).HoldsRoundResults()); + } + + [Fact] + public void NoScoreSlipsOutWhileALoadIsOwed() + { + _restore.StartOwing(Round, Guid.NewGuid()); + + Assert.True(Backups(FinishedVote()).HoldsRoundResults()); + } + + [Fact] + public void ARestoreSaysWhenItsLoadStarts() + { + int started = 0; + _restore.LoadStarted += () => started++; + + _restore.StartVote(); + _restore.End(); + _restore.StartOwing(Round, Guid.NewGuid()); + + Assert.Equal(0, started); + + _restore.StartLoad(); + + Assert.Equal(1, started); + } + + [Fact] + public void ALoadForgetsTheResultOfTheRoundItUndoes() + { + Assert.Contains( + "_restore.LoadStarted += matchEvents.ClearPendingRoundResult;", + Code("FiveStack.Services", "GameBackUpRounds.cs") + ); + } + + [Theory] + [InlineData("RoundEnd.cs", 2)] + [InlineData("RoundStart.cs", 1)] + public void TheRoundHooksAskWhetherResultsAreHeld(string file, int sites) + { + string source = Code("FiveStack.Events", file); + + Assert.DoesNotContain("IsResettingRound", source); + Assert.Equal(sites, Regex.Matches(source, @"_gameBackupRounds\.HoldsRoundResults\(\)").Count); + } + + [Fact] + public void AVoteSaysWhetherItIsARecoveryVote() + { + Assert.Contains( + "_restore.StartVote(recovering);", + Code("FiveStack.Services", "GameBackUpRounds.cs") + ); + } +} diff --git a/apps/counterstrikesharp/test/PendingRoundResultTests.cs b/apps/counterstrikesharp/test/PendingRoundResultTests.cs new file mode 100644 index 00000000..305a88b1 --- /dev/null +++ b/apps/counterstrikesharp/test/PendingRoundResultTests.cs @@ -0,0 +1,64 @@ +using System.Runtime.CompilerServices; +using FiveStack; +using FiveStack.Utilities; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +// The real state and the real clear, joined the way GameBackUpRounds' constructor +// joins them: that constructor can reach native code, so the line itself is read +// in DeclinedVoteRoundTests. Only here: the snapshot holds a team enum, and the +// other plugin's enum cannot load without its native package. +public class PendingRoundResultTests : IAsyncLifetime +{ + private readonly RoundRestoreState _restore = new(); + private readonly MatchEvents _events; + + public PendingRoundResultTests() + { + _events = new MatchEvents( + NullLogger.Instance, + (EnvironmentService)RuntimeHelpers.GetUninitializedObject(typeof(EnvironmentService)), + (MatchService)RuntimeHelpers.GetUninitializedObject(typeof(MatchService)), + null!, + _restore + ); + + _restore.LoadStarted += _events.ClearPendingRoundResult; + + _events.PendingRoundResult = new MatchEvents.RoundResultSnapshot { Round = 9 }; + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + public Task DisposeAsync() + { + return _events.Disconnect(); + } + + [Fact] + public void ALoadForgetsTheRoundItUndoes() + { + _restore.StartLoad(); + + Assert.Null(_events.PendingRoundResult); + } + + [Fact] + public void AVoteLeavesItAlone() + { + _restore.StartVote(); + + Assert.NotNull(_events.PendingRoundResult); + } + + [Fact] + public void AnOwedLoadLeavesItAloneUntilItStarts() + { + _restore.StartOwing(9, Guid.NewGuid()); + + Assert.NotNull(_events.PendingRoundResult); + } +} diff --git a/apps/counterstrikesharp/test/RestoreWiringTests.cs b/apps/counterstrikesharp/test/RestoreWiringTests.cs index 9d0dc4b5..c2a828a7 100644 --- a/apps/counterstrikesharp/test/RestoreWiringTests.cs +++ b/apps/counterstrikesharp/test/RestoreWiringTests.cs @@ -79,6 +79,22 @@ public void APassedVoteAsksWhetherItOwesALoadBeforeSendingIt() Assert.True(sent > owes.Index, "the answer can beat the debt"); } + // A result captured during the vote is from a round the restore is about to + // undo, and it would publish at the next round start, before the load begins. + // A refused restore undoes nothing, so its result is real and stays. + [Fact] + public void APassedVoteForgetsTheResultOfARoundItIsAboutToUndo() + { + string passed = PassedVote(); + int refused = passed.IndexOf("return;", StringComparison.Ordinal); + int forgotten = passed.IndexOf("_matchEvents.ClearPendingRoundResult();", StringComparison.Ordinal); + int sent = passed.IndexOf("SendRestoreRoundToBackend(round);", StringComparison.Ordinal); + + Assert.True(forgotten >= 0, "a passed vote leaves the result to publish"); + Assert.True(forgotten > refused, "a refused restore throws away a real result"); + Assert.True(forgotten < sent, "the result can publish after the restore is sent"); + } + [Fact] public void AnOfflineRecoveryTurnsBackupsBackOnAndDeletesNothing() { diff --git a/apps/swiftly/src/FiveStack.Events/RoundEnd.cs b/apps/swiftly/src/FiveStack.Events/RoundEnd.cs index 3c562cf9..c3296340 100644 --- a/apps/swiftly/src/FiveStack.Events/RoundEnd.cs +++ b/apps/swiftly/src/FiveStack.Events/RoundEnd.cs @@ -36,7 +36,7 @@ public HookResult OnRoundOfficiallyEnded(EventRoundOfficiallyEnded @event) match.UpdateMapStatus(eMapStatus.Overtime); } - if (_gameBackupRounds.IsResettingRound()) + if (_gameBackupRounds.HoldsRoundResults()) { _logger.LogInformation("OnRoundOfficiallyEnded skipping capture: restoring round"); return HookResult.Continue; @@ -56,7 +56,7 @@ public HookResult OnRoundOfficiallyEnded(EventRoundOfficiallyEnded @event) [GameEventHandler(HookMode.Post)] public HookResult OnRoundEnd(EventRoundEnd @event) { - if (_gameBackupRounds.IsResettingRound()) + if (_gameBackupRounds.HoldsRoundResults()) { _logger.LogInformation( $"OnRoundEnd ignored (restoring round): message={@event.Message}" diff --git a/apps/swiftly/src/FiveStack.Events/RoundStart.cs b/apps/swiftly/src/FiveStack.Events/RoundStart.cs index c76f7140..90bf3446 100644 --- a/apps/swiftly/src/FiveStack.Events/RoundStart.cs +++ b/apps/swiftly/src/FiveStack.Events/RoundStart.cs @@ -36,7 +36,7 @@ public HookResult OnRoundStart(EventRoundStart @event) return HookResult.Continue; } - if (_gameBackupRounds.IsResettingRound()) + if (_gameBackupRounds.HoldsRoundResults()) { // A restart can unpause the round the hold paused. if (_gameBackupRounds.IsHoldingForRestore()) diff --git a/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs b/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs index 33d8dec4..ccc7009c 100644 --- a/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs +++ b/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs @@ -41,6 +41,7 @@ RoundRestoreState restore ) { _restore = restore; + _restore.LoadStarted += matchEvents.ClearPendingRoundResult; _core = core; _logger = logger; _matchEvents = matchEvents; @@ -134,6 +135,15 @@ public bool IsResettingRound() return _restore.Resetting; } + public bool HoldsRoundResults() + { + return RoundRestoreGateUtility.HoldsRoundResults( + IsResettingRound(), + restoreRoundVote?.IsVoteActive() == true, + _restore.HoldingRounds + ); + } + public bool IsHoldingForRestore() { return _restore.HoldingRounds; @@ -514,7 +524,7 @@ public void RequestRestoreBackupRound( if (player != null || vote == true) { - _restore.StartVote(); + _restore.StartVote(recovering); restoreRoundVote = _serviceProvider.GetRequiredService(typeof(VoteSystem)) as VoteSystem; @@ -550,6 +560,10 @@ public void RequestRestoreBackupRound( return; } + // A round that ended during the vote is the one being undone, + // and the next round start can come before the load does. + _matchEvents.ClearPendingRoundResult(); + if ( RoundRestoreGateUtility.OwesLoad( recovering, diff --git a/apps/swiftly/test/DeclinedVoteRoundTests.cs b/apps/swiftly/test/DeclinedVoteRoundTests.cs new file mode 100644 index 00000000..5e7961f8 --- /dev/null +++ b/apps/swiftly/test/DeclinedVoteRoundTests.cs @@ -0,0 +1,183 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using FiveStack; +using FiveStack.Utilities; +using Xunit; + +// A .reset vote leaves the round running until the next freezetime, so a round +// that ends during one is real unless the vote passes. A recovery vote is the +// other way round: the server is behind the backend, and has nothing to report. +public class DeclinedVoteRoundTests +{ + private const int Round = 4; + + private readonly RoundRestoreState _restore = new(); + + private static string SourceDir([CallerFilePath] string testFile = "") + { + return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(testFile)!, "..", "src")); + } + + private static string Code(params string[] path) + { + return Regex.Replace( + File.ReadAllText(Path.Combine(new[] { SourceDir() }.Concat(path).ToArray())), + @"//[^\n]*", + "" + ); + } + + private static void Set(object target, string field, object? value) + { + target + .GetType() + .GetField(field, BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(target, value); + } + + private static VoteSystem FinishedVote() + { + return (VoteSystem)RuntimeHelpers.GetUninitializedObject(typeof(VoteSystem)); + } + + private static VoteSystem OpenVote() + { + VoteSystem vote = FinishedVote(); + FieldInfo timer = typeof(VoteSystem).GetField( + "_playerMessageTimer", + BindingFlags.NonPublic | BindingFlags.Instance + )!; + + timer.SetValue(vote, RuntimeHelpers.GetUninitializedObject(timer.FieldType)); + + return vote; + } + + private GameBackUpRounds Backups(VoteSystem? vote = null) + { + GameBackUpRounds backups = (GameBackUpRounds) + RuntimeHelpers.GetUninitializedObject(typeof(GameBackUpRounds)); + + Set(backups, "_restore", _restore); + backups.restoreRoundVote = vote; + + return backups; + } + + [Fact] + public void ARoundEndingDuringAResetVoteIsKept() + { + _restore.StartVote(); + + Assert.False(Backups(OpenVote()).HoldsRoundResults()); + } + + [Fact] + public void NothingIsHeldWithNoRestoreGoingOn() + { + Assert.False(Backups().HoldsRoundResults()); + } + + [Fact] + public void ARecoveryVoteHoldsResultsWhileItIsOpen() + { + _restore.StartVote(recovering: true); + + Assert.True(Backups(OpenVote()).HoldsRoundResults()); + } + + [Fact] + public void ARecoveryVoteKeepsTheBackupsHeld() + { + _restore.StartVote(recovering: true); + + Assert.True(Backups(OpenVote()).IsHoldingForRestore()); + } + + [Fact] + public void AResetVoteHoldsNoBackups() + { + _restore.StartVote(); + + Assert.False(Backups(OpenVote()).IsHoldingForRestore()); + } + + [Fact] + public void ARestoreHeldForPlayersHoldsResults() + { + _restore.Hold(Round); + + Assert.True(Backups().HoldsRoundResults()); + } + + [Fact] + public void ALoadingBackupHoldsResults() + { + _restore.StartLoad(); + + Assert.True(Backups().HoldsRoundResults()); + } + + [Fact] + public void ALoadAfterAVoteFinishedHoldsResults() + { + _restore.StartLoad(); + + Assert.True(Backups(FinishedVote()).HoldsRoundResults()); + } + + [Fact] + public void NoScoreSlipsOutWhileALoadIsOwed() + { + _restore.StartOwing(Round, Guid.NewGuid()); + + Assert.True(Backups(FinishedVote()).HoldsRoundResults()); + } + + [Fact] + public void ARestoreSaysWhenItsLoadStarts() + { + int started = 0; + _restore.LoadStarted += () => started++; + + _restore.StartVote(); + _restore.End(); + _restore.StartOwing(Round, Guid.NewGuid()); + + Assert.Equal(0, started); + + _restore.StartLoad(); + + Assert.Equal(1, started); + } + + [Fact] + public void ALoadForgetsTheResultOfTheRoundItUndoes() + { + Assert.Contains( + "_restore.LoadStarted += matchEvents.ClearPendingRoundResult;", + Code("FiveStack.Services", "GameBackUpRounds.cs") + ); + } + + [Theory] + [InlineData("RoundEnd.cs", 2)] + [InlineData("RoundStart.cs", 1)] + public void TheRoundHooksAskWhetherResultsAreHeld(string file, int sites) + { + string source = Code("FiveStack.Events", file); + + Assert.DoesNotContain("IsResettingRound", source); + Assert.Equal(sites, Regex.Matches(source, @"_gameBackupRounds\.HoldsRoundResults\(\)").Count); + } + + [Fact] + public void AVoteSaysWhetherItIsARecoveryVote() + { + Assert.Contains( + "_restore.StartVote(recovering);", + Code("FiveStack.Services", "GameBackUpRounds.cs") + ); + } +} diff --git a/apps/swiftly/test/RestoreWiringTests.cs b/apps/swiftly/test/RestoreWiringTests.cs index 9d0dc4b5..c2a828a7 100644 --- a/apps/swiftly/test/RestoreWiringTests.cs +++ b/apps/swiftly/test/RestoreWiringTests.cs @@ -79,6 +79,22 @@ public void APassedVoteAsksWhetherItOwesALoadBeforeSendingIt() Assert.True(sent > owes.Index, "the answer can beat the debt"); } + // A result captured during the vote is from a round the restore is about to + // undo, and it would publish at the next round start, before the load begins. + // A refused restore undoes nothing, so its result is real and stays. + [Fact] + public void APassedVoteForgetsTheResultOfARoundItIsAboutToUndo() + { + string passed = PassedVote(); + int refused = passed.IndexOf("return;", StringComparison.Ordinal); + int forgotten = passed.IndexOf("_matchEvents.ClearPendingRoundResult();", StringComparison.Ordinal); + int sent = passed.IndexOf("SendRestoreRoundToBackend(round);", StringComparison.Ordinal); + + Assert.True(forgotten >= 0, "a passed vote leaves the result to publish"); + Assert.True(forgotten > refused, "a refused restore throws away a real result"); + Assert.True(forgotten < sent, "the result can publish after the restore is sent"); + } + [Fact] public void AnOfflineRecoveryTurnsBackupsBackOnAndDeletesNothing() { diff --git a/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs b/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs index f2227a37..ca5b7738 100644 --- a/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs @@ -12,6 +12,15 @@ public static class RoundRestoreGateUtility // Same for match_state, which the api sends to restart a map from warmup. public const string MatchStateRefusedPrefix = "match_state refused:"; + // A restore vote leaves the round running, because mp_pause_match waits for + // the next freezetime, so a round ending during one is real and is lost if + // the vote fails. A recovery vote is not that: the server is behind the + // backend, and anything it reported would land over rounds already kept. + public static bool HoldsRoundResults(bool resetting, bool voteOpen, bool recovering) + { + return resetting && (!voteOpen || recovering); + } + // Only a server behind the backend has anything to protect, and offline // nothing would ever answer. public static bool OwesLoad(bool recovering, bool offline) diff --git a/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs b/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs index b5d7e8f4..150cc88d 100644 --- a/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs +++ b/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs @@ -21,6 +21,7 @@ public class RoundRestoreState public const int OwedSeconds = 300; private bool _voting; + private bool _recoveryVote; private int _owing; private int? _owedRound; private Guid? _owedMap; @@ -38,7 +39,7 @@ public class RoundRestoreState public bool Resetting => InProgress || HeldRound != null; - public bool HoldingRounds => HeldRound != null || _owedRound != null; + public bool HoldingRounds => HeldRound != null || _recoveryVote || _owedRound != null; // Taking a hold to vote on it while something else is running loses it: // the vote is refused, and nothing puts the hold back. @@ -57,10 +58,14 @@ public void Hold(int round) return held; } - public void StartVote() + // A result waiting for the next round start is from the round this undoes. + public event Action? LoadStarted; + + public void StartVote(bool recovering = false) { End(); _voting = true; + _recoveryVote = recovering; } public int StartOwing(int round, Guid? map) @@ -77,7 +82,9 @@ public void StartLoad() _loadOwed = _owedRound; _owedRound = null; _voting = false; + _recoveryVote = false; LoadingBackup = true; + LoadStarted?.Invoke(); } // The round to hold again, if a load was owed. @@ -108,6 +115,7 @@ public OwedLoadTimeout TimedOut(int owing, Guid? map) public void End() { _voting = false; + _recoveryVote = false; _owedRound = null; _owedMap = null; _loadOwed = null; From fa577fb0e95a5a678cdadc2494b3afca7746fef5 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 2 Nov 2026 17:09:46 -0500 Subject: [PATCH 4/7] fix: backups turn back on after a round restore that could not write its file --- .../FiveStack.Services/GameBackUpRounds.cs | 33 ++++- .../test/BackupWritingTests.cs | 122 ++++++++++++++++++ .../test/RestoreWiringTests.cs | 9 ++ .../FiveStack.Services/GameBackUpRounds.cs | 19 ++- apps/swiftly/test/BackupWritingTests.cs | 122 ++++++++++++++++++ apps/swiftly/test/RestoreParityTests.cs | 46 +++++++ apps/swiftly/test/RestoreWiringTests.cs | 9 ++ .../FiveStack.Utilities/RoundRestoreState.cs | 5 +- 8 files changed, 357 insertions(+), 8 deletions(-) create mode 100644 apps/counterstrikesharp/test/BackupWritingTests.cs create mode 100644 apps/swiftly/test/BackupWritingTests.cs create mode 100644 apps/swiftly/test/RestoreParityTests.cs diff --git a/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs b/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs index 4c4f80f4..21583d31 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs @@ -208,6 +208,19 @@ private void ReturnOwedToHold() } } + // A hold turns backups off itself and offers its vote straight away, so + // nothing here may read the hold after that. + private void FailLoad() + { + if (_restore.FailedLoad() is int round) + { + ReturnToHold(round); + return; + } + + SetBackupWriting(!IsHoldingForRestore()); + } + private Guid? ActiveMapId() { MatchManager? matchManager = _matchService.GetCurrentMatch(); @@ -524,10 +537,24 @@ public void RestoreRound(int round) backupRoundFileName ); - File.WriteAllText(backupRoundFilePath, backupRound.backup_file); + int? held = _restore.ReleaseHold(); - _restore.ReleaseHold(); - _restore.StartLoad(); + _restore.StartLoad(held); + + try + { + File.WriteAllText(backupRoundFilePath, backupRound.backup_file); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed writing restore backup round file {File}", + backupRoundFileName + ); + FailLoad(); + return; + } _logger.LogInformation($"Loading backup round file {backupRoundFileName}"); diff --git a/apps/counterstrikesharp/test/BackupWritingTests.cs b/apps/counterstrikesharp/test/BackupWritingTests.cs new file mode 100644 index 00000000..6a8f3790 --- /dev/null +++ b/apps/counterstrikesharp/test/BackupWritingTests.cs @@ -0,0 +1,122 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using FiveStack.Utilities; +using Xunit; + +// A restore that could not write its file never reaches the timer that turns +// backups back on. What the state decides is tested. RestoreRound needs the engine +// from its first line, so its half of this is read. +public class BackupWritingTests +{ + private const int HeldRound = 5; + private const int Round = 8; + + private static readonly Guid Map = Guid.NewGuid(); + + private readonly RoundRestoreState _state = new(); + + private static string SourceDir([CallerFilePath] string testFile = "") + { + return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(testFile)!, "..", "src")); + } + + private static string Method(string signature) + { + string source = Regex.Replace( + File.ReadAllText(Path.Combine(SourceDir(), "FiveStack.Services", "GameBackUpRounds.cs")), + @"//[^\n]*", + "" + ); + + int from = source.IndexOf(signature, StringComparison.Ordinal); + + Assert.True(from >= 0, $"{signature} is gone"); + + int to = source.IndexOf("\n }\n", from, StringComparison.Ordinal); + + Assert.True(to > from, $"the end of {signature} was not found"); + + return source[from..to]; + } + + private static string FailedWrite() + { + string restore = Method("public void RestoreRound(int round)"); + int write = restore.IndexOf("File.WriteAllText(", StringComparison.Ordinal); + int caught = restore.IndexOf("catch (Exception", write, StringComparison.Ordinal); + int load = restore.IndexOf("mp_backup_restore_load_file", StringComparison.Ordinal); + + Assert.True(write >= 0, "RestoreRound no longer writes the backup file"); + Assert.True(caught > write && caught < load, "a failed write is not caught before the load"); + + return restore[caught..load]; + } + + [Fact] + public void AnOwedLoadWhoseWriteFailedHoldsItsRoundAgain() + { + _state.StartOwing(Round, Map); + _state.StartLoad(); + + Assert.Equal(Round, _state.FailedLoad()); + Assert.False(_state.LoadingBackup); + } + + [Fact] + public void ALoadThatTookOverAHoldPutsItBack() + { + _state.StartLoad(HeldRound); + + Assert.Equal(HeldRound, _state.FailedLoad()); + } + + [Fact] + public void AnOwedLoadKeepsItsOwnRoundOverAHoldItTookOver() + { + _state.StartOwing(Round, Map); + _state.StartLoad(HeldRound); + + Assert.Equal(Round, _state.FailedLoad()); + } + + [Fact] + public void ALoadWithNothingHeldHoldsNothing() + { + _state.StartLoad(); + + Assert.Null(_state.FailedLoad()); + } + + [Fact] + public void TheLoadIsToldWhatItTookOver() + { + string restore = Method("public void RestoreRound(int round)"); + int taken = restore.IndexOf("int? held = _restore.ReleaseHold();", StringComparison.Ordinal); + int load = restore.IndexOf("_restore.StartLoad(held);", StringComparison.Ordinal); + int write = restore.IndexOf("File.WriteAllText(", StringComparison.Ordinal); + + Assert.True(taken >= 0 && load > taken, "the hold is let go with nothing keeping it"); + Assert.True(load < write, "a failed write has nothing to put back"); + } + + [Fact] + public void AFailedWriteIsSettledOneWay() + { + Assert.Contains("FailLoad", FailedWrite()); + } + + // A hold offers its vote straight away, which empties it, so anything that + // reads the hold afterwards would turn backups on during that vote. + [Fact] + public void BackupsOnlyComeBackWhenTheFailedLoadHoldsNothing() + { + string failed = Method("private void FailLoad()"); + int asked = failed.IndexOf("_restore.FailedLoad()", StringComparison.Ordinal); + int hold = failed.IndexOf("ReturnToHold(", StringComparison.Ordinal); + int returned = failed.IndexOf("return;", hold, StringComparison.Ordinal); + int writing = failed.IndexOf("SetBackupWriting(!IsHoldingForRestore());", StringComparison.Ordinal); + + Assert.True(asked >= 0 && hold > asked, "the state is not asked before acting"); + Assert.True(returned > hold && writing > returned, "backups can come back during the vote"); + } +} diff --git a/apps/counterstrikesharp/test/RestoreWiringTests.cs b/apps/counterstrikesharp/test/RestoreWiringTests.cs index c2a828a7..bf744249 100644 --- a/apps/counterstrikesharp/test/RestoreWiringTests.cs +++ b/apps/counterstrikesharp/test/RestoreWiringTests.cs @@ -57,6 +57,15 @@ private static string PassedVote() ); } + [Fact] + public void AnOrganizerRestoreIsOnlyRefusedForAVoteOrALoadAsOnMain() + { + string refusal = Method(Backups(), "public string? RestoreRefusal(int round)"); + + Assert.Contains("_restore.VotingOrLoading,", refusal); + Assert.DoesNotContain("_restore.InProgress", refusal); + } + [Fact] public void AVoteIsLetGoAsSoonAsItPasses() { diff --git a/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs b/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs index ccc7009c..4f24e7f8 100644 --- a/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs +++ b/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs @@ -312,6 +312,19 @@ private void ReturnOwedToHold() } } + // A hold turns backups off itself and offers its vote straight away, so + // nothing here may read the hold after that. + private void FailLoad() + { + if (_restore.FailedLoad() is int round) + { + ReturnToHold(round); + return; + } + + SetBackupWriting(!IsHoldingForRestore()); + } + private Guid? ActiveMapId() { MatchManager? matchManager = _matchService.GetCurrentMatch(); @@ -760,7 +773,7 @@ public void RestoreRound(int round) return; } - _restore.ReleaseHold(); + int? held = _restore.ReleaseHold(); string backupRoundFileName = $"restore-{MatchUtility.GetSafeMatchPrefix(match)}round{round.ToString().PadLeft(2, '0')}.txt"; @@ -769,7 +782,7 @@ public void RestoreRound(int round) backupRoundFileName ); - _restore.StartLoad(); + _restore.StartLoad(held); _logger.LogInformation($"Loading backup round file {backupRoundFileName}"); @@ -788,7 +801,7 @@ public void RestoreRound(int round) "Failed writing restore backup round file {File}", backupRoundFileName ); - _core.Scheduler.NextTick(() => _restore.End()); + _core.Scheduler.NextTick(FailLoad); return; } diff --git a/apps/swiftly/test/BackupWritingTests.cs b/apps/swiftly/test/BackupWritingTests.cs new file mode 100644 index 00000000..6a8f3790 --- /dev/null +++ b/apps/swiftly/test/BackupWritingTests.cs @@ -0,0 +1,122 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using FiveStack.Utilities; +using Xunit; + +// A restore that could not write its file never reaches the timer that turns +// backups back on. What the state decides is tested. RestoreRound needs the engine +// from its first line, so its half of this is read. +public class BackupWritingTests +{ + private const int HeldRound = 5; + private const int Round = 8; + + private static readonly Guid Map = Guid.NewGuid(); + + private readonly RoundRestoreState _state = new(); + + private static string SourceDir([CallerFilePath] string testFile = "") + { + return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(testFile)!, "..", "src")); + } + + private static string Method(string signature) + { + string source = Regex.Replace( + File.ReadAllText(Path.Combine(SourceDir(), "FiveStack.Services", "GameBackUpRounds.cs")), + @"//[^\n]*", + "" + ); + + int from = source.IndexOf(signature, StringComparison.Ordinal); + + Assert.True(from >= 0, $"{signature} is gone"); + + int to = source.IndexOf("\n }\n", from, StringComparison.Ordinal); + + Assert.True(to > from, $"the end of {signature} was not found"); + + return source[from..to]; + } + + private static string FailedWrite() + { + string restore = Method("public void RestoreRound(int round)"); + int write = restore.IndexOf("File.WriteAllText(", StringComparison.Ordinal); + int caught = restore.IndexOf("catch (Exception", write, StringComparison.Ordinal); + int load = restore.IndexOf("mp_backup_restore_load_file", StringComparison.Ordinal); + + Assert.True(write >= 0, "RestoreRound no longer writes the backup file"); + Assert.True(caught > write && caught < load, "a failed write is not caught before the load"); + + return restore[caught..load]; + } + + [Fact] + public void AnOwedLoadWhoseWriteFailedHoldsItsRoundAgain() + { + _state.StartOwing(Round, Map); + _state.StartLoad(); + + Assert.Equal(Round, _state.FailedLoad()); + Assert.False(_state.LoadingBackup); + } + + [Fact] + public void ALoadThatTookOverAHoldPutsItBack() + { + _state.StartLoad(HeldRound); + + Assert.Equal(HeldRound, _state.FailedLoad()); + } + + [Fact] + public void AnOwedLoadKeepsItsOwnRoundOverAHoldItTookOver() + { + _state.StartOwing(Round, Map); + _state.StartLoad(HeldRound); + + Assert.Equal(Round, _state.FailedLoad()); + } + + [Fact] + public void ALoadWithNothingHeldHoldsNothing() + { + _state.StartLoad(); + + Assert.Null(_state.FailedLoad()); + } + + [Fact] + public void TheLoadIsToldWhatItTookOver() + { + string restore = Method("public void RestoreRound(int round)"); + int taken = restore.IndexOf("int? held = _restore.ReleaseHold();", StringComparison.Ordinal); + int load = restore.IndexOf("_restore.StartLoad(held);", StringComparison.Ordinal); + int write = restore.IndexOf("File.WriteAllText(", StringComparison.Ordinal); + + Assert.True(taken >= 0 && load > taken, "the hold is let go with nothing keeping it"); + Assert.True(load < write, "a failed write has nothing to put back"); + } + + [Fact] + public void AFailedWriteIsSettledOneWay() + { + Assert.Contains("FailLoad", FailedWrite()); + } + + // A hold offers its vote straight away, which empties it, so anything that + // reads the hold afterwards would turn backups on during that vote. + [Fact] + public void BackupsOnlyComeBackWhenTheFailedLoadHoldsNothing() + { + string failed = Method("private void FailLoad()"); + int asked = failed.IndexOf("_restore.FailedLoad()", StringComparison.Ordinal); + int hold = failed.IndexOf("ReturnToHold(", StringComparison.Ordinal); + int returned = failed.IndexOf("return;", hold, StringComparison.Ordinal); + int writing = failed.IndexOf("SetBackupWriting(!IsHoldingForRestore());", StringComparison.Ordinal); + + Assert.True(asked >= 0 && hold > asked, "the state is not asked before acting"); + Assert.True(returned > hold && writing > returned, "backups can come back during the vote"); + } +} diff --git a/apps/swiftly/test/RestoreParityTests.cs b/apps/swiftly/test/RestoreParityTests.cs new file mode 100644 index 00000000..f32a4ee9 --- /dev/null +++ b/apps/swiftly/test/RestoreParityTests.cs @@ -0,0 +1,46 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using Xunit; + +// The two plugins share RoundRestoreState, but each has its own copy of the code +// that acts on it, and a restore has to behave the same on both. +public class RestoreParityTests +{ + private static string Apps([CallerFilePath] string testFile = "") + { + return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(testFile)!, "..", "..")); + } + + private static string Method(string app, string signature) + { + string source = File.ReadAllText( + Path.Combine(Apps(), app, "src", "FiveStack.Services", "GameBackUpRounds.cs") + ); + + int from = source.IndexOf(signature, StringComparison.Ordinal); + + Assert.True(from >= 0, $"{app} has no {signature}"); + + int to = source.IndexOf("\n }\n", from, StringComparison.Ordinal); + + return Regex.Replace(source[from..to], @"\s+", " ").Replace("HudDestination.", "MessageType."); + } + + [Theory] + [InlineData("private void HoldForRestore(int round)")] + [InlineData("private void ReturnToHold(int round)")] + [InlineData("private void ReturnOwedToHold()")] + [InlineData("private void FailLoad()")] + [InlineData("private void OweLoad(int round)")] + [InlineData("private void OwedLoadTimedOut(int owing)")] + [InlineData("public void TellWhyResumeWaits()")] + [InlineData("public void AbandonRestore()")] + [InlineData("private Guid? ActiveMapId()")] + [InlineData("public bool IsResettingRound()")] + [InlineData("public bool IsHoldingForRestore()")] + [InlineData("public bool HoldsRoundResults()")] + public void BothPluginsActTheSame(string signature) + { + Assert.Equal(Method("swiftly", signature), Method("counterstrikesharp", signature)); + } +} diff --git a/apps/swiftly/test/RestoreWiringTests.cs b/apps/swiftly/test/RestoreWiringTests.cs index c2a828a7..bf744249 100644 --- a/apps/swiftly/test/RestoreWiringTests.cs +++ b/apps/swiftly/test/RestoreWiringTests.cs @@ -57,6 +57,15 @@ private static string PassedVote() ); } + [Fact] + public void AnOrganizerRestoreIsOnlyRefusedForAVoteOrALoadAsOnMain() + { + string refusal = Method(Backups(), "public string? RestoreRefusal(int round)"); + + Assert.Contains("_restore.VotingOrLoading,", refusal); + Assert.DoesNotContain("_restore.InProgress", refusal); + } + [Fact] public void AVoteIsLetGoAsSoonAsItPasses() { diff --git a/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs b/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs index 150cc88d..d29ae73f 100644 --- a/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs +++ b/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs @@ -77,9 +77,10 @@ public int StartOwing(int round, Guid? map) return ++_owing; } - public void StartLoad() + // heldRound is a hold this load took over, which a failed load puts back. + public void StartLoad(int? heldRound = null) { - _loadOwed = _owedRound; + _loadOwed = _owedRound ?? heldRound; _owedRound = null; _voting = false; _recoveryVote = false; From bca120f0fc1807b420ca099707668d13747a87b4 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 2 Nov 2026 18:04:38 -0500 Subject: [PATCH 5/7] fix: a round restore nobody answered stops resending and never outlives its match --- .../FiveStack.Services/GameBackUpRounds.cs | 27 ++++- .../src/FiveStack.Services/MatchEvents.cs | 17 +++ .../src/FiveStack.Services/MatchManager.cs | 1 + .../test/RestoreVoteTests.cs | 14 ++- .../test/RestoreWiringTests.cs | 65 ++++++++++- .../test/RoundRestorePublishTests.cs | 11 ++ .../test/RoundRestoreStateTests.cs | 104 +++++++++++++++++- .../FiveStack.Services/GameBackUpRounds.cs | 34 +++++- .../src/FiveStack.Services/MatchEvents.cs | 17 +++ .../src/FiveStack.Services/MatchManager.cs | 1 + apps/swiftly/test/RestoreParityTests.cs | 16 +++ apps/swiftly/test/RestoreVoteTests.cs | 14 ++- apps/swiftly/test/RestoreWiringTests.cs | 65 ++++++++++- apps/swiftly/test/RoundRestorePublishTests.cs | 11 ++ apps/swiftly/test/RoundRestoreStateTests.cs | 104 +++++++++++++++++- .../dotnet/FiveStack.Entities/BackupRound.cs | 3 + .../RoundRestoreGateUtility.cs | 18 +++ .../FiveStack.Utilities/RoundRestoreState.cs | 24 +++- 18 files changed, 520 insertions(+), 26 deletions(-) diff --git a/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs b/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs index 21583d31..b7d95b1e 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs @@ -126,7 +126,7 @@ public void OfferPendingRestore() // Refused for another reason: keep holding for the next player. if (!_restore.InProgress) { - _restore.Hold(round); + _restore.Hold(round, ActiveMapId()); } } @@ -177,7 +177,7 @@ public bool CheckForBackupRestore(eMapStatus status) private void HoldForRestore(int round) { - _restore.Hold(round); + _restore.Hold(round, ActiveMapId()); SetBackupWriting(false); _gameServer.SendCommands(["mp_pause_match"]); OfferPendingRestore(); @@ -245,15 +245,32 @@ private void OwedLoadTimedOut(int owing) return; } - if (timeout.HoldRound is int round) + _matchEvents.WithdrawRestoreRequests(); + + BackupRound[] rounds = + _matchService.GetCurrentMatch()?.GetCurrentMap()?.rounds ?? Array.Empty(); + + if ( + timeout.HoldRound is int round + && RoundRestoreGateUtility.RoundToHoldAgain(rounds, round) is int held + ) { - ReturnToHold(round); + ReturnToHold(held); return; } SetBackupWriting(!IsHoldingForRestore()); } + // A dedicated server keeps this state from one match to the next. + public void ForgetOtherMaps(Guid? map) + { + if (_restore.LeaveMap(map)) + { + SetBackupWriting(!IsHoldingForRestore()); + } + } + public void TellWhyResumeWaits() { string? refusal = RoundRestoreGateUtility.ResumeRefusal(_restore.OwedRound); @@ -371,7 +388,7 @@ public void RequestRestoreBackupRound( ); if (recovering) { - _restore.Hold(round); + _restore.Hold(round, ActiveMapId()); } return; } diff --git a/apps/counterstrikesharp/src/FiveStack.Services/MatchEvents.cs b/apps/counterstrikesharp/src/FiveStack.Services/MatchEvents.cs index a34642b9..be6c02af 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/MatchEvents.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/MatchEvents.cs @@ -151,6 +151,23 @@ public void PublishMapStatus(eMapStatus status, Guid? winningLineupId) PublishGameEvent("mapStatus", data); } + // The api answers whatever reaches it, however late, and an unacknowledged + // event is resent. A request given up on has to stop going out, or it can land + // on top of what the players decided next. + public void WithdrawRestoreRequests() + { + foreach (var message in _pendingMessages) + { + if ( + message.Value.Event.data?.TryGetValue("event", out object? name) == true + && name as string == "restoreRound" + ) + { + _pendingMessages.TryRemove(message.Key, out _); + } + } + } + public void PublishGameEvent(string Event, Dictionary Data) { if (RoundRestoreGateUtility.DroppedDuringLoad(Event, _restore.LoadingBackup)) diff --git a/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs b/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs index 1d125b92..7437e017 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs @@ -560,6 +560,7 @@ public void SetupMatch(MatchData match) bool wasAlreadySetup = _configuredMapId == _currentMap.id; _activeMapId = _currentMap.id; + _backUpManagement.ForgetOtherMaps(_activeMapId); if (_currentMapStatus == eMapStatus.WaitingForTV) { diff --git a/apps/counterstrikesharp/test/RestoreVoteTests.cs b/apps/counterstrikesharp/test/RestoreVoteTests.cs index 397bd3d0..46a5083e 100644 --- a/apps/counterstrikesharp/test/RestoreVoteTests.cs +++ b/apps/counterstrikesharp/test/RestoreVoteTests.cs @@ -19,6 +19,14 @@ private static void Set(object target, string field, object? value) .SetValue(target, value); } + private static object? Get(object target, string field) + { + return target + .GetType() + .GetField(field, BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(target); + } + private VoteSystem FinishedVote() { VoteSystem vote = (VoteSystem)RuntimeHelpers.GetUninitializedObject(typeof(VoteSystem)); @@ -41,9 +49,13 @@ public void AFinishedVoteCannotBeCancelledIntoItsFailure() [Fact] public void AbandoningAFinishedVoteRunsNoCallback() { - FinishedVote().Abandon(); + VoteSystem vote = FinishedVote(); + + vote.Abandon(); Assert.Equal(0, _failures); Assert.Equal(0, _passes); + Assert.Null(Get(vote, "_voteFailedCallback")); + Assert.Null(Get(vote, "_voteSuccessCallback")); } } diff --git a/apps/counterstrikesharp/test/RestoreWiringTests.cs b/apps/counterstrikesharp/test/RestoreWiringTests.cs index bf744249..4d9f094b 100644 --- a/apps/counterstrikesharp/test/RestoreWiringTests.cs +++ b/apps/counterstrikesharp/test/RestoreWiringTests.cs @@ -109,8 +109,13 @@ public void AnOfflineRecoveryTurnsBackupsBackOnAndDeletesNothing() { string passed = PassedVote(); - Assert.Contains("SetBackupWriting(true);", passed); + Assert.Matches( + @"\{\s*OweLoad\(round\);\s*\}\s*else if \(recovering\)\s*\{\s*SetBackupWriting\(true\);\s*\}", + passed + ); Assert.DoesNotContain("ReplayMap", passed); + Assert.DoesNotContain("File.Delete", passed); + Assert.DoesNotContain("Backups(", passed); } [Fact] @@ -138,9 +143,12 @@ public void AnOwedLoadIsTimedFromTheMomentItIsOwed() public void TheAnswerIsNotGatedOnWhatWasAskedFor() { string restore = Method(Backups(), "public void RestoreRound(int round)"); + string opening = restore[restore.IndexOf('{')..restore.IndexOf("if (!CanRestoreRound(round))", StringComparison.Ordinal)]; - Assert.Contains("if (_restore.VotingOrLoading)", restore); - Assert.DoesNotContain("round == 0", restore); + Assert.Contains("if (_restore.VotingOrLoading)", opening); + Assert.Single(Regex.Matches(opening, @"\bif\s*\(")); + Assert.DoesNotContain("OwedRound", restore); + Assert.DoesNotMatch(@"\bround\s*(==|!=|<|>)", restore); } [Fact] @@ -174,6 +182,57 @@ public void AnOwedLoadOnAnotherMapTurnsBackupsBackOnIfNothingHolds() ); } + [Fact] + public void AnOwedLoadThatTimedOutStopsBeingResentBeforeTheHoldReturns() + { + string timedOut = Method(Backups(), "private void OwedLoadTimedOut(int owing)"); + int withdrawn = timedOut.IndexOf("_matchEvents.WithdrawRestoreRequests();", StringComparison.Ordinal); + int held = timedOut.IndexOf("ReturnToHold(", StringComparison.Ordinal); + + Assert.True(withdrawn >= 0, "the request can land after a declined vote"); + Assert.True(withdrawn < held, "the hold can be offered with the request still going out"); + } + + [Fact] + public void AnUnansweredHoldOnlyComesBackForARoundTheBackendCanLoad() + { + Assert.Contains( + "RoundRestoreGateUtility.RoundToHoldAgain(", + Method(Backups(), "private void OwedLoadTimedOut(int owing)") + ); + } + + [Fact] + public void TheNextMatchLeavesWhatTheLastOneHeldAndPutsBackupsRight() + { + string forget = Method(Backups(), "public void ForgetOtherMaps(Guid? map)"); + int left = forget.IndexOf("_restore.LeaveMap(map)", StringComparison.Ordinal); + int writing = forget.IndexOf("SetBackupWriting(!IsHoldingForRestore());", StringComparison.Ordinal); + + Assert.True(left >= 0 && writing > left, "backups stay off with nothing holding them"); + } + + [Fact] + public void AMatchIsSetUpBeforeAnythingElseLooksAtItsHold() + { + string setup = Method(Code("FiveStack.Services", "MatchManager.cs"), "public void SetupMatch("); + int active = setup.IndexOf("_activeMapId = _currentMap.id;", StringComparison.Ordinal); + int forgotten = setup.IndexOf("_backUpManagement.ForgetOtherMaps(_activeMapId);", StringComparison.Ordinal); + int waiting = setup.IndexOf("eMapStatus.WaitingForTV", active, StringComparison.Ordinal); + + Assert.True(active >= 0 && forgotten > active, "a hold outlives its match"); + Assert.True(forgotten < waiting, "an early return skips it"); + } + + [Fact] + public void AHoldRemembersItsMapEverywhereItIsSet() + { + MatchCollection holds = Regex.Matches(Backups(), @"_restore\.Hold\(([^;]*)\);"); + + Assert.Equal(3, holds.Count); + Assert.All(holds.Select(hold => hold.Groups[1].Value), hold => Assert.EndsWith("ActiveMapId()", hold)); + } + [Fact] public void AHoldIsOnlyOfferedThroughTheState() { diff --git a/apps/counterstrikesharp/test/RoundRestorePublishTests.cs b/apps/counterstrikesharp/test/RoundRestorePublishTests.cs index 72dabe1d..ef55b7dd 100644 --- a/apps/counterstrikesharp/test/RoundRestorePublishTests.cs +++ b/apps/counterstrikesharp/test/RoundRestorePublishTests.cs @@ -112,6 +112,17 @@ public async Task ARestoreVoteKeepsPublishingTheRoundBeingPlayed(string gameEven Assert.Contains(gameEvent, await PublishThenSettle(gameEvent)); } + [Fact] + public async Task ARequestThatTimedOutStopsBeingResent() + { + Assert.Contains("kill", await PublishThenSettle("kill")); + + _events.WithdrawRestoreRequests(); + + Assert.DoesNotContain(Marker, Published()); + Assert.Contains("kill", Published()); + } + [Fact] public async Task AKillIsPublishedOnceTheLoadHasEnded() { diff --git a/apps/counterstrikesharp/test/RoundRestoreStateTests.cs b/apps/counterstrikesharp/test/RoundRestoreStateTests.cs index 36e0589c..6b17f83e 100644 --- a/apps/counterstrikesharp/test/RoundRestoreStateTests.cs +++ b/apps/counterstrikesharp/test/RoundRestoreStateTests.cs @@ -234,11 +234,35 @@ public void AnAnswerWithNothingGoingOnIsLoadedAsOnMain() } [Fact] - public void ResetToRoundZeroStaysAsOnMain() + public void ResetToRoundZeroFindsItsBackupAsOnMain() { - BackupRound zero = new() { round = 0, backup_file = "round00" }; + BackupRound first = new() { round = 0, backup_file = "round00" }; + BackupRound newest = new() { round = 0, backup_file = "round00 again" }; + BackupRound later = new() { round = Round, backup_file = "round08" }; - Assert.Same(zero, RoundRestoreGateUtility.LatestBackup(new[] { zero }, 0)); + Assert.Same(newest, RoundRestoreGateUtility.LatestBackup(new[] { first, later, newest }, 0)); + } + + [Fact] + public void ResetToRoundZeroIsRefusedOnlyForAMissingBackup() + { + Assert.Equal( + "the server has no backup of round 0", + RoundRestoreGateUtility.Refusal(false, false, eMapStatus.Live, 0, false, 10, 10, "Competitive") + ); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnOwedLoadDoesNotStopRoundZeroLoading(bool owing) + { + if (owing) + { + _state.StartOwing(Round, Map); + } + + Assert.False(_state.VotingOrLoading); Assert.Null( RoundRestoreGateUtility.Refusal( false, @@ -253,6 +277,80 @@ public void ResetToRoundZeroStaysAsOnMain() ); } + [Fact] + public void TheNextMatchEndsALoadOwedOnTheLastOne() + { + _state.StartOwing(Round, Map); + + Assert.True(_state.LeaveMap(OtherMap)); + Assert.Null(_state.OwedRound); + Assert.False(_state.Resetting); + } + + [Fact] + public void TheNextMatchEndsAHoldOnTheLastOne() + { + _state.Hold(Round, Map); + + Assert.True(_state.LeaveMap(OtherMap)); + Assert.Null(_state.HeldRound); + Assert.False(_state.HoldingRounds); + } + + [Fact] + public void SetupOnTheSameMapKeepsAHoldAndAnOwedLoad() + { + _state.Hold(Round, Map); + _state.StartOwing(Round, Map); + + Assert.False(_state.LeaveMap(Map)); + Assert.Equal(Round, _state.HeldRound); + Assert.Equal(Round, _state.OwedRound); + } + + [Fact] + public void ANewMatchWithNothingToLeaveChangesNothing() + { + Assert.False(_state.LeaveMap(OtherMap)); + } + + [Fact] + public void ARoundTheBackendKeptIsHeldAgain() + { + BackupRound[] rounds = { new() { round = Round, backup_file = "kept" } }; + + Assert.Equal(Round, RoundRestoreGateUtility.RoundToHoldAgain(rounds, Round)); + } + + [Fact] + public void ARoundOnlyOnDiskFallsBackToTheNewestTheBackendKept() + { + BackupRound[] rounds = + { + new() { round = 5, backup_file = "kept" }, + new() { round = 6, backup_file = "kept" }, + new() { round = Round, backup_file = "disk", from_disk = true }, + }; + + Assert.Equal(6, RoundRestoreGateUtility.RoundToHoldAgain(rounds, Round)); + } + + [Fact] + public void ARoundARefetchDroppedFallsBackTheSameWay() + { + BackupRound[] rounds = { new() { round = 5, backup_file = "kept" } }; + + Assert.Equal(5, RoundRestoreGateUtility.RoundToHoldAgain(rounds, Round)); + } + + [Fact] + public void NothingTheBackendCanLoadMeansNothingToHold() + { + BackupRound[] rounds = { new() { round = Round, backup_file = "disk", from_disk = true } }; + + Assert.Null(RoundRestoreGateUtility.RoundToHoldAgain(rounds, Round)); + } + [Fact] public void AnOwedLoadDoesNotRefuseAnOrganizerRestore() { diff --git a/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs b/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs index 4f24e7f8..12265ef2 100644 --- a/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs +++ b/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs @@ -185,7 +185,7 @@ public void OfferPendingRestore() // Refused for another reason: keep holding for the next player. if (!_restore.InProgress) { - _restore.Hold(round); + _restore.Hold(round, ActiveMapId()); } } @@ -250,7 +250,12 @@ public bool CheckForBackupRestore(eMapStatus status) string content = File.ReadAllText(backupFilePath); matchMap.rounds = matchMap .rounds.Append( - new BackupRound { round = highestNumber, backup_file = content } + new BackupRound + { + round = highestNumber, + backup_file = content, + from_disk = true, + } ) .ToArray(); } @@ -281,7 +286,7 @@ public bool CheckForBackupRestore(eMapStatus status) private void HoldForRestore(int round) { - _restore.Hold(round); + _restore.Hold(round, ActiveMapId()); SetBackupWriting(false); _gameServer.SendCommands(["mp_pause_match"]); OfferPendingRestore(); @@ -349,15 +354,32 @@ private void OwedLoadTimedOut(int owing) return; } - if (timeout.HoldRound is int round) + _matchEvents.WithdrawRestoreRequests(); + + BackupRound[] rounds = + _matchService.GetCurrentMatch()?.GetCurrentMap()?.rounds ?? Array.Empty(); + + if ( + timeout.HoldRound is int round + && RoundRestoreGateUtility.RoundToHoldAgain(rounds, round) is int held + ) { - ReturnToHold(round); + ReturnToHold(held); return; } SetBackupWriting(!IsHoldingForRestore()); } + // A dedicated server keeps this state from one match to the next. + public void ForgetOtherMaps(Guid? map) + { + if (_restore.LeaveMap(map)) + { + SetBackupWriting(!IsHoldingForRestore()); + } + } + public void TellWhyResumeWaits() { string? refusal = RoundRestoreGateUtility.ResumeRefusal(_restore.OwedRound); @@ -568,7 +590,7 @@ public void RequestRestoreBackupRound( ); if (recovering) { - _restore.Hold(round); + _restore.Hold(round, ActiveMapId()); } return; } diff --git a/apps/swiftly/src/FiveStack.Services/MatchEvents.cs b/apps/swiftly/src/FiveStack.Services/MatchEvents.cs index 6efca4ac..4d08de75 100644 --- a/apps/swiftly/src/FiveStack.Services/MatchEvents.cs +++ b/apps/swiftly/src/FiveStack.Services/MatchEvents.cs @@ -157,6 +157,23 @@ public void PublishMapStatus(eMapStatus status, Guid? winningLineupId) PublishGameEvent("mapStatus", data); } + // The api answers whatever reaches it, however late, and an unacknowledged + // event is resent. A request given up on has to stop going out, or it can land + // on top of what the players decided next. + public void WithdrawRestoreRequests() + { + foreach (var message in _pendingMessages) + { + if ( + message.Value.Event.data?.TryGetValue("event", out object? name) == true + && name as string == "restoreRound" + ) + { + _pendingMessages.TryRemove(message.Key, out _); + } + } + } + public void PublishGameEvent(string Event, Dictionary Data) { if (RoundRestoreGateUtility.DroppedDuringLoad(Event, _restore.LoadingBackup)) diff --git a/apps/swiftly/src/FiveStack.Services/MatchManager.cs b/apps/swiftly/src/FiveStack.Services/MatchManager.cs index 9d0a331a..1fc7cf6d 100644 --- a/apps/swiftly/src/FiveStack.Services/MatchManager.cs +++ b/apps/swiftly/src/FiveStack.Services/MatchManager.cs @@ -547,6 +547,7 @@ public void SetupMatch(MatchData match) bool wasAlreadySetup = _configuredMapId == _currentMap.id; _activeMapId = _currentMap.id; + _backUpManagement.ForgetOtherMaps(_activeMapId); if (_currentMapStatus == eMapStatus.WaitingForTV) { diff --git a/apps/swiftly/test/RestoreParityTests.cs b/apps/swiftly/test/RestoreParityTests.cs index f32a4ee9..f01fd85f 100644 --- a/apps/swiftly/test/RestoreParityTests.cs +++ b/apps/swiftly/test/RestoreParityTests.cs @@ -26,6 +26,21 @@ private static string Method(string app, string signature) return Regex.Replace(source[from..to], @"\s+", " ").Replace("HudDestination.", "MessageType."); } + // Only this plugin reads backups off disk, and the backend has never heard of + // a round it only found there. + [Fact] + public void ARoundFoundOnlyOnDiskIsMarked() + { + string source = File.ReadAllText( + Path.Combine(Apps(), "swiftly", "src", "FiveStack.Services", "GameBackUpRounds.cs") + ); + + Assert.Matches( + @"new BackupRound\s*\{[^}]*round = highestNumber[^}]*from_disk = true[^}]*\}", + source + ); + } + [Theory] [InlineData("private void HoldForRestore(int round)")] [InlineData("private void ReturnToHold(int round)")] @@ -36,6 +51,7 @@ private static string Method(string app, string signature) [InlineData("public void TellWhyResumeWaits()")] [InlineData("public void AbandonRestore()")] [InlineData("private Guid? ActiveMapId()")] + [InlineData("public void ForgetOtherMaps(Guid? map)")] [InlineData("public bool IsResettingRound()")] [InlineData("public bool IsHoldingForRestore()")] [InlineData("public bool HoldsRoundResults()")] diff --git a/apps/swiftly/test/RestoreVoteTests.cs b/apps/swiftly/test/RestoreVoteTests.cs index 397bd3d0..46a5083e 100644 --- a/apps/swiftly/test/RestoreVoteTests.cs +++ b/apps/swiftly/test/RestoreVoteTests.cs @@ -19,6 +19,14 @@ private static void Set(object target, string field, object? value) .SetValue(target, value); } + private static object? Get(object target, string field) + { + return target + .GetType() + .GetField(field, BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(target); + } + private VoteSystem FinishedVote() { VoteSystem vote = (VoteSystem)RuntimeHelpers.GetUninitializedObject(typeof(VoteSystem)); @@ -41,9 +49,13 @@ public void AFinishedVoteCannotBeCancelledIntoItsFailure() [Fact] public void AbandoningAFinishedVoteRunsNoCallback() { - FinishedVote().Abandon(); + VoteSystem vote = FinishedVote(); + + vote.Abandon(); Assert.Equal(0, _failures); Assert.Equal(0, _passes); + Assert.Null(Get(vote, "_voteFailedCallback")); + Assert.Null(Get(vote, "_voteSuccessCallback")); } } diff --git a/apps/swiftly/test/RestoreWiringTests.cs b/apps/swiftly/test/RestoreWiringTests.cs index bf744249..4d9f094b 100644 --- a/apps/swiftly/test/RestoreWiringTests.cs +++ b/apps/swiftly/test/RestoreWiringTests.cs @@ -109,8 +109,13 @@ public void AnOfflineRecoveryTurnsBackupsBackOnAndDeletesNothing() { string passed = PassedVote(); - Assert.Contains("SetBackupWriting(true);", passed); + Assert.Matches( + @"\{\s*OweLoad\(round\);\s*\}\s*else if \(recovering\)\s*\{\s*SetBackupWriting\(true\);\s*\}", + passed + ); Assert.DoesNotContain("ReplayMap", passed); + Assert.DoesNotContain("File.Delete", passed); + Assert.DoesNotContain("Backups(", passed); } [Fact] @@ -138,9 +143,12 @@ public void AnOwedLoadIsTimedFromTheMomentItIsOwed() public void TheAnswerIsNotGatedOnWhatWasAskedFor() { string restore = Method(Backups(), "public void RestoreRound(int round)"); + string opening = restore[restore.IndexOf('{')..restore.IndexOf("if (!CanRestoreRound(round))", StringComparison.Ordinal)]; - Assert.Contains("if (_restore.VotingOrLoading)", restore); - Assert.DoesNotContain("round == 0", restore); + Assert.Contains("if (_restore.VotingOrLoading)", opening); + Assert.Single(Regex.Matches(opening, @"\bif\s*\(")); + Assert.DoesNotContain("OwedRound", restore); + Assert.DoesNotMatch(@"\bround\s*(==|!=|<|>)", restore); } [Fact] @@ -174,6 +182,57 @@ public void AnOwedLoadOnAnotherMapTurnsBackupsBackOnIfNothingHolds() ); } + [Fact] + public void AnOwedLoadThatTimedOutStopsBeingResentBeforeTheHoldReturns() + { + string timedOut = Method(Backups(), "private void OwedLoadTimedOut(int owing)"); + int withdrawn = timedOut.IndexOf("_matchEvents.WithdrawRestoreRequests();", StringComparison.Ordinal); + int held = timedOut.IndexOf("ReturnToHold(", StringComparison.Ordinal); + + Assert.True(withdrawn >= 0, "the request can land after a declined vote"); + Assert.True(withdrawn < held, "the hold can be offered with the request still going out"); + } + + [Fact] + public void AnUnansweredHoldOnlyComesBackForARoundTheBackendCanLoad() + { + Assert.Contains( + "RoundRestoreGateUtility.RoundToHoldAgain(", + Method(Backups(), "private void OwedLoadTimedOut(int owing)") + ); + } + + [Fact] + public void TheNextMatchLeavesWhatTheLastOneHeldAndPutsBackupsRight() + { + string forget = Method(Backups(), "public void ForgetOtherMaps(Guid? map)"); + int left = forget.IndexOf("_restore.LeaveMap(map)", StringComparison.Ordinal); + int writing = forget.IndexOf("SetBackupWriting(!IsHoldingForRestore());", StringComparison.Ordinal); + + Assert.True(left >= 0 && writing > left, "backups stay off with nothing holding them"); + } + + [Fact] + public void AMatchIsSetUpBeforeAnythingElseLooksAtItsHold() + { + string setup = Method(Code("FiveStack.Services", "MatchManager.cs"), "public void SetupMatch("); + int active = setup.IndexOf("_activeMapId = _currentMap.id;", StringComparison.Ordinal); + int forgotten = setup.IndexOf("_backUpManagement.ForgetOtherMaps(_activeMapId);", StringComparison.Ordinal); + int waiting = setup.IndexOf("eMapStatus.WaitingForTV", active, StringComparison.Ordinal); + + Assert.True(active >= 0 && forgotten > active, "a hold outlives its match"); + Assert.True(forgotten < waiting, "an early return skips it"); + } + + [Fact] + public void AHoldRemembersItsMapEverywhereItIsSet() + { + MatchCollection holds = Regex.Matches(Backups(), @"_restore\.Hold\(([^;]*)\);"); + + Assert.Equal(3, holds.Count); + Assert.All(holds.Select(hold => hold.Groups[1].Value), hold => Assert.EndsWith("ActiveMapId()", hold)); + } + [Fact] public void AHoldIsOnlyOfferedThroughTheState() { diff --git a/apps/swiftly/test/RoundRestorePublishTests.cs b/apps/swiftly/test/RoundRestorePublishTests.cs index 72dabe1d..ef55b7dd 100644 --- a/apps/swiftly/test/RoundRestorePublishTests.cs +++ b/apps/swiftly/test/RoundRestorePublishTests.cs @@ -112,6 +112,17 @@ public async Task ARestoreVoteKeepsPublishingTheRoundBeingPlayed(string gameEven Assert.Contains(gameEvent, await PublishThenSettle(gameEvent)); } + [Fact] + public async Task ARequestThatTimedOutStopsBeingResent() + { + Assert.Contains("kill", await PublishThenSettle("kill")); + + _events.WithdrawRestoreRequests(); + + Assert.DoesNotContain(Marker, Published()); + Assert.Contains("kill", Published()); + } + [Fact] public async Task AKillIsPublishedOnceTheLoadHasEnded() { diff --git a/apps/swiftly/test/RoundRestoreStateTests.cs b/apps/swiftly/test/RoundRestoreStateTests.cs index 36e0589c..6b17f83e 100644 --- a/apps/swiftly/test/RoundRestoreStateTests.cs +++ b/apps/swiftly/test/RoundRestoreStateTests.cs @@ -234,11 +234,35 @@ public void AnAnswerWithNothingGoingOnIsLoadedAsOnMain() } [Fact] - public void ResetToRoundZeroStaysAsOnMain() + public void ResetToRoundZeroFindsItsBackupAsOnMain() { - BackupRound zero = new() { round = 0, backup_file = "round00" }; + BackupRound first = new() { round = 0, backup_file = "round00" }; + BackupRound newest = new() { round = 0, backup_file = "round00 again" }; + BackupRound later = new() { round = Round, backup_file = "round08" }; - Assert.Same(zero, RoundRestoreGateUtility.LatestBackup(new[] { zero }, 0)); + Assert.Same(newest, RoundRestoreGateUtility.LatestBackup(new[] { first, later, newest }, 0)); + } + + [Fact] + public void ResetToRoundZeroIsRefusedOnlyForAMissingBackup() + { + Assert.Equal( + "the server has no backup of round 0", + RoundRestoreGateUtility.Refusal(false, false, eMapStatus.Live, 0, false, 10, 10, "Competitive") + ); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AnOwedLoadDoesNotStopRoundZeroLoading(bool owing) + { + if (owing) + { + _state.StartOwing(Round, Map); + } + + Assert.False(_state.VotingOrLoading); Assert.Null( RoundRestoreGateUtility.Refusal( false, @@ -253,6 +277,80 @@ public void ResetToRoundZeroStaysAsOnMain() ); } + [Fact] + public void TheNextMatchEndsALoadOwedOnTheLastOne() + { + _state.StartOwing(Round, Map); + + Assert.True(_state.LeaveMap(OtherMap)); + Assert.Null(_state.OwedRound); + Assert.False(_state.Resetting); + } + + [Fact] + public void TheNextMatchEndsAHoldOnTheLastOne() + { + _state.Hold(Round, Map); + + Assert.True(_state.LeaveMap(OtherMap)); + Assert.Null(_state.HeldRound); + Assert.False(_state.HoldingRounds); + } + + [Fact] + public void SetupOnTheSameMapKeepsAHoldAndAnOwedLoad() + { + _state.Hold(Round, Map); + _state.StartOwing(Round, Map); + + Assert.False(_state.LeaveMap(Map)); + Assert.Equal(Round, _state.HeldRound); + Assert.Equal(Round, _state.OwedRound); + } + + [Fact] + public void ANewMatchWithNothingToLeaveChangesNothing() + { + Assert.False(_state.LeaveMap(OtherMap)); + } + + [Fact] + public void ARoundTheBackendKeptIsHeldAgain() + { + BackupRound[] rounds = { new() { round = Round, backup_file = "kept" } }; + + Assert.Equal(Round, RoundRestoreGateUtility.RoundToHoldAgain(rounds, Round)); + } + + [Fact] + public void ARoundOnlyOnDiskFallsBackToTheNewestTheBackendKept() + { + BackupRound[] rounds = + { + new() { round = 5, backup_file = "kept" }, + new() { round = 6, backup_file = "kept" }, + new() { round = Round, backup_file = "disk", from_disk = true }, + }; + + Assert.Equal(6, RoundRestoreGateUtility.RoundToHoldAgain(rounds, Round)); + } + + [Fact] + public void ARoundARefetchDroppedFallsBackTheSameWay() + { + BackupRound[] rounds = { new() { round = 5, backup_file = "kept" } }; + + Assert.Equal(5, RoundRestoreGateUtility.RoundToHoldAgain(rounds, Round)); + } + + [Fact] + public void NothingTheBackendCanLoadMeansNothingToHold() + { + BackupRound[] rounds = { new() { round = Round, backup_file = "disk", from_disk = true } }; + + Assert.Null(RoundRestoreGateUtility.RoundToHoldAgain(rounds, Round)); + } + [Fact] public void AnOwedLoadDoesNotRefuseAnOrganizerRestore() { diff --git a/shared/dotnet/FiveStack.Entities/BackupRound.cs b/shared/dotnet/FiveStack.Entities/BackupRound.cs index 61b08170..268c6258 100644 --- a/shared/dotnet/FiveStack.Entities/BackupRound.cs +++ b/shared/dotnet/FiveStack.Entities/BackupRound.cs @@ -5,4 +5,7 @@ public class BackupRound public int round { get; set; } public string deleted_at { get; set; } = ""; public string backup_file { get; set; } = ""; + + // Found on this server's disk, so the backend has never heard of it. + public bool from_disk { get; set; } } diff --git a/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs b/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs index ca5b7738..6408a7a5 100644 --- a/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs @@ -28,6 +28,24 @@ public static bool OwesLoad(bool recovering, bool offline) return recovering && !offline; } + // The api refuses a round it never recorded and tells nobody, so a hold on + // one would come back for ever, and a refetch drops the row its vote needs. + // A hold that went unanswered moves to the newest round the backend can + // load. Null when it has none: there is nothing there to play over. + public static int? RoundToHoldAgain(IEnumerable rounds, int round) + { + List kept = rounds.Where(backup => !backup.from_disk).ToList(); + + if (LatestBackup(kept, round) != null) + { + return round; + } + + int newest = HighestRestorableRound(kept, 0); + + return newest > 0 ? newest : null; + } + public static string? ResumeRefusal(int? owedRound) { return owedRound == null diff --git a/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs b/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs index d29ae73f..c4660222 100644 --- a/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs +++ b/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs @@ -25,6 +25,7 @@ public class RoundRestoreState private int _owing; private int? _owedRound; private Guid? _owedMap; + private Guid? _heldMap; private int? _loadOwed; public int? HeldRound { get; private set; } @@ -45,9 +46,30 @@ public class RoundRestoreState // the vote is refused, and nothing puts the hold back. public bool CanOfferVote => HeldRound != null && !InProgress; - public void Hold(int round) + public void Hold(int round, Guid? map = null) { HeldRound = round; + _heldMap = map; + } + + // Says whether anything went, so the caller can put backups right. + public bool LeaveMap(Guid? map) + { + bool left = false; + + if (_owedRound != null && _owedMap != map) + { + End(); + left = true; + } + + if (HeldRound != null && _heldMap != map) + { + HeldRound = null; + left = true; + } + + return left; } public int? ReleaseHold() From e473e3f4a763708ea51bdabd495c0f15d8953f28 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 2 Nov 2026 18:53:39 -0500 Subject: [PATCH 6/7] fix: a restore only offers rounds the backend can load, and a vote ends with its map A disk file above the backend's rows could never restore, because the api raises on a round with no row and answers nothing. Such files are now ignored when choosing the round to hold, which removes from_disk and RoundToHoldAgain. A file can still fill in a row that exists, and offline keeps its disk rounds. A hold no longer comes back for a round whose backup is gone: it ends and the players are told. An open vote is abandoned when its map changes. --- .../FiveStack.Services/GameBackUpRounds.cs | 50 ++++++++---- .../test/DeclinedVoteRoundTests.cs | 2 +- .../test/RestoreWiringTests.cs | 57 ++++++++++--- .../test/RoundRestoreStateTests.cs | 80 +++++++++++++++---- .../FiveStack.Services/GameBackUpRounds.cs | 74 ++++++++++------- apps/swiftly/test/DeclinedVoteRoundTests.cs | 2 +- apps/swiftly/test/RestoreParityTests.cs | 13 ++- apps/swiftly/test/RestoreWiringTests.cs | 57 ++++++++++--- apps/swiftly/test/RoundRestoreStateTests.cs | 80 +++++++++++++++---- .../dotnet/FiveStack.Entities/BackupRound.cs | 3 - .../RoundRestoreGateUtility.cs | 27 ++++--- .../FiveStack.Utilities/RoundRestoreState.cs | 13 +-- 12 files changed, 326 insertions(+), 132 deletions(-) diff --git a/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs b/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs index b7d95b1e..eb177680 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs @@ -124,7 +124,7 @@ public void OfferPendingRestore() RequestRestoreBackupRound(round, null, true, recovering: true); // Refused for another reason: keep holding for the next player. - if (!_restore.InProgress) + if (!_restore.InProgress && CanHoldAgain(round)) { _restore.Hold(round, ActiveMapId()); } @@ -187,12 +187,8 @@ private void HoldForRestore(int round) // replay: only players deciding that gives up rounds the backend recorded. private void ReturnToHold(int round) { - eMapStatus status = - _matchService.GetCurrentMatch()?.GetCurrentMapStatus() ?? eMapStatus.Unknown; - - if (!RoundRestoreGateUtility.IsInPlay(status)) + if (!CanHoldAgain(round)) { - SetBackupWriting(!IsHoldingForRestore()); return; } @@ -200,6 +196,32 @@ private void ReturnToHold(int round) HoldForRestore(round); } + private bool CanHoldAgain(int round) + { + MatchManager? matchManager = _matchService.GetCurrentMatch(); + eMapStatus status = matchManager?.GetCurrentMapStatus() ?? eMapStatus.Unknown; + BackupRound[] rounds = matchManager?.GetCurrentMap()?.rounds ?? Array.Empty(); + + if ( + RoundRestoreGateUtility.HoldsAgain( + status, + RoundRestoreGateUtility.LatestBackup(rounds, round) != null + ) + ) + { + return true; + } + + if (RoundRestoreGateUtility.IsInPlay(status)) + { + _gameServer.Message(HudDestination.Alert, RoundRestoreGateUtility.BackupGone(round)); + } + + SetBackupWriting(!IsHoldingForRestore()); + + return false; + } + private void ReturnOwedToHold() { if (_restore.FailedLoad() is int round) @@ -247,15 +269,9 @@ private void OwedLoadTimedOut(int owing) _matchEvents.WithdrawRestoreRequests(); - BackupRound[] rounds = - _matchService.GetCurrentMatch()?.GetCurrentMap()?.rounds ?? Array.Empty(); - - if ( - timeout.HoldRound is int round - && RoundRestoreGateUtility.RoundToHoldAgain(rounds, round) is int held - ) + if (timeout.HoldRound is int round) { - ReturnToHold(held); + ReturnToHold(round); return; } @@ -267,6 +283,8 @@ public void ForgetOtherMaps(Guid? map) { if (_restore.LeaveMap(map)) { + restoreRoundVote?.Abandon(); + restoreRoundVote = null; SetBackupWriting(!IsHoldingForRestore()); } } @@ -357,7 +375,7 @@ public void RequestRestoreBackupRound( if (player != null || vote == true) { - _restore.StartVote(recovering); + _restore.StartVote(recovering, ActiveMapId()); restoreRoundVote = _serviceProvider.GetRequiredService(typeof(VoteSystem)) as VoteSystem; @@ -386,7 +404,7 @@ public void RequestRestoreBackupRound( HudDestination.Alert, $" Restore round blocked: {refusal}." ); - if (recovering) + if (recovering && CanHoldAgain(round)) { _restore.Hold(round, ActiveMapId()); } diff --git a/apps/counterstrikesharp/test/DeclinedVoteRoundTests.cs b/apps/counterstrikesharp/test/DeclinedVoteRoundTests.cs index 5e7961f8..17594e23 100644 --- a/apps/counterstrikesharp/test/DeclinedVoteRoundTests.cs +++ b/apps/counterstrikesharp/test/DeclinedVoteRoundTests.cs @@ -176,7 +176,7 @@ public void TheRoundHooksAskWhetherResultsAreHeld(string file, int sites) public void AVoteSaysWhetherItIsARecoveryVote() { Assert.Contains( - "_restore.StartVote(recovering);", + "_restore.StartVote(recovering, ", Code("FiveStack.Services", "GameBackUpRounds.cs") ); } diff --git a/apps/counterstrikesharp/test/RestoreWiringTests.cs b/apps/counterstrikesharp/test/RestoreWiringTests.cs index 4d9f094b..d296e0b8 100644 --- a/apps/counterstrikesharp/test/RestoreWiringTests.cs +++ b/apps/counterstrikesharp/test/RestoreWiringTests.cs @@ -164,13 +164,53 @@ public void EveryWayOutOfRestoreRoundSettlesAnOwedLoad() } [Fact] - public void AHoldOnlyComesBackOnAMapStillBeingPlayed() + public void AHoldOnlyComesBackForARoundThatCanStillLoad() + { + string check = Method(Backups(), "private bool CanHoldAgain(int round)"); + int asked = check.IndexOf("RoundRestoreGateUtility.HoldsAgain(", StringComparison.Ordinal); + int told = check.IndexOf("RoundRestoreGateUtility.BackupGone(round)", StringComparison.Ordinal); + int writing = check.IndexOf("SetBackupWriting(!IsHoldingForRestore());", StringComparison.Ordinal); + + Assert.Contains("RoundRestoreGateUtility.LatestBackup(", check); + Assert.True(asked >= 0 && told > asked, "players aren't told why the hold ended"); + Assert.True(writing > told, "backups stay off with nothing holding them"); + } + + [Fact] + public void EveryHoldThatComesBackChecksItCanStillLoad() { string hold = Method(Backups(), "private void ReturnToHold(int round)"); - int played = hold.IndexOf("RoundRestoreGateUtility.IsInPlay(", StringComparison.Ordinal); - int held = hold.IndexOf("HoldForRestore(round);", StringComparison.Ordinal); + string offer = Method(Backups(), "public void OfferPendingRestore()"); + string vote = Method(Backups(), "public void RequestRestoreBackupRound("); + + Assert.True( + hold.IndexOf("CanHoldAgain(round)", StringComparison.Ordinal) >= 0 + && hold.IndexOf("CanHoldAgain(round)", StringComparison.Ordinal) + < hold.IndexOf("HoldForRestore(round);", StringComparison.Ordinal), + "a hold comes back for a round that can't load" + ); + Assert.Matches(@"CanHoldAgain\(round\)\s*\)\s*\{\s*_restore\.Hold\(", offer); + Assert.Matches(@"CanHoldAgain\(round\)\s*\)\s*\{\s*_restore\.Hold\(", vote); + } + + [Fact] + public void AnOpenVoteIsAbandonedWithItsMap() + { + string forget = Method(Backups(), "public void ForgetOtherMaps(Guid? map)"); + int left = forget.IndexOf("_restore.LeaveMap(map)", StringComparison.Ordinal); + int abandoned = forget.IndexOf("restoreRoundVote?.Abandon();", StringComparison.Ordinal); + int cleared = forget.IndexOf("restoreRoundVote = null;", StringComparison.Ordinal); + int writing = forget.IndexOf("SetBackupWriting(!IsHoldingForRestore());", StringComparison.Ordinal); - Assert.True(played >= 0 && held > played, "a hold can come back on a map that is over"); + Assert.True(abandoned > left, "a vote outlives its map"); + Assert.True(cleared > abandoned && writing > cleared, "backups are put right too early"); + Assert.DoesNotContain("Withdraw", forget); + } + + [Fact] + public void AVoteRemembersItsMap() + { + Assert.Contains("_restore.StartVote(recovering, ActiveMapId());", Backups()); } [Fact] @@ -193,15 +233,6 @@ public void AnOwedLoadThatTimedOutStopsBeingResentBeforeTheHoldReturns() Assert.True(withdrawn < held, "the hold can be offered with the request still going out"); } - [Fact] - public void AnUnansweredHoldOnlyComesBackForARoundTheBackendCanLoad() - { - Assert.Contains( - "RoundRestoreGateUtility.RoundToHoldAgain(", - Method(Backups(), "private void OwedLoadTimedOut(int owing)") - ); - } - [Fact] public void TheNextMatchLeavesWhatTheLastOneHeldAndPutsBackupsRight() { diff --git a/apps/counterstrikesharp/test/RoundRestoreStateTests.cs b/apps/counterstrikesharp/test/RoundRestoreStateTests.cs index 6b17f83e..40ce1921 100644 --- a/apps/counterstrikesharp/test/RoundRestoreStateTests.cs +++ b/apps/counterstrikesharp/test/RoundRestoreStateTests.cs @@ -314,41 +314,91 @@ public void ANewMatchWithNothingToLeaveChangesNothing() Assert.False(_state.LeaveMap(OtherMap)); } - [Fact] - public void ARoundTheBackendKeptIsHeldAgain() + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AnOpenVoteEndsWithItsMap(bool recovering) { - BackupRound[] rounds = { new() { round = Round, backup_file = "kept" } }; + _state.StartVote(recovering, Map); - Assert.Equal(Round, RoundRestoreGateUtility.RoundToHoldAgain(rounds, Round)); + Assert.True(_state.LeaveMap(OtherMap)); + Assert.False(_state.VotingOrLoading); + Assert.False(_state.HoldingRounds); + Assert.False(_state.Resetting); } [Fact] - public void ARoundOnlyOnDiskFallsBackToTheNewestTheBackendKept() + public void AnOpenVoteStaysOnItsOwnMap() + { + _state.StartVote(map: Map); + + Assert.False(_state.LeaveMap(Map)); + Assert.True(_state.VotingOrLoading); + } + + [Theory] + [InlineData(5, true)] + [InlineData(4, true)] + [InlineData(6, false)] + [InlineData(Round, false)] + [InlineData(3, false)] + public void OnlyADiskRoundTheBackendHasARowForIsUsable(int round, bool usable) { BackupRound[] rounds = { - new() { round = 5, backup_file = "kept" }, - new() { round = 6, backup_file = "kept" }, - new() { round = Round, backup_file = "disk", from_disk = true }, + new() { round = 4, backup_file = "kept" }, + new() { round = 5 }, }; - Assert.Equal(6, RoundRestoreGateUtility.RoundToHoldAgain(rounds, Round)); + Assert.Equal(usable, RoundRestoreGateUtility.IsUsableOnDisk(rounds, round, false)); } [Fact] - public void ARoundARefetchDroppedFallsBackTheSameWay() + public void WithNoRowThereIsNothingOnDiskToUse() { - BackupRound[] rounds = { new() { round = 5, backup_file = "kept" } }; + Assert.False( + RoundRestoreGateUtility.IsUsableOnDisk(Array.Empty(), Round, false) + ); + } - Assert.Equal(5, RoundRestoreGateUtility.RoundToHoldAgain(rounds, Round)); + [Fact] + public void ARowARestoreUndidStaysUnusable() + { + BackupRound[] rounds = { new() { round = Round, deleted_at = "2026-11-02" } }; + + Assert.False(RoundRestoreGateUtility.IsUsableOnDisk(rounds, Round, false)); } [Fact] - public void NothingTheBackendCanLoadMeansNothingToHold() + public void OfflineKeepsItsDiskRoundsAsOnMain() + { + BackupRound[] undone = { new() { round = 5, deleted_at = "2026-11-02" } }; + + Assert.True( + RoundRestoreGateUtility.IsUsableOnDisk(Array.Empty(), Round, true) + ); + Assert.False(RoundRestoreGateUtility.IsUsableOnDisk(undone, 5, true)); + } + + [Theory] + [InlineData(eMapStatus.Live, true, true)] + [InlineData(eMapStatus.Paused, true, true)] + [InlineData(eMapStatus.Live, false, false)] + [InlineData(eMapStatus.Warmup, true, false)] + [InlineData(eMapStatus.Finished, true, false)] + public void AHoldOnlyComesBackForARoundThatCanStillLoad( + eMapStatus status, + bool hasBackup, + bool holds + ) { - BackupRound[] rounds = { new() { round = Round, backup_file = "disk", from_disk = true } }; + Assert.Equal(holds, RoundRestoreGateUtility.HoldsAgain(status, hasBackup)); + } - Assert.Null(RoundRestoreGateUtility.RoundToHoldAgain(rounds, Round)); + [Fact] + public void PlayersAreToldWhenARoundCanNoLongerBeRestored() + { + Assert.Contains(Round.ToString(), RoundRestoreGateUtility.BackupGone(Round)); } [Fact] diff --git a/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs b/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs index 12265ef2..ea7aa435 100644 --- a/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs +++ b/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs @@ -183,7 +183,7 @@ public void OfferPendingRestore() RequestRestoreBackupRound(round, null, true, recovering: true); // Refused for another reason: keep holding for the next player. - if (!_restore.InProgress) + if (!_restore.InProgress && CanHoldAgain(round)) { _restore.Hold(round, ActiveMapId()); } @@ -200,17 +200,22 @@ public bool CheckForBackupRestore(eMapStatus status) return false; } - // The backup files on disk (written by mp_backup_round_auto) hold rounds - // the backend may not have recorded, as with offline match data. A server - // that came back on another node or a fresh pod has none, only the - // backups the backend kept. + // The backup files on disk (written by mp_backup_round_auto) can fill in a + // round the backend recorded without one, and are all offline match data + // has. A server that came back on another node or a fresh pod has none, + // only the backups the backend kept. string csgoDir = BackupDirectory; string prefix = MatchUtility.GetSafeMatchPrefix(match); int highestOnDisk = GetHighestBackupRoundOnDisk( csgoDir, prefix, - round => RoundRestoreGateUtility.IsUndone(matchMap.rounds, round) + round => + !RoundRestoreGateUtility.IsUsableOnDisk( + matchMap.rounds, + round, + _environmentService.IsOfflineMode() + ) ); int highestNumber = RoundRestoreGateUtility.HighestRestorableRound( matchMap.rounds, @@ -249,14 +254,7 @@ public bool CheckForBackupRestore(eMapStatus status) { string content = File.ReadAllText(backupFilePath); matchMap.rounds = matchMap - .rounds.Append( - new BackupRound - { - round = highestNumber, - backup_file = content, - from_disk = true, - } - ) + .rounds.Append(new BackupRound { round = highestNumber, backup_file = content }) .ToArray(); } } @@ -296,12 +294,8 @@ private void HoldForRestore(int round) // replay: only players deciding that gives up rounds the backend recorded. private void ReturnToHold(int round) { - eMapStatus status = - _matchService.GetCurrentMatch()?.GetCurrentMapStatus() ?? eMapStatus.Unknown; - - if (!RoundRestoreGateUtility.IsInPlay(status)) + if (!CanHoldAgain(round)) { - SetBackupWriting(!IsHoldingForRestore()); return; } @@ -309,6 +303,32 @@ private void ReturnToHold(int round) HoldForRestore(round); } + private bool CanHoldAgain(int round) + { + MatchManager? matchManager = _matchService.GetCurrentMatch(); + eMapStatus status = matchManager?.GetCurrentMapStatus() ?? eMapStatus.Unknown; + BackupRound[] rounds = matchManager?.GetCurrentMap()?.rounds ?? Array.Empty(); + + if ( + RoundRestoreGateUtility.HoldsAgain( + status, + RoundRestoreGateUtility.LatestBackup(rounds, round) != null + ) + ) + { + return true; + } + + if (RoundRestoreGateUtility.IsInPlay(status)) + { + _gameServer.Message(MessageType.Alert, RoundRestoreGateUtility.BackupGone(round)); + } + + SetBackupWriting(!IsHoldingForRestore()); + + return false; + } + private void ReturnOwedToHold() { if (_restore.FailedLoad() is int round) @@ -356,15 +376,9 @@ private void OwedLoadTimedOut(int owing) _matchEvents.WithdrawRestoreRequests(); - BackupRound[] rounds = - _matchService.GetCurrentMatch()?.GetCurrentMap()?.rounds ?? Array.Empty(); - - if ( - timeout.HoldRound is int round - && RoundRestoreGateUtility.RoundToHoldAgain(rounds, round) is int held - ) + if (timeout.HoldRound is int round) { - ReturnToHold(held); + ReturnToHold(round); return; } @@ -376,6 +390,8 @@ public void ForgetOtherMaps(Guid? map) { if (_restore.LeaveMap(map)) { + restoreRoundVote?.Abandon(); + restoreRoundVote = null; SetBackupWriting(!IsHoldingForRestore()); } } @@ -559,7 +575,7 @@ public void RequestRestoreBackupRound( if (player != null || vote == true) { - _restore.StartVote(recovering); + _restore.StartVote(recovering, ActiveMapId()); restoreRoundVote = _serviceProvider.GetRequiredService(typeof(VoteSystem)) as VoteSystem; @@ -588,7 +604,7 @@ public void RequestRestoreBackupRound( MessageType.Alert, $" Restore round blocked: {refusal}." ); - if (recovering) + if (recovering && CanHoldAgain(round)) { _restore.Hold(round, ActiveMapId()); } diff --git a/apps/swiftly/test/DeclinedVoteRoundTests.cs b/apps/swiftly/test/DeclinedVoteRoundTests.cs index 5e7961f8..17594e23 100644 --- a/apps/swiftly/test/DeclinedVoteRoundTests.cs +++ b/apps/swiftly/test/DeclinedVoteRoundTests.cs @@ -176,7 +176,7 @@ public void TheRoundHooksAskWhetherResultsAreHeld(string file, int sites) public void AVoteSaysWhetherItIsARecoveryVote() { Assert.Contains( - "_restore.StartVote(recovering);", + "_restore.StartVote(recovering, ", Code("FiveStack.Services", "GameBackUpRounds.cs") ); } diff --git a/apps/swiftly/test/RestoreParityTests.cs b/apps/swiftly/test/RestoreParityTests.cs index f01fd85f..9e2d9001 100644 --- a/apps/swiftly/test/RestoreParityTests.cs +++ b/apps/swiftly/test/RestoreParityTests.cs @@ -26,23 +26,22 @@ private static string Method(string app, string signature) return Regex.Replace(source[from..to], @"\s+", " ").Replace("HudDestination.", "MessageType."); } - // Only this plugin reads backups off disk, and the backend has never heard of - // a round it only found there. + // Only this plugin reads backups off disk. The api raises P0002 for a round it + // has no row for and answers nothing, so offering one could never load. [Fact] - public void ARoundFoundOnlyOnDiskIsMarked() + public void OnlyDiskRoundsTheBackendHasARowForAreOffered() { string source = File.ReadAllText( Path.Combine(Apps(), "swiftly", "src", "FiveStack.Services", "GameBackUpRounds.cs") ); - Assert.Matches( - @"new BackupRound\s*\{[^}]*round = highestNumber[^}]*from_disk = true[^}]*\}", - source - ); + Assert.Contains("RoundRestoreGateUtility.IsUsableOnDisk(", source); + Assert.DoesNotContain("from_disk", source); } [Theory] [InlineData("private void HoldForRestore(int round)")] + [InlineData("private bool CanHoldAgain(int round)")] [InlineData("private void ReturnToHold(int round)")] [InlineData("private void ReturnOwedToHold()")] [InlineData("private void FailLoad()")] diff --git a/apps/swiftly/test/RestoreWiringTests.cs b/apps/swiftly/test/RestoreWiringTests.cs index 4d9f094b..d296e0b8 100644 --- a/apps/swiftly/test/RestoreWiringTests.cs +++ b/apps/swiftly/test/RestoreWiringTests.cs @@ -164,13 +164,53 @@ public void EveryWayOutOfRestoreRoundSettlesAnOwedLoad() } [Fact] - public void AHoldOnlyComesBackOnAMapStillBeingPlayed() + public void AHoldOnlyComesBackForARoundThatCanStillLoad() + { + string check = Method(Backups(), "private bool CanHoldAgain(int round)"); + int asked = check.IndexOf("RoundRestoreGateUtility.HoldsAgain(", StringComparison.Ordinal); + int told = check.IndexOf("RoundRestoreGateUtility.BackupGone(round)", StringComparison.Ordinal); + int writing = check.IndexOf("SetBackupWriting(!IsHoldingForRestore());", StringComparison.Ordinal); + + Assert.Contains("RoundRestoreGateUtility.LatestBackup(", check); + Assert.True(asked >= 0 && told > asked, "players aren't told why the hold ended"); + Assert.True(writing > told, "backups stay off with nothing holding them"); + } + + [Fact] + public void EveryHoldThatComesBackChecksItCanStillLoad() { string hold = Method(Backups(), "private void ReturnToHold(int round)"); - int played = hold.IndexOf("RoundRestoreGateUtility.IsInPlay(", StringComparison.Ordinal); - int held = hold.IndexOf("HoldForRestore(round);", StringComparison.Ordinal); + string offer = Method(Backups(), "public void OfferPendingRestore()"); + string vote = Method(Backups(), "public void RequestRestoreBackupRound("); + + Assert.True( + hold.IndexOf("CanHoldAgain(round)", StringComparison.Ordinal) >= 0 + && hold.IndexOf("CanHoldAgain(round)", StringComparison.Ordinal) + < hold.IndexOf("HoldForRestore(round);", StringComparison.Ordinal), + "a hold comes back for a round that can't load" + ); + Assert.Matches(@"CanHoldAgain\(round\)\s*\)\s*\{\s*_restore\.Hold\(", offer); + Assert.Matches(@"CanHoldAgain\(round\)\s*\)\s*\{\s*_restore\.Hold\(", vote); + } + + [Fact] + public void AnOpenVoteIsAbandonedWithItsMap() + { + string forget = Method(Backups(), "public void ForgetOtherMaps(Guid? map)"); + int left = forget.IndexOf("_restore.LeaveMap(map)", StringComparison.Ordinal); + int abandoned = forget.IndexOf("restoreRoundVote?.Abandon();", StringComparison.Ordinal); + int cleared = forget.IndexOf("restoreRoundVote = null;", StringComparison.Ordinal); + int writing = forget.IndexOf("SetBackupWriting(!IsHoldingForRestore());", StringComparison.Ordinal); - Assert.True(played >= 0 && held > played, "a hold can come back on a map that is over"); + Assert.True(abandoned > left, "a vote outlives its map"); + Assert.True(cleared > abandoned && writing > cleared, "backups are put right too early"); + Assert.DoesNotContain("Withdraw", forget); + } + + [Fact] + public void AVoteRemembersItsMap() + { + Assert.Contains("_restore.StartVote(recovering, ActiveMapId());", Backups()); } [Fact] @@ -193,15 +233,6 @@ public void AnOwedLoadThatTimedOutStopsBeingResentBeforeTheHoldReturns() Assert.True(withdrawn < held, "the hold can be offered with the request still going out"); } - [Fact] - public void AnUnansweredHoldOnlyComesBackForARoundTheBackendCanLoad() - { - Assert.Contains( - "RoundRestoreGateUtility.RoundToHoldAgain(", - Method(Backups(), "private void OwedLoadTimedOut(int owing)") - ); - } - [Fact] public void TheNextMatchLeavesWhatTheLastOneHeldAndPutsBackupsRight() { diff --git a/apps/swiftly/test/RoundRestoreStateTests.cs b/apps/swiftly/test/RoundRestoreStateTests.cs index 6b17f83e..40ce1921 100644 --- a/apps/swiftly/test/RoundRestoreStateTests.cs +++ b/apps/swiftly/test/RoundRestoreStateTests.cs @@ -314,41 +314,91 @@ public void ANewMatchWithNothingToLeaveChangesNothing() Assert.False(_state.LeaveMap(OtherMap)); } - [Fact] - public void ARoundTheBackendKeptIsHeldAgain() + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AnOpenVoteEndsWithItsMap(bool recovering) { - BackupRound[] rounds = { new() { round = Round, backup_file = "kept" } }; + _state.StartVote(recovering, Map); - Assert.Equal(Round, RoundRestoreGateUtility.RoundToHoldAgain(rounds, Round)); + Assert.True(_state.LeaveMap(OtherMap)); + Assert.False(_state.VotingOrLoading); + Assert.False(_state.HoldingRounds); + Assert.False(_state.Resetting); } [Fact] - public void ARoundOnlyOnDiskFallsBackToTheNewestTheBackendKept() + public void AnOpenVoteStaysOnItsOwnMap() + { + _state.StartVote(map: Map); + + Assert.False(_state.LeaveMap(Map)); + Assert.True(_state.VotingOrLoading); + } + + [Theory] + [InlineData(5, true)] + [InlineData(4, true)] + [InlineData(6, false)] + [InlineData(Round, false)] + [InlineData(3, false)] + public void OnlyADiskRoundTheBackendHasARowForIsUsable(int round, bool usable) { BackupRound[] rounds = { - new() { round = 5, backup_file = "kept" }, - new() { round = 6, backup_file = "kept" }, - new() { round = Round, backup_file = "disk", from_disk = true }, + new() { round = 4, backup_file = "kept" }, + new() { round = 5 }, }; - Assert.Equal(6, RoundRestoreGateUtility.RoundToHoldAgain(rounds, Round)); + Assert.Equal(usable, RoundRestoreGateUtility.IsUsableOnDisk(rounds, round, false)); } [Fact] - public void ARoundARefetchDroppedFallsBackTheSameWay() + public void WithNoRowThereIsNothingOnDiskToUse() { - BackupRound[] rounds = { new() { round = 5, backup_file = "kept" } }; + Assert.False( + RoundRestoreGateUtility.IsUsableOnDisk(Array.Empty(), Round, false) + ); + } - Assert.Equal(5, RoundRestoreGateUtility.RoundToHoldAgain(rounds, Round)); + [Fact] + public void ARowARestoreUndidStaysUnusable() + { + BackupRound[] rounds = { new() { round = Round, deleted_at = "2026-11-02" } }; + + Assert.False(RoundRestoreGateUtility.IsUsableOnDisk(rounds, Round, false)); } [Fact] - public void NothingTheBackendCanLoadMeansNothingToHold() + public void OfflineKeepsItsDiskRoundsAsOnMain() + { + BackupRound[] undone = { new() { round = 5, deleted_at = "2026-11-02" } }; + + Assert.True( + RoundRestoreGateUtility.IsUsableOnDisk(Array.Empty(), Round, true) + ); + Assert.False(RoundRestoreGateUtility.IsUsableOnDisk(undone, 5, true)); + } + + [Theory] + [InlineData(eMapStatus.Live, true, true)] + [InlineData(eMapStatus.Paused, true, true)] + [InlineData(eMapStatus.Live, false, false)] + [InlineData(eMapStatus.Warmup, true, false)] + [InlineData(eMapStatus.Finished, true, false)] + public void AHoldOnlyComesBackForARoundThatCanStillLoad( + eMapStatus status, + bool hasBackup, + bool holds + ) { - BackupRound[] rounds = { new() { round = Round, backup_file = "disk", from_disk = true } }; + Assert.Equal(holds, RoundRestoreGateUtility.HoldsAgain(status, hasBackup)); + } - Assert.Null(RoundRestoreGateUtility.RoundToHoldAgain(rounds, Round)); + [Fact] + public void PlayersAreToldWhenARoundCanNoLongerBeRestored() + { + Assert.Contains(Round.ToString(), RoundRestoreGateUtility.BackupGone(Round)); } [Fact] diff --git a/shared/dotnet/FiveStack.Entities/BackupRound.cs b/shared/dotnet/FiveStack.Entities/BackupRound.cs index 268c6258..61b08170 100644 --- a/shared/dotnet/FiveStack.Entities/BackupRound.cs +++ b/shared/dotnet/FiveStack.Entities/BackupRound.cs @@ -5,7 +5,4 @@ public class BackupRound public int round { get; set; } public string deleted_at { get; set; } = ""; public string backup_file { get; set; } = ""; - - // Found on this server's disk, so the backend has never heard of it. - public bool from_disk { get; set; } } diff --git a/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs b/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs index 6408a7a5..651b9bad 100644 --- a/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs @@ -28,22 +28,23 @@ public static bool OwesLoad(bool recovering, bool offline) return recovering && !offline; } - // The api refuses a round it never recorded and tells nobody, so a hold on - // one would come back for ever, and a refetch drops the row its vote needs. - // A hold that went unanswered moves to the newest round the backend can - // load. Null when it has none: there is nothing there to play over. - public static int? RoundToHoldAgain(IEnumerable rounds, int round) + // The api refuses a round it has no row for and tells nobody, so a backup + // file of one can never load. Offline there is no api to refuse it. + public static bool IsUsableOnDisk(IEnumerable rounds, int round, bool offline) { - List kept = rounds.Where(backup => !backup.from_disk).ToList(); - - if (LatestBackup(kept, round) != null) - { - return round; - } + return !IsUndone(rounds, round) + && (offline || rounds.Any(backup => backup.round == round)); + } - int newest = HighestRestorableRound(kept, 0); + // A hold on a round that can't load would come back for ever. + public static bool HoldsAgain(eMapStatus status, bool hasBackup) + { + return IsInPlay(status) && hasBackup; + } - return newest > 0 ? newest : null; + public static string BackupGone(int round) + { + return $" Round {round} can't be restored, its backup is gone."; } public static string? ResumeRefusal(int? owedRound) diff --git a/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs b/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs index c4660222..b7e56174 100644 --- a/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs +++ b/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs @@ -24,7 +24,7 @@ public class RoundRestoreState private bool _recoveryVote; private int _owing; private int? _owedRound; - private Guid? _owedMap; + private Guid? _map; private Guid? _heldMap; private int? _loadOwed; @@ -57,7 +57,7 @@ public bool LeaveMap(Guid? map) { bool left = false; - if (_owedRound != null && _owedMap != map) + if ((_voting || _owedRound != null) && _map != map) { End(); left = true; @@ -83,18 +83,19 @@ public bool LeaveMap(Guid? map) // A result waiting for the next round start is from the round this undoes. public event Action? LoadStarted; - public void StartVote(bool recovering = false) + public void StartVote(bool recovering = false, Guid? map = null) { End(); _voting = true; _recoveryVote = recovering; + _map = map; } public int StartOwing(int round, Guid? map) { End(); _owedRound = round; - _owedMap = map; + _map = map; return ++_owing; } @@ -128,7 +129,7 @@ public OwedLoadTimeout TimedOut(int owing, Guid? map) } int round = _owedRound.Value; - bool sameMap = _owedMap == map; + bool sameMap = _map == map; End(); @@ -140,7 +141,7 @@ public void End() _voting = false; _recoveryVote = false; _owedRound = null; - _owedMap = null; + _map = null; _loadOwed = null; LoadingBackup = false; } From f7aca0a575c49cbe4d40cd59cc3a4aa507f5e3f2 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 2 Nov 2026 19:02:06 -0500 Subject: [PATCH 7/7] fix: a hold with no backup left keeps recorded rounds safe instead of playing over them Ending the hold when its round's backup was gone turned backups on and let the match resume with recorded rounds still ahead of the server. A hold now falls back to the newest round the backend can load. With none left and rounds recorded ahead, the match stays held on NothingToLoad until Restart Map, with players told once and no vote offered. It only ends when nothing is recorded ahead of the server. --- .../FiveStack.Services/GameBackUpRounds.cs | 39 +++-- .../test/RestoreWiringTests.cs | 38 ++-- .../test/RoundRestoreStateTests.cs | 165 ++++++++++++++++-- .../FiveStack.Services/GameBackUpRounds.cs | 39 +++-- apps/swiftly/test/RestoreParityTests.cs | 2 +- apps/swiftly/test/RestoreWiringTests.cs | 38 ++-- apps/swiftly/test/RoundRestoreStateTests.cs | 165 ++++++++++++++++-- .../RoundRestoreGateUtility.cs | 45 ++++- .../FiveStack.Utilities/RoundRestoreState.cs | 9 +- 9 files changed, 456 insertions(+), 84 deletions(-) diff --git a/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs b/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs index eb177680..8099eeee 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/GameBackUpRounds.cs @@ -124,7 +124,7 @@ public void OfferPendingRestore() RequestRestoreBackupRound(round, null, true, recovering: true); // Refused for another reason: keep holding for the next player. - if (!_restore.InProgress && CanHoldAgain(round)) + if (!_restore.InProgress && HoldStaysOn(round)) { _restore.Hold(round, ActiveMapId()); } @@ -187,7 +187,7 @@ private void HoldForRestore(int round) // replay: only players deciding that gives up rounds the backend recorded. private void ReturnToHold(int round) { - if (!CanHoldAgain(round)) + if (!HoldStaysOn(round)) { return; } @@ -196,25 +196,35 @@ private void ReturnToHold(int round) HoldForRestore(round); } - private bool CanHoldAgain(int round) + // False once the hold has moved to another round or ended. + private bool HoldStaysOn(int round) { MatchManager? matchManager = _matchService.GetCurrentMatch(); eMapStatus status = matchManager?.GetCurrentMapStatus() ?? eMapStatus.Unknown; - BackupRound[] rounds = matchManager?.GetCurrentMap()?.rounds ?? Array.Empty(); + int? next = RoundRestoreGateUtility.RoundToHold( + status, + matchManager?.GetCurrentMap()?.rounds ?? Array.Empty(), + round, + _gameServer.GetTotalRoundsPlayed() + ); - if ( - RoundRestoreGateUtility.HoldsAgain( - status, - RoundRestoreGateUtility.LatestBackup(rounds, round) != null - ) - ) + if (next == round) { return true; } if (RoundRestoreGateUtility.IsInPlay(status)) { - _gameServer.Message(HudDestination.Alert, RoundRestoreGateUtility.BackupGone(round)); + _gameServer.Message( + HudDestination.Alert, + RoundRestoreGateUtility.WhyTheHoldMoved(round, next) + ); + } + + if (next is int moved) + { + HoldForRestore(moved); + return false; } SetBackupWriting(!IsHoldingForRestore()); @@ -291,7 +301,10 @@ public void ForgetOtherMaps(Guid? map) public void TellWhyResumeWaits() { - string? refusal = RoundRestoreGateUtility.ResumeRefusal(_restore.OwedRound); + string? refusal = RoundRestoreGateUtility.ResumeRefusal( + _restore.OwedRound, + _restore.NeedsRestartMap + ); if (refusal != null) { @@ -404,7 +417,7 @@ public void RequestRestoreBackupRound( HudDestination.Alert, $" Restore round blocked: {refusal}." ); - if (recovering && CanHoldAgain(round)) + if (recovering && HoldStaysOn(round)) { _restore.Hold(round, ActiveMapId()); } diff --git a/apps/counterstrikesharp/test/RestoreWiringTests.cs b/apps/counterstrikesharp/test/RestoreWiringTests.cs index d296e0b8..bd6d21b9 100644 --- a/apps/counterstrikesharp/test/RestoreWiringTests.cs +++ b/apps/counterstrikesharp/test/RestoreWiringTests.cs @@ -164,33 +164,47 @@ public void EveryWayOutOfRestoreRoundSettlesAnOwedLoad() } [Fact] - public void AHoldOnlyComesBackForARoundThatCanStillLoad() + public void AHoldAsksWhereToGoAndOnlySpeaksWhenItMoves() { - string check = Method(Backups(), "private bool CanHoldAgain(int round)"); - int asked = check.IndexOf("RoundRestoreGateUtility.HoldsAgain(", StringComparison.Ordinal); - int told = check.IndexOf("RoundRestoreGateUtility.BackupGone(round)", StringComparison.Ordinal); + string check = Method(Backups(), "private bool HoldStaysOn(int round)"); + int asked = check.IndexOf("RoundRestoreGateUtility.RoundToHold(", StringComparison.Ordinal); + int stays = check.IndexOf("if (next == round)", StringComparison.Ordinal); + int told = check.IndexOf("RoundRestoreGateUtility.WhyTheHoldMoved(round, next)", StringComparison.Ordinal); + int moved = check.IndexOf("HoldForRestore(moved);", StringComparison.Ordinal); int writing = check.IndexOf("SetBackupWriting(!IsHoldingForRestore());", StringComparison.Ordinal); - Assert.Contains("RoundRestoreGateUtility.LatestBackup(", check); - Assert.True(asked >= 0 && told > asked, "players aren't told why the hold ended"); - Assert.True(writing > told, "backups stay off with nothing holding them"); + Assert.Contains("_gameServer.GetTotalRoundsPlayed()", check); + Assert.True(asked >= 0 && stays > asked, "a hold stays on a round that can't load"); + Assert.True(told > stays, "players are told again by a hold that didn't move"); + Assert.True(moved > told, "a new hold is offered before players hear why"); + Assert.True(writing > moved, "backups come on with a hold still in place"); + Assert.DoesNotContain("ReplayMap", check); } [Fact] - public void EveryHoldThatComesBackChecksItCanStillLoad() + public void EveryHoldThatComesBackAsksWhereItBelongs() { string hold = Method(Backups(), "private void ReturnToHold(int round)"); string offer = Method(Backups(), "public void OfferPendingRestore()"); string vote = Method(Backups(), "public void RequestRestoreBackupRound("); Assert.True( - hold.IndexOf("CanHoldAgain(round)", StringComparison.Ordinal) >= 0 - && hold.IndexOf("CanHoldAgain(round)", StringComparison.Ordinal) + hold.IndexOf("HoldStaysOn(round)", StringComparison.Ordinal) >= 0 + && hold.IndexOf("HoldStaysOn(round)", StringComparison.Ordinal) < hold.IndexOf("HoldForRestore(round);", StringComparison.Ordinal), "a hold comes back for a round that can't load" ); - Assert.Matches(@"CanHoldAgain\(round\)\s*\)\s*\{\s*_restore\.Hold\(", offer); - Assert.Matches(@"CanHoldAgain\(round\)\s*\)\s*\{\s*_restore\.Hold\(", vote); + Assert.Matches(@"HoldStaysOn\(round\)\s*\)\s*\{\s*_restore\.Hold\(", offer); + Assert.Matches(@"HoldStaysOn\(round\)\s*\)\s*\{\s*_restore\.Hold\(", vote); + } + + [Fact] + public void AResumeOnNothingToLoadSaysWhy() + { + Assert.Matches( + @"ResumeRefusal\(\s*_restore\.OwedRound,\s*_restore\.NeedsRestartMap\s*\)", + Method(Backups(), "public void TellWhyResumeWaits()") + ); } [Fact] diff --git a/apps/counterstrikesharp/test/RoundRestoreStateTests.cs b/apps/counterstrikesharp/test/RoundRestoreStateTests.cs index 40ce1921..3ce2feac 100644 --- a/apps/counterstrikesharp/test/RoundRestoreStateTests.cs +++ b/apps/counterstrikesharp/test/RoundRestoreStateTests.cs @@ -380,25 +380,164 @@ public void OfflineKeepsItsDiskRoundsAsOnMain() Assert.False(RoundRestoreGateUtility.IsUsableOnDisk(undone, 5, true)); } + private static BackupRound Row(int round, string file = "", string deleted = "") + { + return new BackupRound + { + round = round, + backup_file = file, + deleted_at = deleted, + }; + } + + private static int? RoundToHold(BackupRound[] rounds, int round = Round, int played = 0) + { + return RoundRestoreGateUtility.RoundToHold(eMapStatus.Paused, rounds, round, played); + } + + [Fact] + public void AHoldStaysOnARoundThatCanStillLoad() + { + Assert.Equal(Round, RoundToHold(new[] { Row(5, "kept"), Row(Round, "kept") })); + } + + [Fact] + public void AGoneBackupFallsBackToTheNewestTheBackendKept() + { + Assert.Equal(5, RoundToHold(new[] { Row(4, "kept"), Row(5, "kept"), Row(Round) })); + } + + [Fact] + public void AGoneBackupDoesNotFallBackToARoundARestoreUndid() + { + BackupRound[] rounds = { Row(4, "kept"), Row(5, "undone", "2026-11-02"), Row(Round) }; + + Assert.Equal(4, RoundToHold(rounds)); + } + + [Fact] + public void NoBackupWithRoundsRecordedAheadNeedsRestartMap() + { + Assert.Equal(RoundRestoreState.NothingToLoad, RoundToHold(new[] { Row(5), Row(Round) })); + } + + [Fact] + public void NoRowLeftForTheHeldRoundStillProtectsTheOnesAhead() + { + Assert.Equal(RoundRestoreState.NothingToLoad, RoundToHold(new[] { Row(5) }, played: 2)); + } + [Theory] - [InlineData(eMapStatus.Live, true, true)] - [InlineData(eMapStatus.Paused, true, true)] - [InlineData(eMapStatus.Live, false, false)] - [InlineData(eMapStatus.Warmup, true, false)] - [InlineData(eMapStatus.Finished, true, false)] - public void AHoldOnlyComesBackForARoundThatCanStillLoad( - eMapStatus status, - bool hasBackup, - bool holds - ) + [InlineData(5)] + [InlineData(6)] + public void NothingRecordedAheadEndsTheHold(int played) { - Assert.Equal(holds, RoundRestoreGateUtility.HoldsAgain(status, hasBackup)); + Assert.Null(RoundToHold(new[] { Row(5) }, played: played)); + } + + [Fact] + public void NoRowsAtAllEndsTheHold() + { + Assert.Null(RoundToHold(Array.Empty())); + } + + [Fact] + public void AnOldBackupIsNoReasonToHoldWithNothingAhead() + { + Assert.Null(RoundToHold(new[] { Row(5, "kept") }, played: 5)); + } + + [Fact] + public void AnOlderBackupStillRecoversRoundsRecordedAhead() + { + Assert.Equal(3, RoundToHold(new[] { Row(3, "kept"), Row(5) }, played: 4)); + } + + [Theory] + [InlineData(eMapStatus.Warmup)] + [InlineData(eMapStatus.Finished)] + public void AMapNotInPlayEndsTheHold(eMapStatus status) + { + BackupRound[] rounds = { Row(5, "kept"), Row(Round, "kept") }; + + Assert.Null(RoundRestoreGateUtility.RoundToHold(status, rounds, Round, 0)); } [Fact] - public void PlayersAreToldWhenARoundCanNoLongerBeRestored() + public void AHoldOnNothingToLoadStaysPutSoPlayersAreOnlyToldOnce() { - Assert.Contains(Round.ToString(), RoundRestoreGateUtility.BackupGone(Round)); + Assert.Equal( + RoundRestoreState.NothingToLoad, + RoundToHold(new[] { Row(Round) }, RoundRestoreState.NothingToLoad) + ); + } + + [Fact] + public void AHoldOnNothingToLoadKeepsTheMatchHeldWithNoVote() + { + _state.Hold(RoundRestoreState.NothingToLoad, Map); + + Assert.True(_state.NeedsRestartMap); + Assert.False(_state.CanOfferVote); + Assert.True(_state.HoldingRounds); + Assert.True(_state.Resetting); + } + + [Fact] + public void AnOrdinaryHoldDoesNotNeedRestartMap() + { + _state.Hold(Round, Map); + + Assert.False(_state.NeedsRestartMap); + Assert.True(_state.CanOfferVote); + } + + [Fact] + public void RestartMapEndsAHoldOnNothingToLoad() + { + _state.Hold(RoundRestoreState.NothingToLoad, Map); + _state.Abandon(); + + Assert.False(_state.NeedsRestartMap); + Assert.False(_state.HoldingRounds); + } + + [Fact] + public void AHoldOnNothingToLoadEndsWithItsMap() + { + _state.Hold(RoundRestoreState.NothingToLoad, Map); + + Assert.True(_state.LeaveMap(OtherMap)); + Assert.False(_state.HoldingRounds); + } + + [Fact] + public void PlayersAreToldAnAdminNeedsRestartMap() + { + Assert.Contains( + "Restart Map", + RoundRestoreGateUtility.WhyTheHoldMoved(Round, RoundRestoreState.NothingToLoad) + ); + } + + [Theory] + [InlineData(5)] + [InlineData(null)] + public void PlayersAreToldWhichRoundCanNoLongerBeRestored(int? next) + { + string told = RoundRestoreGateUtility.WhyTheHoldMoved(Round, next); + + Assert.Contains(Round.ToString(), told); + Assert.DoesNotContain("Restart Map", told); + } + + [Fact] + public void AResumeSaysWhyItWaitsForRestartMap() + { + Assert.Equal( + RoundRestoreGateUtility.RestartMapNeeded, + RoundRestoreGateUtility.ResumeRefusal(null, needsRestartMap: true) + ); } [Fact] diff --git a/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs b/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs index ea7aa435..cb7c3a42 100644 --- a/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs +++ b/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs @@ -183,7 +183,7 @@ public void OfferPendingRestore() RequestRestoreBackupRound(round, null, true, recovering: true); // Refused for another reason: keep holding for the next player. - if (!_restore.InProgress && CanHoldAgain(round)) + if (!_restore.InProgress && HoldStaysOn(round)) { _restore.Hold(round, ActiveMapId()); } @@ -294,7 +294,7 @@ private void HoldForRestore(int round) // replay: only players deciding that gives up rounds the backend recorded. private void ReturnToHold(int round) { - if (!CanHoldAgain(round)) + if (!HoldStaysOn(round)) { return; } @@ -303,25 +303,35 @@ private void ReturnToHold(int round) HoldForRestore(round); } - private bool CanHoldAgain(int round) + // False once the hold has moved to another round or ended. + private bool HoldStaysOn(int round) { MatchManager? matchManager = _matchService.GetCurrentMatch(); eMapStatus status = matchManager?.GetCurrentMapStatus() ?? eMapStatus.Unknown; - BackupRound[] rounds = matchManager?.GetCurrentMap()?.rounds ?? Array.Empty(); + int? next = RoundRestoreGateUtility.RoundToHold( + status, + matchManager?.GetCurrentMap()?.rounds ?? Array.Empty(), + round, + _gameServer.GetTotalRoundsPlayed() + ); - if ( - RoundRestoreGateUtility.HoldsAgain( - status, - RoundRestoreGateUtility.LatestBackup(rounds, round) != null - ) - ) + if (next == round) { return true; } if (RoundRestoreGateUtility.IsInPlay(status)) { - _gameServer.Message(MessageType.Alert, RoundRestoreGateUtility.BackupGone(round)); + _gameServer.Message( + MessageType.Alert, + RoundRestoreGateUtility.WhyTheHoldMoved(round, next) + ); + } + + if (next is int moved) + { + HoldForRestore(moved); + return false; } SetBackupWriting(!IsHoldingForRestore()); @@ -398,7 +408,10 @@ public void ForgetOtherMaps(Guid? map) public void TellWhyResumeWaits() { - string? refusal = RoundRestoreGateUtility.ResumeRefusal(_restore.OwedRound); + string? refusal = RoundRestoreGateUtility.ResumeRefusal( + _restore.OwedRound, + _restore.NeedsRestartMap + ); if (refusal != null) { @@ -604,7 +617,7 @@ public void RequestRestoreBackupRound( MessageType.Alert, $" Restore round blocked: {refusal}." ); - if (recovering && CanHoldAgain(round)) + if (recovering && HoldStaysOn(round)) { _restore.Hold(round, ActiveMapId()); } diff --git a/apps/swiftly/test/RestoreParityTests.cs b/apps/swiftly/test/RestoreParityTests.cs index 9e2d9001..a3f18fd3 100644 --- a/apps/swiftly/test/RestoreParityTests.cs +++ b/apps/swiftly/test/RestoreParityTests.cs @@ -41,7 +41,7 @@ public void OnlyDiskRoundsTheBackendHasARowForAreOffered() [Theory] [InlineData("private void HoldForRestore(int round)")] - [InlineData("private bool CanHoldAgain(int round)")] + [InlineData("private bool HoldStaysOn(int round)")] [InlineData("private void ReturnToHold(int round)")] [InlineData("private void ReturnOwedToHold()")] [InlineData("private void FailLoad()")] diff --git a/apps/swiftly/test/RestoreWiringTests.cs b/apps/swiftly/test/RestoreWiringTests.cs index d296e0b8..bd6d21b9 100644 --- a/apps/swiftly/test/RestoreWiringTests.cs +++ b/apps/swiftly/test/RestoreWiringTests.cs @@ -164,33 +164,47 @@ public void EveryWayOutOfRestoreRoundSettlesAnOwedLoad() } [Fact] - public void AHoldOnlyComesBackForARoundThatCanStillLoad() + public void AHoldAsksWhereToGoAndOnlySpeaksWhenItMoves() { - string check = Method(Backups(), "private bool CanHoldAgain(int round)"); - int asked = check.IndexOf("RoundRestoreGateUtility.HoldsAgain(", StringComparison.Ordinal); - int told = check.IndexOf("RoundRestoreGateUtility.BackupGone(round)", StringComparison.Ordinal); + string check = Method(Backups(), "private bool HoldStaysOn(int round)"); + int asked = check.IndexOf("RoundRestoreGateUtility.RoundToHold(", StringComparison.Ordinal); + int stays = check.IndexOf("if (next == round)", StringComparison.Ordinal); + int told = check.IndexOf("RoundRestoreGateUtility.WhyTheHoldMoved(round, next)", StringComparison.Ordinal); + int moved = check.IndexOf("HoldForRestore(moved);", StringComparison.Ordinal); int writing = check.IndexOf("SetBackupWriting(!IsHoldingForRestore());", StringComparison.Ordinal); - Assert.Contains("RoundRestoreGateUtility.LatestBackup(", check); - Assert.True(asked >= 0 && told > asked, "players aren't told why the hold ended"); - Assert.True(writing > told, "backups stay off with nothing holding them"); + Assert.Contains("_gameServer.GetTotalRoundsPlayed()", check); + Assert.True(asked >= 0 && stays > asked, "a hold stays on a round that can't load"); + Assert.True(told > stays, "players are told again by a hold that didn't move"); + Assert.True(moved > told, "a new hold is offered before players hear why"); + Assert.True(writing > moved, "backups come on with a hold still in place"); + Assert.DoesNotContain("ReplayMap", check); } [Fact] - public void EveryHoldThatComesBackChecksItCanStillLoad() + public void EveryHoldThatComesBackAsksWhereItBelongs() { string hold = Method(Backups(), "private void ReturnToHold(int round)"); string offer = Method(Backups(), "public void OfferPendingRestore()"); string vote = Method(Backups(), "public void RequestRestoreBackupRound("); Assert.True( - hold.IndexOf("CanHoldAgain(round)", StringComparison.Ordinal) >= 0 - && hold.IndexOf("CanHoldAgain(round)", StringComparison.Ordinal) + hold.IndexOf("HoldStaysOn(round)", StringComparison.Ordinal) >= 0 + && hold.IndexOf("HoldStaysOn(round)", StringComparison.Ordinal) < hold.IndexOf("HoldForRestore(round);", StringComparison.Ordinal), "a hold comes back for a round that can't load" ); - Assert.Matches(@"CanHoldAgain\(round\)\s*\)\s*\{\s*_restore\.Hold\(", offer); - Assert.Matches(@"CanHoldAgain\(round\)\s*\)\s*\{\s*_restore\.Hold\(", vote); + Assert.Matches(@"HoldStaysOn\(round\)\s*\)\s*\{\s*_restore\.Hold\(", offer); + Assert.Matches(@"HoldStaysOn\(round\)\s*\)\s*\{\s*_restore\.Hold\(", vote); + } + + [Fact] + public void AResumeOnNothingToLoadSaysWhy() + { + Assert.Matches( + @"ResumeRefusal\(\s*_restore\.OwedRound,\s*_restore\.NeedsRestartMap\s*\)", + Method(Backups(), "public void TellWhyResumeWaits()") + ); } [Fact] diff --git a/apps/swiftly/test/RoundRestoreStateTests.cs b/apps/swiftly/test/RoundRestoreStateTests.cs index 40ce1921..3ce2feac 100644 --- a/apps/swiftly/test/RoundRestoreStateTests.cs +++ b/apps/swiftly/test/RoundRestoreStateTests.cs @@ -380,25 +380,164 @@ public void OfflineKeepsItsDiskRoundsAsOnMain() Assert.False(RoundRestoreGateUtility.IsUsableOnDisk(undone, 5, true)); } + private static BackupRound Row(int round, string file = "", string deleted = "") + { + return new BackupRound + { + round = round, + backup_file = file, + deleted_at = deleted, + }; + } + + private static int? RoundToHold(BackupRound[] rounds, int round = Round, int played = 0) + { + return RoundRestoreGateUtility.RoundToHold(eMapStatus.Paused, rounds, round, played); + } + + [Fact] + public void AHoldStaysOnARoundThatCanStillLoad() + { + Assert.Equal(Round, RoundToHold(new[] { Row(5, "kept"), Row(Round, "kept") })); + } + + [Fact] + public void AGoneBackupFallsBackToTheNewestTheBackendKept() + { + Assert.Equal(5, RoundToHold(new[] { Row(4, "kept"), Row(5, "kept"), Row(Round) })); + } + + [Fact] + public void AGoneBackupDoesNotFallBackToARoundARestoreUndid() + { + BackupRound[] rounds = { Row(4, "kept"), Row(5, "undone", "2026-11-02"), Row(Round) }; + + Assert.Equal(4, RoundToHold(rounds)); + } + + [Fact] + public void NoBackupWithRoundsRecordedAheadNeedsRestartMap() + { + Assert.Equal(RoundRestoreState.NothingToLoad, RoundToHold(new[] { Row(5), Row(Round) })); + } + + [Fact] + public void NoRowLeftForTheHeldRoundStillProtectsTheOnesAhead() + { + Assert.Equal(RoundRestoreState.NothingToLoad, RoundToHold(new[] { Row(5) }, played: 2)); + } + [Theory] - [InlineData(eMapStatus.Live, true, true)] - [InlineData(eMapStatus.Paused, true, true)] - [InlineData(eMapStatus.Live, false, false)] - [InlineData(eMapStatus.Warmup, true, false)] - [InlineData(eMapStatus.Finished, true, false)] - public void AHoldOnlyComesBackForARoundThatCanStillLoad( - eMapStatus status, - bool hasBackup, - bool holds - ) + [InlineData(5)] + [InlineData(6)] + public void NothingRecordedAheadEndsTheHold(int played) { - Assert.Equal(holds, RoundRestoreGateUtility.HoldsAgain(status, hasBackup)); + Assert.Null(RoundToHold(new[] { Row(5) }, played: played)); + } + + [Fact] + public void NoRowsAtAllEndsTheHold() + { + Assert.Null(RoundToHold(Array.Empty())); + } + + [Fact] + public void AnOldBackupIsNoReasonToHoldWithNothingAhead() + { + Assert.Null(RoundToHold(new[] { Row(5, "kept") }, played: 5)); + } + + [Fact] + public void AnOlderBackupStillRecoversRoundsRecordedAhead() + { + Assert.Equal(3, RoundToHold(new[] { Row(3, "kept"), Row(5) }, played: 4)); + } + + [Theory] + [InlineData(eMapStatus.Warmup)] + [InlineData(eMapStatus.Finished)] + public void AMapNotInPlayEndsTheHold(eMapStatus status) + { + BackupRound[] rounds = { Row(5, "kept"), Row(Round, "kept") }; + + Assert.Null(RoundRestoreGateUtility.RoundToHold(status, rounds, Round, 0)); } [Fact] - public void PlayersAreToldWhenARoundCanNoLongerBeRestored() + public void AHoldOnNothingToLoadStaysPutSoPlayersAreOnlyToldOnce() { - Assert.Contains(Round.ToString(), RoundRestoreGateUtility.BackupGone(Round)); + Assert.Equal( + RoundRestoreState.NothingToLoad, + RoundToHold(new[] { Row(Round) }, RoundRestoreState.NothingToLoad) + ); + } + + [Fact] + public void AHoldOnNothingToLoadKeepsTheMatchHeldWithNoVote() + { + _state.Hold(RoundRestoreState.NothingToLoad, Map); + + Assert.True(_state.NeedsRestartMap); + Assert.False(_state.CanOfferVote); + Assert.True(_state.HoldingRounds); + Assert.True(_state.Resetting); + } + + [Fact] + public void AnOrdinaryHoldDoesNotNeedRestartMap() + { + _state.Hold(Round, Map); + + Assert.False(_state.NeedsRestartMap); + Assert.True(_state.CanOfferVote); + } + + [Fact] + public void RestartMapEndsAHoldOnNothingToLoad() + { + _state.Hold(RoundRestoreState.NothingToLoad, Map); + _state.Abandon(); + + Assert.False(_state.NeedsRestartMap); + Assert.False(_state.HoldingRounds); + } + + [Fact] + public void AHoldOnNothingToLoadEndsWithItsMap() + { + _state.Hold(RoundRestoreState.NothingToLoad, Map); + + Assert.True(_state.LeaveMap(OtherMap)); + Assert.False(_state.HoldingRounds); + } + + [Fact] + public void PlayersAreToldAnAdminNeedsRestartMap() + { + Assert.Contains( + "Restart Map", + RoundRestoreGateUtility.WhyTheHoldMoved(Round, RoundRestoreState.NothingToLoad) + ); + } + + [Theory] + [InlineData(5)] + [InlineData(null)] + public void PlayersAreToldWhichRoundCanNoLongerBeRestored(int? next) + { + string told = RoundRestoreGateUtility.WhyTheHoldMoved(Round, next); + + Assert.Contains(Round.ToString(), told); + Assert.DoesNotContain("Restart Map", told); + } + + [Fact] + public void AResumeSaysWhyItWaitsForRestartMap() + { + Assert.Equal( + RoundRestoreGateUtility.RestartMapNeeded, + RoundRestoreGateUtility.ResumeRefusal(null, needsRestartMap: true) + ); } [Fact] diff --git a/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs b/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs index 651b9bad..734e3b9a 100644 --- a/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs +++ b/shared/dotnet/FiveStack.Utilities/RoundRestoreGateUtility.cs @@ -36,19 +36,52 @@ public static bool IsUsableOnDisk(IEnumerable rounds, int round, bo && (offline || rounds.Any(backup => backup.round == round)); } - // A hold on a round that can't load would come back for ever. - public static bool HoldsAgain(eMapStatus status, bool hasBackup) + public const string RestartMapNeeded = + " No round can be restored, an admin needs to use Restart Map."; + + // Where a hold goes when it comes back, as a hold on a round that can't + // load would come back for ever. The newest round the backend can load, or + // NothingToLoad if it has none. Null when no recorded round is ahead of the + // server: there is nothing left to play over. + public static int? RoundToHold( + eMapStatus status, + IEnumerable rounds, + int round, + int totalRoundsPlayed + ) { - return IsInPlay(status) && hasBackup; + if (!IsInPlay(status)) + { + return null; + } + + if (LatestBackup(rounds, round) != null) + { + return round; + } + + if (HighestRecordedRound(rounds, 0) <= totalRoundsPlayed) + { + return null; + } + + return HighestRestorableRound(rounds, 0); } - public static string BackupGone(int round) + public static string WhyTheHoldMoved(int round, int? next) { - return $" Round {round} can't be restored, its backup is gone."; + return next == RoundRestoreState.NothingToLoad + ? RestartMapNeeded + : $" Round {round} can't be restored, its backup is gone."; } - public static string? ResumeRefusal(int? owedRound) + public static string? ResumeRefusal(int? owedRound, bool needsRestartMap = false) { + if (needsRestartMap) + { + return RestartMapNeeded; + } + return owedRound == null ? null : $" Round {owedRound} is still being restored, the match can't resume yet."; diff --git a/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs b/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs index b7e56174..7f8ce9b9 100644 --- a/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs +++ b/shared/dotnet/FiveStack.Utilities/RoundRestoreState.cs @@ -20,6 +20,11 @@ public class RoundRestoreState // the hold early costs a second vote. Waiting costs a few paused minutes. public const int OwedSeconds = 300; + // A hold on this has rounds recorded ahead of it and no backup of any of + // them. There is nothing to vote on, and playing would go over them, so it + // stays held until Restart Map. + public const int NothingToLoad = 0; + private bool _voting; private bool _recoveryVote; private int _owing; @@ -44,7 +49,9 @@ public class RoundRestoreState // Taking a hold to vote on it while something else is running loses it: // the vote is refused, and nothing puts the hold back. - public bool CanOfferVote => HeldRound != null && !InProgress; + public bool CanOfferVote => HeldRound > NothingToLoad && !InProgress; + + public bool NeedsRestartMap => HeldRound == NothingToLoad; public void Hold(int round, Guid? map = null) {