From 9843da59f4866918391902de30125577b60c1464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 23 Dec 2020 20:29:17 +0100 Subject: [PATCH 1/7] Fix intermittent test fail due to duplicate user `TestSceneRealtimeReadyButton` was manually adding `API.LocalUser`, which wasn't actually needed. The base `RealtimeMultiplayerTestScene` by default creates a new room as `API.LocalUser`, therefore automatically adding that user to the room - and as such there is no need to add them manually unless the `joinRoom` ctor param is specified as `false`. --- .../Visual/RealtimeMultiplayer/TestSceneRealtimeReadyButton.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/osu.Game.Tests/Visual/RealtimeMultiplayer/TestSceneRealtimeReadyButton.cs b/osu.Game.Tests/Visual/RealtimeMultiplayer/TestSceneRealtimeReadyButton.cs index b7cd81fb32..e9d3ddb32d 100644 --- a/osu.Game.Tests/Visual/RealtimeMultiplayer/TestSceneRealtimeReadyButton.cs +++ b/osu.Game.Tests/Visual/RealtimeMultiplayer/TestSceneRealtimeReadyButton.cs @@ -55,8 +55,6 @@ namespace osu.Game.Tests.Visual.RealtimeMultiplayer } } }; - - Client.AddUser(API.LocalUser.Value); }); [Test] From 47020c888731c2370d6f81fbfe9f11d6e7fcfdc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 23 Dec 2020 21:00:47 +0100 Subject: [PATCH 2/7] Add failing test cases --- .../TestSceneRealtimeMultiplayer.cs | 24 +++++++++++++++++++ .../TestRealtimeMultiplayerClient.cs | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/osu.Game.Tests/Visual/RealtimeMultiplayer/TestSceneRealtimeMultiplayer.cs b/osu.Game.Tests/Visual/RealtimeMultiplayer/TestSceneRealtimeMultiplayer.cs index 80955ca380..5cf80df6aa 100644 --- a/osu.Game.Tests/Visual/RealtimeMultiplayer/TestSceneRealtimeMultiplayer.cs +++ b/osu.Game.Tests/Visual/RealtimeMultiplayer/TestSceneRealtimeMultiplayer.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using NUnit.Framework; using osu.Game.Screens.Multi.Components; +using osu.Game.Users; namespace osu.Game.Tests.Visual.RealtimeMultiplayer { @@ -15,6 +17,28 @@ namespace osu.Game.Tests.Visual.RealtimeMultiplayer AddUntilStep("wait for loaded", () => multi.IsLoaded); } + [Test] + public void TestOneUserJoinedMultipleTimes() + { + var user = new User { Id = 33 }; + + AddRepeatStep("add user multiple times", () => Client.AddUser(user), 3); + + AddAssert("room has 2 users", () => Client.Room?.Users.Count == 2); + } + + [Test] + public void TestOneUserLeftMultipleTimes() + { + var user = new User { Id = 44 }; + + AddStep("add user", () => Client.AddUser(user)); + AddAssert("room has 2 users", () => Client.Room?.Users.Count == 2); + + AddRepeatStep("remove user multiple times", () => Client.RemoveUser(user), 3); + AddAssert("room has 1 user", () => Client.Room?.Users.Count == 1); + } + private class TestRealtimeMultiplayer : Screens.Multi.RealtimeMultiplayer.RealtimeMultiplayer { protected override RoomManager CreateRoomManager() => new TestRealtimeRoomManager(); diff --git a/osu.Game/Tests/Visual/RealtimeMultiplayer/TestRealtimeMultiplayerClient.cs b/osu.Game/Tests/Visual/RealtimeMultiplayer/TestRealtimeMultiplayerClient.cs index de52633c88..52047016e2 100644 --- a/osu.Game/Tests/Visual/RealtimeMultiplayer/TestRealtimeMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/RealtimeMultiplayer/TestRealtimeMultiplayerClient.cs @@ -31,7 +31,7 @@ namespace osu.Game.Tests.Visual.RealtimeMultiplayer public void RemoveUser(User user) { Debug.Assert(Room != null); - ((IMultiplayerClient)this).UserLeft(Room.Users.Single(u => u.User == user)); + ((IMultiplayerClient)this).UserLeft(new MultiplayerRoomUser(user.Id)); Schedule(() => { From a71496bc4e9f0386d13b295eb92496f33144e5b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 23 Dec 2020 20:34:19 +0100 Subject: [PATCH 3/7] Sanity check received user joined messages While test failures fixed in 9843da5 were a shortcoming of the test, they exposed a potential vulnerable point of the multiplayer client logic. In case of unreliable message delivery it is not unreasonable that duplicate messages might arrive, in which case the same scenario that failed in the tests could crash the game. To ensure that is not the case, explicitly screen each new joined user against the room user list, to ensure that duplicates do not show up. `UserLeft` is already tolerant in that respect (if a user is requested to be removed twice by the server, the second removal just won't do anything). --- .../Online/RealtimeMultiplayer/StatefulMultiplayerClient.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/osu.Game/Online/RealtimeMultiplayer/StatefulMultiplayerClient.cs b/osu.Game/Online/RealtimeMultiplayer/StatefulMultiplayerClient.cs index 6331d324a6..e8dbeda9cb 100644 --- a/osu.Game/Online/RealtimeMultiplayer/StatefulMultiplayerClient.cs +++ b/osu.Game/Online/RealtimeMultiplayer/StatefulMultiplayerClient.cs @@ -226,6 +226,10 @@ namespace osu.Game.Online.RealtimeMultiplayer if (Room == null) return; + // for sanity, ensure that there can be no duplicate users in the room user list. + if (Room.Users.Any(existing => existing.UserID == user.UserID)) + return; + Room.Users.Add(user); RoomChanged?.Invoke(); From 1f80f01b53f4861a26ee8564bb7b81684cfad488 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 24 Dec 2020 14:46:52 +0900 Subject: [PATCH 4/7] Add accuracy to frame bundle header --- osu.Game/Online/Spectator/FrameHeader.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/osu.Game/Online/Spectator/FrameHeader.cs b/osu.Game/Online/Spectator/FrameHeader.cs index b4988fecf9..135b356eda 100644 --- a/osu.Game/Online/Spectator/FrameHeader.cs +++ b/osu.Game/Online/Spectator/FrameHeader.cs @@ -14,6 +14,11 @@ namespace osu.Game.Online.Spectator [Serializable] public class FrameHeader { + /// + /// The current accuracy of the score. + /// + public double Accuracy { get; set; } + /// /// The current combo of the score. /// @@ -42,16 +47,18 @@ namespace osu.Game.Online.Spectator { Combo = score.Combo; MaxCombo = score.MaxCombo; + Accuracy = score.Accuracy; // copy for safety Statistics = new Dictionary(score.Statistics); } [JsonConstructor] - public FrameHeader(int combo, int maxCombo, Dictionary statistics, DateTimeOffset receivedTime) + public FrameHeader(int combo, int maxCombo, double accuracy, Dictionary statistics, DateTimeOffset receivedTime) { Combo = combo; MaxCombo = maxCombo; + Accuracy = accuracy; Statistics = statistics; ReceivedTime = receivedTime; } From d66e2183185bbedc26b2d7350499e71d3aebc0fb Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 24 Dec 2020 14:57:23 +0900 Subject: [PATCH 5/7] Source display accuracy from header and remove from ScoreProcessor function --- osu.Game/Rulesets/Scoring/ScoreProcessor.cs | 15 +++++---------- .../Play/HUD/MultiplayerGameplayLeaderboard.cs | 4 ++-- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 4b2e2bf715..2024290460 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -193,7 +193,7 @@ namespace osu.Game.Rulesets.Scoring private void updateScore() { if (rollingMaxBaseScore != 0) - Accuracy.Value = baseScore / rollingMaxBaseScore; + Accuracy.Value = calculateAccuracyRatio(baseScore, true); TotalScore.Value = getScore(Mode.Value); } @@ -233,13 +233,13 @@ namespace osu.Game.Rulesets.Scoring } /// - /// Given a minimal set of inputs, return the computed score and accuracy for the tracked beatmap / mods combination, at the current point in time. + /// Given a minimal set of inputs, return the computed score for the tracked beatmap / mods combination, at the current point in time. /// /// The to compute the total score in. /// The maximum combo achievable in the beatmap. /// Statistics to be used for calculating accuracy, bonus score, etc. - /// The computed score and accuracy for provided inputs. - public (double score, double accuracy) GetScoreAndAccuracy(ScoringMode mode, int maxCombo, Dictionary statistics) + /// The computed score for provided inputs. + public double GetImmediateScore(ScoringMode mode, int maxCombo, Dictionary statistics) { // calculate base score from statistics pairs int computedBaseScore = 0; @@ -252,12 +252,7 @@ namespace osu.Game.Rulesets.Scoring computedBaseScore += Judgement.ToNumericResult(pair.Key) * pair.Value; } - double pointInTimeAccuracy = calculateAccuracyRatio(computedBaseScore, true); - double comboRatio = calculateComboRatio(maxCombo); - - double score = GetScore(mode, maxAchievableCombo, calculateAccuracyRatio(computedBaseScore), comboRatio, scoreResultCounts); - - return (score, pointInTimeAccuracy); + return GetScore(mode, maxAchievableCombo, calculateAccuracyRatio(computedBaseScore), calculateComboRatio(maxCombo), scoreResultCounts); } /// diff --git a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs index 12321de442..c10ec9e004 100644 --- a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs @@ -122,8 +122,8 @@ namespace osu.Game.Screens.Play.HUD if (LastHeader == null) return; - (score.Value, accuracy.Value) = processor.GetScoreAndAccuracy(mode, LastHeader.MaxCombo, LastHeader.Statistics); - + score.Value = processor.GetImmediateScore(mode, LastHeader.MaxCombo, LastHeader.Statistics); + accuracy.Value = LastHeader.Accuracy; currentCombo.Value = LastHeader.Combo; } } From e86e9bfae625351e5323c9393e011190dd58c7b8 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 24 Dec 2020 15:32:55 +0900 Subject: [PATCH 6/7] Don't begin gameplay until all users are in a completely prepared state --- .../RealtimeMultiplayer/RealtimePlayer.cs | 39 ++++++++++++------- osu.Game/Screens/Play/Player.cs | 18 +++++++-- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/osu.Game/Screens/Multi/RealtimeMultiplayer/RealtimePlayer.cs b/osu.Game/Screens/Multi/RealtimeMultiplayer/RealtimePlayer.cs index 085c52cb85..20b184bed3 100644 --- a/osu.Game/Screens/Multi/RealtimeMultiplayer/RealtimePlayer.cs +++ b/osu.Game/Screens/Multi/RealtimeMultiplayer/RealtimePlayer.cs @@ -3,12 +3,12 @@ using System; using System.Diagnostics; -using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Logging; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; using osu.Game.Online.RealtimeMultiplayer; using osu.Game.Scoring; @@ -33,13 +33,14 @@ namespace osu.Game.Screens.Multi.RealtimeMultiplayer private IBindable isConnected; private readonly TaskCompletionSource resultsReady = new TaskCompletionSource(); - private readonly ManualResetEventSlim startedEvent = new ManualResetEventSlim(); [CanBeNull] private MultiplayerGameplayLeaderboard leaderboard; private readonly int[] userIds; + private LoadingLayer loadingDisplay; + /// /// Construct a multiplayer player. /// @@ -60,6 +61,12 @@ namespace osu.Game.Screens.Multi.RealtimeMultiplayer client.MatchStarted += onMatchStarted; client.ResultsReady += onResultsReady; + ScoreProcessor.HasCompleted.BindValueChanged(completed => + { + // wait for server to tell us that results are ready (see SubmitScore implementation) + loadingDisplay.Show(); + }); + isConnected = client.IsConnected.GetBoundCopy(); isConnected.BindValueChanged(connected => { @@ -70,19 +77,20 @@ namespace osu.Game.Screens.Multi.RealtimeMultiplayer } }, true); - client.ChangeState(MultiplayerUserState.Loaded) - .ContinueWith(task => failAndBail(task.Exception?.Message ?? "Server error"), TaskContinuationOptions.NotOnRanToCompletion); - - if (!startedEvent.Wait(TimeSpan.FromSeconds(30))) - { - failAndBail("Failed to start the multiplayer match in time."); - return; - } - Debug.Assert(client.Room != null); // todo: this should be implemented via a custom HUD implementation, and correctly masked to the main content area. LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(ScoreProcessor, userIds), HUDOverlay.Add); + + HUDOverlay.Add(loadingDisplay = new LoadingLayer(DrawableRuleset) { Depth = float.MaxValue }); + } + + protected override void StartGameplay() + { + // block base call, but let the server know we are ready to start. + loadingDisplay.Show(); + + client.ChangeState(MultiplayerUserState.Loaded).ContinueWith(task => failAndBail(task.Exception?.Message ?? "Server error"), TaskContinuationOptions.NotOnRanToCompletion); } private void failAndBail(string message = null) @@ -90,7 +98,6 @@ namespace osu.Game.Screens.Multi.RealtimeMultiplayer if (!string.IsNullOrEmpty(message)) Logger.Log(message, LoggingTarget.Runtime, LogLevel.Important); - startedEvent.Set(); Schedule(() => PerformExit(false)); } @@ -112,7 +119,11 @@ namespace osu.Game.Screens.Multi.RealtimeMultiplayer padding + HUDOverlay.TopScoringElementsHeight); } - private void onMatchStarted() => startedEvent.Set(); + private void onMatchStarted() => Scheduler.Add(() => + { + loadingDisplay.Hide(); + base.StartGameplay(); + }); private void onResultsReady() => resultsReady.SetResult(true); @@ -124,7 +135,7 @@ namespace osu.Game.Screens.Multi.RealtimeMultiplayer // Await up to 30 seconds for results to become available (3 api request timeouts). // This is arbitrary just to not leave the player in an essentially deadlocked state if any connection issues occur. - await Task.WhenAny(resultsReady.Task, Task.Delay(TimeSpan.FromSeconds(30))); + await Task.WhenAny(resultsReady.Task, Task.Delay(TimeSpan.FromSeconds(60))); } protected override ResultsScreen CreateResults(ScoreInfo score) diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 2bc84ce5d5..3f761d9e11 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -723,9 +723,6 @@ namespace osu.Game.Screens.Play storyboardReplacesBackground.Value = Beatmap.Value.Storyboard.ReplacesBackground && Beatmap.Value.Storyboard.HasDrawable; - GameplayClockContainer.Restart(); - GameplayClockContainer.FadeInFromZero(750, Easing.OutQuint); - foreach (var mod in Mods.Value.OfType()) mod.ApplyToPlayer(this); @@ -740,6 +737,21 @@ namespace osu.Game.Screens.Play mod.ApplyToTrack(musicController.CurrentTrack); updateGameplayState(); + + GameplayClockContainer.FadeInFromZero(750, Easing.OutQuint); + StartGameplay(); + } + + /// + /// Called to trigger the starting of the gameplay clock and underlying gameplay. + /// This will be called on entering the player screen once. A derived class may block the first call to this to delay the start of gameplay. + /// + protected virtual void StartGameplay() + { + if (GameplayClockContainer.GameplayClock.IsRunning) + throw new InvalidOperationException($"{nameof(StartGameplay)} should not be called when the gameplay clock is already running"); + + GameplayClockContainer.Restart(); } public override void OnSuspending(IScreen next) From 261c250b46dae0db37151601a2d789c0e789b5f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 24 Dec 2020 11:33:49 +0100 Subject: [PATCH 7/7] Update outdated comment --- osu.Game/Screens/Multi/RealtimeMultiplayer/RealtimePlayer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/Multi/RealtimeMultiplayer/RealtimePlayer.cs b/osu.Game/Screens/Multi/RealtimeMultiplayer/RealtimePlayer.cs index 20b184bed3..b1179ea7cd 100644 --- a/osu.Game/Screens/Multi/RealtimeMultiplayer/RealtimePlayer.cs +++ b/osu.Game/Screens/Multi/RealtimeMultiplayer/RealtimePlayer.cs @@ -133,7 +133,7 @@ namespace osu.Game.Screens.Multi.RealtimeMultiplayer await client.ChangeState(MultiplayerUserState.FinishedPlay); - // Await up to 30 seconds for results to become available (3 api request timeouts). + // Await up to 60 seconds for results to become available (6 api request timeouts). // This is arbitrary just to not leave the player in an essentially deadlocked state if any connection issues occur. await Task.WhenAny(resultsReady.Task, Task.Delay(TimeSpan.FromSeconds(60))); }