diff --git a/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs index 48528a8b36fa..e7d53ed9cac2 100644 --- a/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs +++ b/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs @@ -246,7 +246,7 @@ protected void UpdateUserStatesRandomly() break; } - spectatorClient.Raise(s => s.OnNewFrames -= null, userId, new FrameDataBundle(header, new[] { new LegacyReplayFrame(Time.Current, 0, 0, ReplayButtonState.None) })); + spectatorClient.Raise(s => s.OnNewFrames -= null, userId, new FrameDataBundle(header, new[] { new LegacyReplayFrame(Time.Current, 0, 0, ReplayButtonState.None) }, null)); } } } diff --git a/osu.Game/Online/Spectator/CompleteReplayRequest.cs b/osu.Game/Online/Spectator/CompleteReplayRequest.cs new file mode 100644 index 000000000000..70b0a301c83c --- /dev/null +++ b/osu.Game/Online/Spectator/CompleteReplayRequest.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using MessagePack; + +namespace osu.Game.Online.Spectator +{ + /// + /// Sent by the server to retrieve frame bundles that the client sent but never reached the server, if any. + /// + /// The ID of the score token associated with the score with missing bundles. + /// The sequence numbers of frame bundles that the server never received. + [Serializable] + [MessagePackObject] + public record CompleteReplayRequest( + [property: Key(0)] long ScoreTokenId, + [property: Key(1)] IEnumerable FrameBundleSequenceNumbers + ); +} diff --git a/osu.Game/Online/Spectator/CompleteReplayResponse.cs b/osu.Game/Online/Spectator/CompleteReplayResponse.cs new file mode 100644 index 000000000000..3af45deec7a7 --- /dev/null +++ b/osu.Game/Online/Spectator/CompleteReplayResponse.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using MessagePack; + +namespace osu.Game.Online.Spectator +{ + /// + /// Response to . + /// + /// + /// The frame bundles requested by sequence number in the corresponding . + /// Can be blank (contain no frames) if the client does not have the frames available any more. + /// + [Serializable] + [MessagePackObject] + public record CompleteReplayResponse( + [property: Key(0)] IEnumerable FrameBundles + ); +} diff --git a/osu.Game/Online/Spectator/FrameDataBundle.cs b/osu.Game/Online/Spectator/FrameDataBundle.cs index d58ddd531098..3ac3f017baee 100644 --- a/osu.Game/Online/Spectator/FrameDataBundle.cs +++ b/osu.Game/Online/Spectator/FrameDataBundle.cs @@ -21,6 +21,13 @@ public class FrameDataBundle [Key(1)] public IList Frames { get; set; } + /// + /// The sequence number of this frame bundle. + /// Used to determine ordering of frame bundles, and for server-side checks that server received all frame bundles it was supposed to. + /// + [Key(2)] + public long? SequenceNumber { get; set; } + public FrameDataBundle(ScoreInfo score, ScoreProcessor scoreProcessor, IList frames) { Frames = frames; @@ -28,10 +35,11 @@ public FrameDataBundle(ScoreInfo score, ScoreProcessor scoreProcessor, IList frames) + public FrameDataBundle(FrameHeader header, IList frames, long? sequenceNumber) { Header = header; Frames = frames; + SequenceNumber = sequenceNumber; } } } diff --git a/osu.Game/Online/Spectator/ISpectatorClient.cs b/osu.Game/Online/Spectator/ISpectatorClient.cs index 2b73037cb8e1..814f921d0d4d 100644 --- a/osu.Game/Online/Spectator/ISpectatorClient.cs +++ b/osu.Game/Online/Spectator/ISpectatorClient.cs @@ -49,5 +49,12 @@ public interface ISpectatorClient : IStatefulUserHubClient /// /// The ID of the user who ended watching. Task UserEndedWatching(int userId); + + /// + /// Called by the server in response to . + /// Using data provided in , this gives the server a last chance to retrieve any s + /// that it may not have received due to connection drop-outs or similar. + /// + Task CompleteReplay(CompleteReplayRequest request); } } diff --git a/osu.Game/Online/Spectator/OnlineSpectatorClient.cs b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs index 0ac77d6b352a..79a45b3531ff 100644 --- a/osu.Game/Online/Spectator/OnlineSpectatorClient.cs +++ b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs @@ -42,6 +42,7 @@ private void load(IAPIProvider api) connection.On(nameof(ISpectatorClient.UserBeganPlaying), ((ISpectatorClient)this).UserBeganPlaying); connection.On(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames); connection.On(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying); + connection.On(nameof(ISpectatorClient.CompleteReplay), ((ISpectatorClient)this).CompleteReplay); connection.On(nameof(ISpectatorClient.UserScoreProcessed), ((ISpectatorClient)this).UserScoreProcessed); connection.On(nameof(ISpectatorClient.UserStartedWatching), ((ISpectatorClient)this).UserStartedWatching); connection.On(nameof(ISpectatorClient.UserEndedWatching), ((ISpectatorClient)this).UserEndedWatching); diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs index 7b62d64b41c1..5ca0ac4544a7 100644 --- a/osu.Game/Online/Spectator/SpectatorClient.cs +++ b/osu.Game/Online/Spectator/SpectatorClient.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -89,8 +90,10 @@ public abstract partial class SpectatorClient : Component, ISpectatorClient private Score? currentScore; private long? currentScoreToken; private ScoreProcessor? currentScoreProcessor; + private long currentFrameBundleSequenceNumber; private readonly Queue pendingFrameBundles = new Queue(); + private readonly Dictionary> allFrameBundles = new Dictionary>(); private readonly List pendingFrames = new List(); @@ -284,6 +287,8 @@ public void EndPlaying(GameplayState state) if (pendingFrames.Count > 0) purgePendingFrames(); + currentState.LastFrameBundleSequenceNumber = currentFrameBundleSequenceNumber; + clearScoreState(); if (state.HasPassed) @@ -305,6 +310,9 @@ private void setStateForScore(long? scoreToken, GameplayState state, Score score currentScore = score; currentScoreToken = scoreToken; currentScoreProcessor = state.ScoreProcessor; + currentFrameBundleSequenceNumber = 0; + if (scoreToken != null) + allFrameBundles[scoreToken.Value] = new List(); } private void clearScoreState() @@ -315,6 +323,22 @@ private void clearScoreState() currentScore = null; currentScoreProcessor = null; currentScoreToken = null; + currentFrameBundleSequenceNumber = 0; + } + + public Task CompleteReplay(CompleteReplayRequest completeReplayRequest) + { + if (!allFrameBundles.Remove(completeReplayRequest.ScoreTokenId, out var frameBundlesForScoreToken)) + return Task.FromResult(new CompleteReplayResponse([])); + + var sequenceNumberSet = completeReplayRequest.FrameBundleSequenceNumbers.ToHashSet(); + if (sequenceNumberSet.Count == 0) + return Task.FromResult(new CompleteReplayResponse([])); + + var bundles = frameBundlesForScoreToken.Where(b => b.SequenceNumber != null && sequenceNumberSet.Contains(b.SequenceNumber.Value)) + .OrderBy(b => b.SequenceNumber) + .ToArray(); + return Task.FromResult(new CompleteReplayResponse(bundles)); } public virtual void WatchUser(int userId) @@ -389,7 +413,16 @@ private void purgePendingFrames() Debug.Assert(currentScoreProcessor != null); var frames = pendingFrames.ToArray(); - var bundle = new FrameDataBundle(currentScore.ScoreInfo, currentScoreProcessor, frames); + var bundle = new FrameDataBundle(currentScore.ScoreInfo, currentScoreProcessor, frames) + { + SequenceNumber = Interlocked.Increment(ref currentFrameBundleSequenceNumber) + }; + + if (currentScoreToken != null) + { + Debug.Assert(allFrameBundles.ContainsKey(currentScoreToken.Value)); + allFrameBundles[currentScoreToken.Value].Add(bundle); + } pendingFrames.Clear(); lastPurgeTime = Time.Current; diff --git a/osu.Game/Online/Spectator/SpectatorState.cs b/osu.Game/Online/Spectator/SpectatorState.cs index 91df05bf969d..4f39ce6ea7bf 100644 --- a/osu.Game/Online/Spectator/SpectatorState.cs +++ b/osu.Game/Online/Spectator/SpectatorState.cs @@ -33,6 +33,13 @@ public class SpectatorState : IEquatable [Key(4)] public Dictionary MaximumStatistics { get; set; } = new Dictionary(); + /// + /// The sequence number of the last frame bundle in the replay. + /// Only available in invocations. + /// + [Key(5)] + public long? LastFrameBundleSequenceNumber { get; set; } + public bool Equals(SpectatorState other) { if (ReferenceEquals(null, other)) return false;