From a40230da4b7fd5484ac7f3821cdecdb29ad87d3c Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 28 Oct 2025 19:35:15 +0900 Subject: [PATCH 01/17] Ensure to never display "0th" placement --- osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs index e86a546533..0eaf6c7a81 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs @@ -414,6 +414,9 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match if (!matchmakingState.Users.UserDictionary.TryGetValue(User.Id, out MatchmakingUser? userScore)) return; + if (userScore.Placement == 0) + return; + rankText.Text = userScore.Placement.Ordinalize(CultureInfo.CurrentCulture); rankText.FadeColour(SubScreenResults.ColourForPlacement(userScore.Placement)); scoreText.Text = $"{userScore.Points} pts"; From c524bf54325589393d48ce30ff49861815e7e4e3 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 28 Oct 2025 20:39:09 +0900 Subject: [PATCH 02/17] Make `MachmakingUser.Placement` nullable --- .../MatchTypes/Matchmaking/MatchmakingUser.cs | 2 +- .../OnlinePlay/Matchmaking/Match/PlayerPanel.cs | 6 +++--- .../Matchmaking/Match/PlayerPanelOverlay.cs | 4 ++-- .../Matchmaking/Match/Results/SubScreenResults.cs | 13 ++++++++----- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUser.cs b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUser.cs index f596f2473e..ac97b114d8 100644 --- a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUser.cs +++ b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUser.cs @@ -23,7 +23,7 @@ namespace osu.Game.Online.Multiplayer.MatchTypes.Matchmaking /// The aggregate room placement (1-based). /// [Key(1)] - public int Placement { get; set; } + public int? Placement { get; set; } /// /// The aggregate points. diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs index 0eaf6c7a81..e2455eb020 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs @@ -414,11 +414,11 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match if (!matchmakingState.Users.UserDictionary.TryGetValue(User.Id, out MatchmakingUser? userScore)) return; - if (userScore.Placement == 0) + if (userScore.Placement == null) return; - rankText.Text = userScore.Placement.Ordinalize(CultureInfo.CurrentCulture); - rankText.FadeColour(SubScreenResults.ColourForPlacement(userScore.Placement)); + rankText.Text = userScore.Placement.Value.Ordinalize(CultureInfo.CurrentCulture); + rankText.FadeColour(SubScreenResults.ColourForPlacement(userScore.Placement.Value)); scoreText.Text = $"{userScore.Points} pts"; }); diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanelOverlay.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanelOverlay.cs index 9fb5d258a8..4b97400ebe 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanelOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanelOverlay.cs @@ -239,8 +239,8 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match if (client.Room?.MatchState is not MatchmakingRoomState matchmakingState) continue; - if (matchmakingState.Users.UserDictionary.TryGetValue(panels[i].User.Id, out MatchmakingUser? user)) - SetLayoutPosition(Children[i], user.Placement); + if (matchmakingState.Users.UserDictionary.TryGetValue(panels[i].User.Id, out MatchmakingUser? user) && user.Placement != null) + SetLayoutPosition(Children[i], user.Placement.Value); else SetLayoutPosition(Children[i], float.MaxValue); } diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/SubScreenResults.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/SubScreenResults.cs index 797519a53c..b533a84b28 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/SubScreenResults.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/SubScreenResults.cs @@ -201,13 +201,16 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results return; } - int overallPlacement = state.Users[client.LocalUser!.UserID].Placement; + int? overallPlacement = state.Users[client.LocalUser!.UserID].Placement; - placementText.Text = overallPlacement.Ordinalize(CultureInfo.CurrentCulture); - placementText.Colour = ColourForPlacement(overallPlacement); + if (overallPlacement != null) + { + placementText.Text = overallPlacement.Value.Ordinalize(CultureInfo.CurrentCulture); + placementText.Colour = ColourForPlacement(overallPlacement.Value); - int overallPoints = state.Users[client.LocalUser!.UserID].Points; - addStatistic(overallPlacement, $"Overall position ({overallPoints} points)"); + int overallPoints = state.Users[client.LocalUser!.UserID].Points; + addStatistic(overallPlacement.Value, $"Overall position ({overallPoints} points)"); + } var accuracyOrderedUsers = state.Users.Select(u => (user: u, avgAcc: u.Rounds.Select(r => r.Accuracy).DefaultIfEmpty(0).Average())) .OrderByDescending(t => t.avgAcc) From 627fec2e3a6dca3c6aff4003a4eefddd9d3d9bb8 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 29 Oct 2025 11:18:23 +0900 Subject: [PATCH 03/17] Add failing test case --- .../Visual/Matchmaking/TestSceneResultsScreen.cs | 15 +++++++++++++++ .../Matchmaking/Match/Results/SubScreenResults.cs | 14 +++++++------- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneResultsScreen.cs index 4d1a40cc10..80bf660226 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestSceneResultsScreen.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneResultsScreen.cs @@ -137,5 +137,20 @@ namespace osu.Game.Tests.Visual.Matchmaking MultiplayerClient.ChangeMatchRoomState(state).WaitSafely(); }); } + + [Test] + public void TestNoUsers() + { + AddStep("show results with no users", () => + { + var state = new MatchmakingRoomState + { + CurrentRound = 6, + Stage = MatchmakingStage.Ended + }; + + MultiplayerClient.ChangeMatchRoomState(state).WaitSafely(); + }); + } } } diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/SubScreenResults.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/SubScreenResults.cs index 797519a53c..9e47d161ba 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/SubScreenResults.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/SubScreenResults.cs @@ -255,27 +255,27 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results roomAwards.Clear(); long maxScore = long.MinValue; - int maxScoreUserId = 0; + int maxScoreUserId = -1; double maxAccuracy = double.MinValue; - int maxAccuracyUserId = 0; + int maxAccuracyUserId = -1; int maxCombo = int.MinValue; - int maxComboUserId = 0; + int maxComboUserId = -1; long maxBonusScore = 0; - int maxBonusScoreUserId = 0; + int maxBonusScoreUserId = -1; long largestScoreDifference = long.MinValue; - int largestScoreDifferenceUserId = 0; + int largestScoreDifferenceUserId = -1; long smallestScoreDifference = long.MaxValue; - int smallestScoreDifferenceUserId = 0; + int smallestScoreDifferenceUserId = -1; for (int round = 1; round <= state.CurrentRound; round++) { long roundHighestScore = long.MinValue; - int roundHighestScoreUserId = 0; + int roundHighestScoreUserId = -1; long roundLowestScore = long.MaxValue; From 7b0121a43038ef46785fc0e08095cc3ffca820b0 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 28 Oct 2025 20:46:48 +0900 Subject: [PATCH 04/17] Fix quick play results screen when no one plays --- .../Matchmaking/Match/Results/SubScreenResults.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/SubScreenResults.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/SubScreenResults.cs index 9e47d161ba..403b2836e5 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/SubScreenResults.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/SubScreenResults.cs @@ -344,11 +344,14 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results } } - addAward(maxScoreUserId, "Score champ", "Highest score in a single round"); + if (maxScoreUserId > 0) + addAward(maxScoreUserId, "Score champ", "Highest score in a single round"); - addAward(maxAccuracyUserId, "Most accurate", "Highest accuracy in a single round"); + if (maxAccuracyUserId > 0) + addAward(maxAccuracyUserId, "Most accurate", "Highest accuracy in a single round"); - addAward(maxComboUserId, "Top combo", "Highest combo in a single round"); + if (maxComboUserId > 0) + addAward(maxComboUserId, "Top combo", "Highest combo in a single round"); if (maxBonusScoreUserId > 0) addAward(maxBonusScoreUserId, "Biggest bonus", "Biggest bonus score across all rounds"); From 9a965a25465ff5bf955998b4734720d8deadd456 Mon Sep 17 00:00:00 2001 From: Joseph Madamba Date: Tue, 28 Oct 2025 19:25:18 -0700 Subject: [PATCH 05/17] Add failing drawable date seconds update test --- .../UserInterface/TestSceneDrawableDate.cs | 45 ++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDrawableDate.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDrawableDate.cs index b590abf4e5..e78b4d2496 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDrawableDate.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDrawableDate.cs @@ -2,9 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; +using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; using osu.Game.Graphics; using osuTK; using osuTK.Graphics; @@ -13,25 +16,35 @@ namespace osu.Game.Tests.Visual.UserInterface { public partial class TestSceneDrawableDate : OsuTestScene { - public TestSceneDrawableDate() + [SetUpSteps] + public void SetUpSteps() { - Child = new FillFlowContainer + AddStep("Create 7 dates", () => { - Direction = FillDirection.Vertical, - AutoSizeAxes = Axes.Both, - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Children = new Drawable[] + Child = new FillFlowContainer { - new PokeyDrawableDate(DateTimeOffset.Now.Subtract(TimeSpan.FromSeconds(60))), - new PokeyDrawableDate(DateTimeOffset.Now.Subtract(TimeSpan.FromSeconds(55))), - new PokeyDrawableDate(DateTimeOffset.Now.Subtract(TimeSpan.FromSeconds(50))), - new PokeyDrawableDate(DateTimeOffset.Now), - new PokeyDrawableDate(DateTimeOffset.Now.Add(TimeSpan.FromSeconds(60))), - new PokeyDrawableDate(DateTimeOffset.Now.Add(TimeSpan.FromSeconds(65))), - new PokeyDrawableDate(DateTimeOffset.Now.Add(TimeSpan.FromSeconds(70))), - } - }; + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Children = new Drawable[] + { + new PokeyDrawableDate(DateTimeOffset.Now.Subtract(TimeSpan.FromSeconds(60))), + new PokeyDrawableDate(DateTimeOffset.Now.Subtract(TimeSpan.FromSeconds(55))), + new PokeyDrawableDate(DateTimeOffset.Now.Subtract(TimeSpan.FromSeconds(50))), + new PokeyDrawableDate(DateTimeOffset.Now), + new PokeyDrawableDate(DateTimeOffset.Now.Add(TimeSpan.FromSeconds(60))), + new PokeyDrawableDate(DateTimeOffset.Now.Add(TimeSpan.FromSeconds(65))), + new PokeyDrawableDate(DateTimeOffset.Now.Add(TimeSpan.FromSeconds(70))), + } + }; + }); + } + + [Test] + public void TestSecondsUpdate() + { + AddUntilStep("4th date says \"2 seconds ago\"", () => this.ChildrenOfType().ElementAt(3).Current.Value == "2 seconds ago"); } private partial class PokeyDrawableDate : CompositeDrawable From cbe7da99adc9578ab1fe0161d93fdf9a28d8b0bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 29 Oct 2025 04:14:37 +0100 Subject: [PATCH 06/17] Fix screen footer overlay content being pushed to right during fade-out (#35481) * Apply some renames & drawable names for visualiser Optional but really helps me make heads of tails as to what anything is here. Like really, multiple variations of `footerContent` inside a `ScreenFooter` class, with zero elaboration that it's really content to do with *overlays*... * Fix screen footer overlay content being pushed to right during fade-out - Closes https://github.com/ppy/osu/issues/35203 - Supersedes / closes https://github.com/ppy/osu/pull/35468 --- osu.Game/Screens/Footer/ScreenFooter.cs | 38 ++++++++++++++++--------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/osu.Game/Screens/Footer/ScreenFooter.cs b/osu.Game/Screens/Footer/ScreenFooter.cs index 777ec1790c..5dbc7a55ab 100644 --- a/osu.Game/Screens/Footer/ScreenFooter.cs +++ b/osu.Game/Screens/Footer/ScreenFooter.cs @@ -50,7 +50,7 @@ namespace osu.Game.Screens.Footer private Box background = null!; private FillFlowContainer buttonsFlow = null!; - private Container footerContentContainer = null!; + private Container overlayContentContainer = null!; private Container hiddenButtonsContainer = null!; private LogoTrackingContainer logoTrackingContainer = null!; @@ -102,6 +102,7 @@ namespace osu.Game.Screens.Footer { buttonsFlow = new FillFlowContainer { + Name = "Visible footer buttons", Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, Y = ScreenFooterButton.CORNER_RADIUS, @@ -109,8 +110,9 @@ namespace osu.Game.Screens.Footer Spacing = new Vector2(7, 0), AutoSizeAxes = Axes.Both, }, - footerContentContainer = new Container + overlayContentContainer = new Container { + Name = "Overlay-provided extra content", RelativeSizeAxes = Axes.Both, Y = -OsuGame.SCREEN_EDGE_MARGIN, }, @@ -126,6 +128,7 @@ namespace osu.Game.Screens.Footer }, hiddenButtonsContainer = new Container { + Name = "Hidden footer buttons", Margin = new MarginPadding { Left = OsuGame.SCREEN_EDGE_MARGIN + ScreenBackButton.BUTTON_WIDTH + padding }, Y = ScreenFooterButton.CORNER_RADIUS, Anchor = Anchor.BottomLeft, @@ -234,11 +237,11 @@ namespace osu.Game.Screens.Footer public ShearedOverlayContainer? ActiveOverlay { get; private set; } - private VisibilityContainer? activeFooterContent; + private VisibilityContainer? activeOverlayContent; private readonly List temporarilyHiddenButtons = new List(); - public IDisposable RegisterActiveOverlayContainer(ShearedOverlayContainer overlay, out VisibilityContainer? footerContent) + public IDisposable RegisterActiveOverlayContainer(ShearedOverlayContainer overlay, out VisibilityContainer? overlayContent) { if (ActiveOverlay != null) { @@ -267,12 +270,12 @@ namespace osu.Game.Screens.Footer updateColourScheme(overlay.ColourProvider.Hue); - footerContent = overlay.CreateFooterContent(); - activeFooterContent = footerContent; - var content = footerContent; + overlayContent = overlay.CreateFooterContent(); + activeOverlayContent = overlayContent; + var content = overlayContent; if (content != null) - footerContentContainer.Child = content; + overlayContentContainer.Child = content; if (temporarilyHiddenButtons.Count > 0) this.Delay(60).Schedule(() => content?.Show()); @@ -287,15 +290,19 @@ namespace osu.Game.Screens.Footer if (ActiveOverlay == null) return; - Debug.Assert(activeFooterContent != null); - activeFooterContent.Hide(); + Debug.Assert(activeOverlayContent != null); + activeOverlayContent.Hide(); - double timeUntilRun = activeFooterContent.LatestTransformEndTime - Time.Current; + double timeUntilRun = activeOverlayContent.LatestTransformEndTime - Time.Current; for (int i = 0; i < temporarilyHiddenButtons.Count; i++) { var button = temporarilyHiddenButtons[i]; hiddenButtonsContainer.Remove(button, false); + // temporarily bypass autosize on the X axis to prevent the buttons taking space + // immediately upon being moved back to the flow. + // this prevents the overlay content jumping to the right during its fade-out. + button.BypassAutoSizeAxes = Axes.X; buttonsFlow.Add(button); makeButtonAppearFromBottom(button, 0); @@ -305,8 +312,13 @@ namespace osu.Game.Screens.Footer updateColourScheme(OverlayColourScheme.Aquamarine.GetHue()); - activeFooterContent.Delay(timeUntilRun).Expire(); - activeFooterContent = null; + activeOverlayContent.Delay(timeUntilRun).Schedule(() => + { + // overlay content is done displaying, re-enable autosize on all active buttons + foreach (var button in buttonsFlow) + button.BypassAutoSizeAxes = Axes.None; + }).Expire(); + activeOverlayContent = null; ActiveOverlay = null; } From b4fd7ec10ffa81a4d887dda0578dfbf6bfede334 Mon Sep 17 00:00:00 2001 From: De4n <55669793+tadatomix@users.noreply.github.com> Date: Wed, 29 Oct 2025 06:18:00 +0300 Subject: [PATCH 07/17] Add a keycounter that has been actually used in `Triangles` skin (#35491) --- .../Visual/Gameplay/TestSceneSkinnableKeyCounter.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableKeyCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableKeyCounter.cs index 098f8e3246..8e9df5b2bf 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableKeyCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableKeyCounter.cs @@ -35,7 +35,9 @@ namespace osu.Game.Tests.Visual.Gameplay protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); - protected override Drawable CreateDefaultImplementation() => new ArgonKeyCounterDisplay(); + protected override Drawable CreateArgonImplementation() => new ArgonKeyCounterDisplay(); + + protected override Drawable CreateDefaultImplementation() => new DefaultKeyCounterDisplay(); protected override Drawable CreateLegacyImplementation() => new LegacyKeyCounterDisplay(); } From 050c10cec25a63e5c4cfc076c448b56474997874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 29 Oct 2025 04:18:23 +0100 Subject: [PATCH 08/17] Ensure all invocations of spectator server hub methods have their errors observed (#35488) Fell out when attempting https://github.com/ppy/osu-server-spectator/pull/346. Functionally, if a true non-`HubException` is produced via an invocation of a spectator server hub method, this doesn't really do much - the error will still log as 'unobserved' due to the default handler, it will still show up on sentry, etc. The only difference is that it'll get handled via the continuation installed in `FireAndForget()` rather than the `TaskScheduler.UnobservedTaskException` event. The only real case where this is relevant is when the server throws `HubException`s, which will now instead bubble up to a more human-readable form. Which is relevant to the aforementioned PR because that one makes any hub method potentially throw a `HubException` if the client version is too old. Obviously this does nothing for the existing old clients. --- osu.Game/Online/Metadata/OnlineMetadataClient.cs | 8 ++++---- osu.Game/Online/Multiplayer/MultiplayerClient.cs | 4 ++-- osu.Game/Online/Spectator/SpectatorClient.cs | 9 +++++---- .../OnlinePlay/Matchmaking/Match/ScreenMatchmaking.cs | 2 +- osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs | 2 +- .../OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs | 2 +- .../Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs | 2 +- .../Multiplayer/Spectate/MultiSpectatorScreen.cs | 2 +- 8 files changed, 16 insertions(+), 15 deletions(-) diff --git a/osu.Game/Online/Metadata/OnlineMetadataClient.cs b/osu.Game/Online/Metadata/OnlineMetadataClient.cs index 6402962e85..75b0187388 100644 --- a/osu.Game/Online/Metadata/OnlineMetadataClient.cs +++ b/osu.Game/Online/Metadata/OnlineMetadataClient.cs @@ -89,13 +89,13 @@ namespace osu.Game.Online.Metadata userStatus.BindValueChanged(status => { if (localUser.Value is not GuestUser) - UpdateStatus(status.NewValue); + UpdateStatus(status.NewValue).FireAndForget(); }, true); userActivity.BindValueChanged(activity => { if (localUser.Value is not GuestUser) - UpdateActivity(activity.NewValue); + UpdateActivity(activity.NewValue).FireAndForget(); }, true); } @@ -121,8 +121,8 @@ namespace osu.Game.Online.Metadata if (localUser.Value is not GuestUser) { - UpdateActivity(userActivity.Value); - UpdateStatus(userStatus.Value); + UpdateActivity(userActivity.Value).FireAndForget(); + UpdateStatus(userStatus.Value).FireAndForget(); } if (lastQueueId.Value >= 0) diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index a58d433e7d..44cbbafe72 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -201,7 +201,7 @@ namespace osu.Game.Online.Multiplayer if (!connected.NewValue) { if (Room != null) - LeaveRoom(); + LeaveRoom().FireAndForget(); MatchmakingQueueLeft?.Invoke(); } @@ -560,7 +560,7 @@ namespace osu.Game.Online.Multiplayer return; if (user.Equals(LocalUser)) - LeaveRoom(); + LeaveRoom().FireAndForget(); handleUserLeft(user, UserKicked); }); diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs index 7f09fbdc9e..f245e8cf3a 100644 --- a/osu.Game/Online/Spectator/SpectatorClient.cs +++ b/osu.Game/Online/Spectator/SpectatorClient.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Online.API; +using osu.Game.Online.Multiplayer; using osu.Game.Replays.Legacy; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays.Types; @@ -203,7 +204,7 @@ namespace osu.Game.Online.Spectator Task IStatefulUserHubClient.DisconnectRequested() { - Schedule(() => DisconnectInternal()); + Schedule(() => DisconnectInternal().FireAndForget()); return Task.CompletedTask; } @@ -290,7 +291,7 @@ namespace osu.Game.Online.Spectator else currentState.State = SpectatedUserState.Quit; - EndPlayingInternal(currentState); + EndPlayingInternal(currentState).FireAndForget(); }); } @@ -304,7 +305,7 @@ namespace osu.Game.Online.Spectator return; } - WatchUserInternal(userId); + WatchUserInternal(userId).FireAndForget(); } public void StopWatchingUser(int userId) @@ -321,7 +322,7 @@ namespace osu.Game.Online.Spectator watchedUsersRefCounts.Remove(userId); watchedUserStates.Remove(userId); - StopWatchingUserInternal(userId); + StopWatchingUserInternal(userId).FireAndForget(); }); } diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.cs index 95e3cb0236..527b1ba243 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/ScreenMatchmaking.cs @@ -392,7 +392,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match return; } - client.ChangeState(MultiplayerUserState.Idle); + client.ChangeState(MultiplayerUserState.Idle).FireAndForget(); } /// diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs index 0b06a16d98..eb387b2664 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs @@ -87,7 +87,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Debug.Assert(client.LocalUser != null); if (client.LocalUser.State == MultiplayerUserState.Results) - client.ChangeState(MultiplayerUserState.Idle); + client.ChangeState(MultiplayerUserState.Idle).FireAndForget(); } protected override string ScreenTitle => "Multiplayer"; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index bbac86fd2d..16c6a46a9c 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -618,7 +618,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer updateGameplayState(); if (client.LocalUser.State == MultiplayerUserState.Ready) - client.ChangeState(MultiplayerUserState.Idle); + client.ChangeState(MultiplayerUserState.Idle).FireAndForget(); break; } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index a001863780..56120120d5 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -161,7 +161,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (client.LocalUser?.State == MultiplayerUserState.Loaded) { loadingDisplay.Show(); - client.ChangeState(MultiplayerUserState.ReadyForGameplay); + client.ChangeState(MultiplayerUserState.ReadyForGameplay).FireAndForget(); } // This will pause the clock, pending the gameplay started callback from the server. diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index 200e6a715d..fb9343c519 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -296,7 +296,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate // On a manual exit, set the player back to idle unless gameplay has finished. // Of note, this doesn't cover exiting using alt-f4 or menu home option. if (multiplayerClient.Room.State != MultiplayerRoomState.Open) - multiplayerClient.ChangeState(MultiplayerUserState.Idle); + multiplayerClient.ChangeState(MultiplayerUserState.Idle).FireAndForget(); return base.OnBackButton(); } From 4e76bd0f240e5cd8350e33f5753b253e0ca05033 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 29 Oct 2025 13:58:20 +0900 Subject: [PATCH 09/17] Play sound when match is available even when queueing in background (#35496) --- .../Matchmaking/Queue/QueueController.cs | 99 +++++++++++++------ 1 file changed, 67 insertions(+), 32 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs index 40ac0e5777..3b9fc145d6 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; @@ -32,11 +34,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue [Resolved] private INotificationOverlay? notifications { get; set; } - [Resolved] - private IPerformFromScreenRunner? performer { get; set; } - - private ProgressNotification? backgroundNotification; - private Notification? readyNotification; + private BackgroundQueueNotification? backgroundNotification; private bool isBackgrounded; protected override void LoadComplete() @@ -118,27 +116,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue if (backgroundNotification != null) return; - notifications?.Post(backgroundNotification = new ProgressNotification - { - Text = "Searching for opponents...", - CompletionTarget = n => notifications.Post(readyNotification = n), - CompletionText = "Your match is ready! Click to join.", - CompletionClickAction = () => - { - client.MatchmakingAcceptInvitation().FireAndForget(); - performer?.PerformFromScreen(s => s.Push(new IntroScreen())); - - closeNotifications(); - return true; - }, - CancelRequested = () => - { - client.MatchmakingLeaveQueue().FireAndForget(); - - closeNotifications(); - return true; - } - }); + notifications?.Post(backgroundNotification = new BackgroundQueueNotification()); } private void closeNotifications() @@ -146,13 +124,9 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue if (backgroundNotification != null) { backgroundNotification.State = ProgressNotificationState.Cancelled; - backgroundNotification.Close(false); + backgroundNotification.CloseAll(); + backgroundNotification = null; } - - readyNotification?.Close(false); - - backgroundNotification = null; - readyNotification = null; } protected override void Dispose(bool isDisposing) @@ -168,5 +142,66 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue client.MatchmakingRoomReady -= onMatchmakingRoomReady; } } + + private partial class BackgroundQueueNotification : ProgressNotification + { + [Resolved] + private IPerformFromScreenRunner? performer { get; set; } + + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + private Notification? foundNotification; + private Sample? matchFoundSample; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + Text = "Searching for opponents..."; + + CompletionClickAction = () => + { + client.MatchmakingAcceptInvitation().FireAndForget(); + performer?.PerformFromScreen(s => s.Push(new IntroScreen())); + + Close(false); + return true; + }; + + CancelRequested = () => + { + client.MatchmakingLeaveQueue().FireAndForget(); + return true; + }; + + matchFoundSample = audio.Samples.Get(@"Multiplayer/Matchmaking/match-found"); + } + + protected override Notification CreateCompletionNotification() + { + // Playing here means it will play even if notification overlay is hidden. + // + // If we add support for the completion notification to be processed during gameplay, + // this can be moved inside the `MatchFoundNotification` implementation. + matchFoundSample?.Play(); + + return foundNotification = new MatchFoundNotification + { + Activated = CompletionClickAction, + Text = "Your match is ready! Click to join.", + }; + } + + public void CloseAll() + { + foundNotification?.Close(false); + Close(false); + } + + public partial class MatchFoundNotification : ProgressCompletionNotification + { + // for future use. + } + } } } From bd912710f139db7c2fa3c03a297598c11071b473 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 29 Oct 2025 14:46:50 +0900 Subject: [PATCH 10/17] Add quick play helpers to add users/rounds --- .../Matchmaking/MatchmakingRoundList.cs | 23 +++++++++++-------- .../Matchmaking/MatchmakingUserList.cs | 23 +++++++++++-------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoundList.cs b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoundList.cs index c34d1771f8..a934b61511 100644 --- a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoundList.cs +++ b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoundList.cs @@ -25,16 +25,7 @@ namespace osu.Game.Online.Multiplayer.MatchTypes.Matchmaking /// Creates or retrieves the score for the given round. /// /// The round. - public MatchmakingRound this[int round] - { - get - { - if (RoundsDictionary.TryGetValue(round, out MatchmakingRound? score)) - return score; - - return RoundsDictionary[round] = new MatchmakingRound { Round = round }; - } - } + public MatchmakingRound this[int round] => GetOrAdd(round); /// /// The total number of rounds. @@ -42,6 +33,18 @@ namespace osu.Game.Online.Multiplayer.MatchTypes.Matchmaking [IgnoreMember] public int Count => RoundsDictionary.Count; + /// + /// Retrieves or adds a entry to this list. + /// + /// The round. + public MatchmakingRound GetOrAdd(int round) + { + if (RoundsDictionary.TryGetValue(round, out MatchmakingRound? score)) + return score; + + return RoundsDictionary[round] = new MatchmakingRound { Round = round }; + } + public IEnumerator GetEnumerator() => RoundsDictionary.Values.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); diff --git a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUserList.cs b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUserList.cs index 600134de4e..dd8fc72eb9 100644 --- a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUserList.cs +++ b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUserList.cs @@ -25,16 +25,7 @@ namespace osu.Game.Online.Multiplayer.MatchTypes.Matchmaking /// Creates or retrieves the user for the given id. /// /// The user id. - public MatchmakingUser this[int userId] - { - get - { - if (UserDictionary.TryGetValue(userId, out MatchmakingUser? user)) - return user; - - return UserDictionary[userId] = new MatchmakingUser { UserId = userId }; - } - } + public MatchmakingUser this[int userId] => GetOrAdd(userId); /// /// The total number of users. @@ -42,6 +33,18 @@ namespace osu.Game.Online.Multiplayer.MatchTypes.Matchmaking [IgnoreMember] public int Count => UserDictionary.Count; + /// + /// Retrieves or adds a entry to this list. + /// + /// The user ID. + public MatchmakingUser GetOrAdd(int userId) + { + if (UserDictionary.TryGetValue(userId, out MatchmakingUser? user)) + return user; + + return UserDictionary[userId] = new MatchmakingUser { UserId = userId }; + } + public IEnumerator GetEnumerator() => UserDictionary.Values.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); From 2d177226fdc14974a9520eecc7c8198247a62ee8 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 29 Oct 2025 15:08:32 +0900 Subject: [PATCH 11/17] Add failing test --- .../TestSceneBeatmapSelectPanel.cs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectPanel.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectPanel.cs index 02c669aaf5..01f76157f1 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectPanel.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectPanel.cs @@ -1,11 +1,13 @@ // 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 NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Graphics.Cursor; using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; using osu.Game.Overlays; @@ -62,5 +64,41 @@ namespace osu.Game.Tests.Visual.Matchmaking panel.AllowSelection = value; }); } + + [Test] + public void TestFailedBeatmapLookup() + { + AddStep("setup request handle", () => + { + var api = (DummyAPIAccess)API; + var handler = api.HandleRequest; + api.HandleRequest = req => + { + switch (req) + { + case GetBeatmapRequest: + case GetBeatmapsRequest: + req.TriggerFailure(new InvalidOperationException()); + return false; + + default: + return handler?.Invoke(req) ?? false; + } + }; + }); + + AddStep("add panel", () => + { + Child = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = new BeatmapSelectPanel(new MultiplayerPlaylistItem()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + }); + } } } From e9260de56fcda11d4111757851e7ebc714a86022 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 29 Oct 2025 15:15:36 +0900 Subject: [PATCH 12/17] Fix potential nullref if beatmap lookup fails --- .../Match/BeatmapSelect/BeatmapSelectPanel.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectPanel.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectPanel.cs index 001804a521..aa0329ad94 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectPanel.cs @@ -111,7 +111,17 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect { Debug.Assert(card == null); - var beatmap = b.GetResultSafely()!; + APIBeatmap beatmap = b.GetResultSafely() ?? new APIBeatmap + { + BeatmapSet = new APIBeatmapSet + { + Title = "unknown beatmap", + TitleUnicode = "unknown beatmap", + Artist = "unknown artist", + ArtistUnicode = "unknown artist", + } + }; + beatmap.StarRating = Item.StarRating; mainContent.Add(card = new BeatmapCardMatchmaking(beatmap) From 722cfb72d8b82301889554726e93b3ab7b09f103 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 29 Oct 2025 16:07:33 +0900 Subject: [PATCH 13/17] Replace indexers with `GetOrAdd()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bartłomiej Dach --- .../Matchmaking/MatchmakingRoomStateTest.cs | 76 +++++++++---------- .../Matchmaking/TestSceneMatchmakingScreen.cs | 10 +-- .../TestScenePlayerPanelOverlay.cs | 2 +- .../Matchmaking/TestSceneResultsScreen.cs | 52 ++++++------- .../Matchmaking/MatchmakingRoomState.cs | 4 +- .../Matchmaking/MatchmakingRoundList.cs | 6 -- .../Matchmaking/MatchmakingUserList.cs | 6 -- .../Match/Results/SubScreenResults.cs | 10 ++- 8 files changed, 78 insertions(+), 88 deletions(-) diff --git a/osu.Game.Tests/Online/Matchmaking/MatchmakingRoomStateTest.cs b/osu.Game.Tests/Online/Matchmaking/MatchmakingRoomStateTest.cs index c9219c871a..5f82d22ae8 100644 --- a/osu.Game.Tests/Online/Matchmaking/MatchmakingRoomStateTest.cs +++ b/osu.Game.Tests/Online/Matchmaking/MatchmakingRoomStateTest.cs @@ -29,17 +29,17 @@ namespace osu.Game.Tests.Online.Matchmaking new SoloScoreInfo { UserID = 3, TotalScore = 750 }, ], placement_points); - Assert.AreEqual(8, state.Users[1].Points); - Assert.AreEqual(1, state.Users[1].Placement); - Assert.AreEqual(1, state.Users[1].Rounds[1].Placement); + Assert.AreEqual(8, state.Users.GetOrAdd(1).Points); + Assert.AreEqual(1, state.Users.GetOrAdd(1).Placement); + Assert.AreEqual(1, state.Users.GetOrAdd(1).Rounds.GetOrAdd(1).Placement); - Assert.AreEqual(6, state.Users[2].Points); - Assert.AreEqual(3, state.Users[2].Placement); - Assert.AreEqual(3, state.Users[2].Rounds[1].Placement); + Assert.AreEqual(6, state.Users.GetOrAdd(2).Points); + Assert.AreEqual(3, state.Users.GetOrAdd(2).Placement); + Assert.AreEqual(3, state.Users.GetOrAdd(2).Rounds.GetOrAdd(1).Placement); - Assert.AreEqual(7, state.Users[3].Points); - Assert.AreEqual(2, state.Users[3].Placement); - Assert.AreEqual(2, state.Users[3].Rounds[1].Placement); + Assert.AreEqual(7, state.Users.GetOrAdd(3).Points); + Assert.AreEqual(2, state.Users.GetOrAdd(3).Placement); + Assert.AreEqual(2, state.Users.GetOrAdd(3).Rounds.GetOrAdd(1).Placement); // 2 -> 1 -> 3 @@ -51,17 +51,17 @@ namespace osu.Game.Tests.Online.Matchmaking new SoloScoreInfo { UserID = 3, TotalScore = 500 }, ], placement_points); - Assert.AreEqual(15, state.Users[1].Points); - Assert.AreEqual(1, state.Users[1].Placement); - Assert.AreEqual(2, state.Users[1].Rounds[2].Placement); + Assert.AreEqual(15, state.Users.GetOrAdd(1).Points); + Assert.AreEqual(1, state.Users.GetOrAdd(1).Placement); + Assert.AreEqual(2, state.Users.GetOrAdd(1).Rounds.GetOrAdd(2).Placement); - Assert.AreEqual(14, state.Users[2].Points); - Assert.AreEqual(2, state.Users[2].Placement); - Assert.AreEqual(1, state.Users[2].Rounds[2].Placement); + Assert.AreEqual(14, state.Users.GetOrAdd(2).Points); + Assert.AreEqual(2, state.Users.GetOrAdd(2).Placement); + Assert.AreEqual(1, state.Users.GetOrAdd(2).Rounds.GetOrAdd(2).Placement); - Assert.AreEqual(13, state.Users[3].Points); - Assert.AreEqual(3, state.Users[3].Placement); - Assert.AreEqual(3, state.Users[3].Rounds[2].Placement); + Assert.AreEqual(13, state.Users.GetOrAdd(3).Points); + Assert.AreEqual(3, state.Users.GetOrAdd(3).Placement); + Assert.AreEqual(3, state.Users.GetOrAdd(3).Rounds.GetOrAdd(2).Placement); } [Test] @@ -80,21 +80,21 @@ namespace osu.Game.Tests.Online.Matchmaking new SoloScoreInfo { UserID = 4, TotalScore = 500 }, ], placement_points); - Assert.AreEqual(7, state.Users[1].Points); - Assert.AreEqual(1, state.Users[1].Placement); - Assert.AreEqual(2, state.Users[1].Rounds[1].Placement); + Assert.AreEqual(7, state.Users.GetOrAdd(1).Points); + Assert.AreEqual(1, state.Users.GetOrAdd(1).Placement); + Assert.AreEqual(2, state.Users.GetOrAdd(1).Rounds.GetOrAdd(1).Placement); - Assert.AreEqual(7, state.Users[2].Points); - Assert.AreEqual(2, state.Users[2].Placement); - Assert.AreEqual(2, state.Users[2].Rounds[1].Placement); + Assert.AreEqual(7, state.Users.GetOrAdd(2).Points); + Assert.AreEqual(2, state.Users.GetOrAdd(2).Placement); + Assert.AreEqual(2, state.Users.GetOrAdd(2).Rounds.GetOrAdd(1).Placement); - Assert.AreEqual(5, state.Users[3].Points); - Assert.AreEqual(3, state.Users[3].Placement); - Assert.AreEqual(4, state.Users[3].Rounds[1].Placement); + Assert.AreEqual(5, state.Users.GetOrAdd(3).Points); + Assert.AreEqual(3, state.Users.GetOrAdd(3).Placement); + Assert.AreEqual(4, state.Users.GetOrAdd(3).Rounds.GetOrAdd(1).Placement); - Assert.AreEqual(5, state.Users[4].Points); - Assert.AreEqual(4, state.Users[4].Placement); - Assert.AreEqual(4, state.Users[4].Rounds[1].Placement); + Assert.AreEqual(5, state.Users.GetOrAdd(4).Points); + Assert.AreEqual(4, state.Users.GetOrAdd(4).Placement); + Assert.AreEqual(4, state.Users.GetOrAdd(4).Rounds.GetOrAdd(1).Placement); } [Test] @@ -120,8 +120,8 @@ namespace osu.Game.Tests.Online.Matchmaking new SoloScoreInfo { UserID = 2, TotalScore = 1000 }, ], placement_points); - Assert.AreEqual(1, state.Users[1].Placement); - Assert.AreEqual(2, state.Users[2].Placement); + Assert.AreEqual(1, state.Users.GetOrAdd(1).Placement); + Assert.AreEqual(2, state.Users.GetOrAdd(2).Placement); } [Test] @@ -142,12 +142,12 @@ namespace osu.Game.Tests.Online.Matchmaking new SoloScoreInfo { UserID = 5, TotalScore = 1000 }, ], placement_points); - Assert.AreEqual(1, state.Users[1].Placement); - Assert.AreEqual(2, state.Users[2].Placement); - Assert.AreEqual(3, state.Users[3].Placement); - Assert.AreEqual(4, state.Users[4].Placement); - Assert.AreEqual(5, state.Users[5].Placement); - Assert.AreEqual(6, state.Users[6].Placement); + Assert.AreEqual(1, state.Users.GetOrAdd(1).Placement); + Assert.AreEqual(2, state.Users.GetOrAdd(2).Placement); + Assert.AreEqual(3, state.Users.GetOrAdd(3).Placement); + Assert.AreEqual(4, state.Users.GetOrAdd(4).Placement); + Assert.AreEqual(5, state.Users.GetOrAdd(5).Placement); + Assert.AreEqual(6, state.Users.GetOrAdd(6).Placement); } } } diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingScreen.cs index a598ce9a39..e88b10d30d 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingScreen.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneMatchmakingScreen.cs @@ -124,11 +124,11 @@ namespace osu.Game.Tests.Visual.Matchmaking foreach (var user in MultiplayerClient.ServerRoom!.Users.OrderBy(_ => RNG.Next())) { - state.Users[user.UserID].Placement = i++; - state.Users[user.UserID].Points = (8 - i) * 7; - state.Users[user.UserID].Rounds[1].Placement = 1; - state.Users[user.UserID].Rounds[1].TotalScore = 1; - state.Users[user.UserID].Rounds[1].Statistics[HitResult.LargeBonus] = 1; + state.Users.GetOrAdd(user.UserID).Placement = i++; + state.Users.GetOrAdd(user.UserID).Points = (8 - i) * 7; + state.Users.GetOrAdd(user.UserID).Rounds.GetOrAdd(1).Placement = 1; + state.Users.GetOrAdd(user.UserID).Rounds.GetOrAdd(1).TotalScore = 1; + state.Users.GetOrAdd(user.UserID).Rounds.GetOrAdd(1).Statistics[HitResult.LargeBonus] = 1; } }); } diff --git a/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanelOverlay.cs b/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanelOverlay.cs index c2b2b95d55..16f15014fb 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanelOverlay.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanelOverlay.cs @@ -153,7 +153,7 @@ namespace osu.Game.Tests.Visual.Matchmaking MatchmakingRoomState state = new MatchmakingRoomState(); for (int i = 0; i < room.Users.Count; i++) - state.Users[room.Users[i].UserID].Placement = placements[i]; + state.Users.GetOrAdd(room.Users[i].UserID).Placement = placements[i]; MultiplayerClient.ChangeMatchRoomState(state).WaitSafely(); }); diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneResultsScreen.cs index 4d1a40cc10..9111bbd1c8 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestSceneResultsScreen.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneResultsScreen.cs @@ -36,28 +36,28 @@ namespace osu.Game.Tests.Visual.Matchmaking int localUserId = API.LocalUser.Value.OnlineID; // Overall state. - state.Users[localUserId].Placement = 1; - state.Users[localUserId].Points = 8; + state.Users.GetOrAdd(localUserId).Placement = 1; + state.Users.GetOrAdd(localUserId).Points = 8; for (int round = 1; round <= state.CurrentRound; round++) - state.Users[localUserId].Rounds[round].Placement = round; + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(round).Placement = round; // Highest score. - state.Users[localUserId].Rounds[1].TotalScore = 1000; + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(1).TotalScore = 1000; // Highest accuracy. - state.Users[localUserId].Rounds[2].Accuracy = 0.9995; + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(2).Accuracy = 0.9995; // Highest combo. - state.Users[localUserId].Rounds[3].MaxCombo = 100; + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(3).MaxCombo = 100; // Most bonus score. - state.Users[localUserId].Rounds[4].Statistics[HitResult.LargeBonus] = 50; + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(4).Statistics[HitResult.LargeBonus] = 50; // Smallest score difference. - state.Users[localUserId].Rounds[5].TotalScore = 1000; + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(5).TotalScore = 1000; // Largest score difference. - state.Users[localUserId].Rounds[6].TotalScore = 1000; + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(6).TotalScore = 1000; MultiplayerClient.ChangeMatchRoomState(state).WaitSafely(); }); @@ -103,36 +103,36 @@ namespace osu.Game.Tests.Visual.Matchmaking int localUserId = API.LocalUser.Value.OnlineID; // Overall state. - state.Users[localUserId].Placement = 1; - state.Users[localUserId].Points = 8; - state.Users[invalid_user_id].Placement = 2; - state.Users[invalid_user_id].Points = 7; + state.Users.GetOrAdd(localUserId).Placement = 1; + state.Users.GetOrAdd(localUserId).Points = 8; + state.Users.GetOrAdd(invalid_user_id).Placement = 2; + state.Users.GetOrAdd(invalid_user_id).Points = 7; for (int round = 1; round <= state.CurrentRound; round++) - state.Users[localUserId].Rounds[round].Placement = round; + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(round).Placement = round; // Highest score. - state.Users[localUserId].Rounds[1].TotalScore = 1000; - state.Users[invalid_user_id].Rounds[1].TotalScore = 990; + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(1).TotalScore = 1000; + state.Users.GetOrAdd(invalid_user_id).Rounds.GetOrAdd(1).TotalScore = 990; // Highest accuracy. - state.Users[localUserId].Rounds[2].Accuracy = 0.9995; - state.Users[invalid_user_id].Rounds[2].Accuracy = 0.5; + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(2).Accuracy = 0.9995; + state.Users.GetOrAdd(invalid_user_id).Rounds.GetOrAdd(2).Accuracy = 0.5; // Highest combo. - state.Users[localUserId].Rounds[3].MaxCombo = 100; - state.Users[invalid_user_id].Rounds[3].MaxCombo = 10; + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(3).MaxCombo = 100; + state.Users.GetOrAdd(invalid_user_id).Rounds.GetOrAdd(3).MaxCombo = 10; // Most bonus score. - state.Users[localUserId].Rounds[4].Statistics[HitResult.LargeBonus] = 50; - state.Users[invalid_user_id].Rounds[4].Statistics[HitResult.LargeBonus] = 25; + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(4).Statistics[HitResult.LargeBonus] = 50; + state.Users.GetOrAdd(invalid_user_id).Rounds.GetOrAdd(4).Statistics[HitResult.LargeBonus] = 25; // Smallest score difference. - state.Users[localUserId].Rounds[5].TotalScore = 1000; - state.Users[invalid_user_id].Rounds[5].TotalScore = 999; + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(5).TotalScore = 1000; + state.Users.GetOrAdd(invalid_user_id).Rounds.GetOrAdd(5).TotalScore = 999; // Largest score difference. - state.Users[localUserId].Rounds[6].TotalScore = 1000; - state.Users[invalid_user_id].Rounds[6].TotalScore = 0; + state.Users.GetOrAdd(localUserId).Rounds.GetOrAdd(6).TotalScore = 1000; + state.Users.GetOrAdd(invalid_user_id).Rounds.GetOrAdd(6).TotalScore = 0; MultiplayerClient.ChangeMatchRoomState(state).WaitSafely(); }); diff --git a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoomState.cs b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoomState.cs index 9e1953fc59..b55fa63844 100644 --- a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoomState.cs +++ b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoomState.cs @@ -81,10 +81,10 @@ namespace osu.Game.Online.Multiplayer.MatchTypes.Matchmaking foreach (var score in scoreGroup) { - MatchmakingUser mmUser = Users[score.UserID]; + MatchmakingUser mmUser = Users.GetOrAdd(score.UserID); mmUser.Points += placementPoints[placement - 1]; - MatchmakingRound mmRound = mmUser.Rounds[CurrentRound]; + MatchmakingRound mmRound = mmUser.Rounds.GetOrAdd(CurrentRound); mmRound.Placement = placement; mmRound.TotalScore = score.TotalScore; mmRound.Accuracy = score.Accuracy; diff --git a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoundList.cs b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoundList.cs index a934b61511..fb9a713c10 100644 --- a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoundList.cs +++ b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingRoundList.cs @@ -21,12 +21,6 @@ namespace osu.Game.Online.Multiplayer.MatchTypes.Matchmaking [Key(0)] public IDictionary RoundsDictionary { get; set; } = new Dictionary(); - /// - /// Creates or retrieves the score for the given round. - /// - /// The round. - public MatchmakingRound this[int round] => GetOrAdd(round); - /// /// The total number of rounds. /// diff --git a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUserList.cs b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUserList.cs index dd8fc72eb9..23a246db5d 100644 --- a/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUserList.cs +++ b/osu.Game/Online/Multiplayer/MatchTypes/Matchmaking/MatchmakingUserList.cs @@ -21,12 +21,6 @@ namespace osu.Game.Online.Multiplayer.MatchTypes.Matchmaking [Key(0)] public IDictionary UserDictionary { get; set; } = new Dictionary(); - /// - /// Creates or retrieves the user for the given id. - /// - /// The user id. - public MatchmakingUser this[int userId] => GetOrAdd(userId); - /// /// The total number of users. /// diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/SubScreenResults.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/SubScreenResults.cs index 797519a53c..27afcacf9a 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/SubScreenResults.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/Results/SubScreenResults.cs @@ -194,19 +194,21 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results { userStatistics.Clear(); - if (state.Users[client.LocalUser!.UserID].Rounds.Count == 0) + var localUserState = state.Users.GetOrAdd(client.LocalUser!.UserID); + + if (localUserState.Rounds.Count == 0) { placementText.Text = "-"; placementText.Colour = OsuColour.Gray(1f); return; } - int overallPlacement = state.Users[client.LocalUser!.UserID].Placement; + int overallPlacement = localUserState.Placement; placementText.Text = overallPlacement.Ordinalize(CultureInfo.CurrentCulture); placementText.Colour = ColourForPlacement(overallPlacement); - int overallPoints = state.Users[client.LocalUser!.UserID].Points; + int overallPoints = localUserState.Points; addStatistic(overallPlacement, $"Overall position ({overallPoints} points)"); var accuracyOrderedUsers = state.Users.Select(u => (user: u, avgAcc: u.Rounds.Select(r => r.Accuracy).DefaultIfEmpty(0).Average())) @@ -223,7 +225,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results int maxComboPlacement = maxComboOrderedUsers.index + 1; addStatistic(maxComboPlacement, $"Best max combo ({maxComboOrderedUsers.info.maxCombo}x)"); - var bestPlacement = state.Users[client.LocalUser!.UserID].Rounds.MinBy(r => r.Placement); + var bestPlacement = localUserState.Rounds.MinBy(r => r.Placement); addStatistic(bestPlacement!.Placement, $"Best round placement (round {bestPlacement.Round})"); void addStatistic(int position, string text) => userStatistics.Add(new PanelUserStatistic(position, text)); From beb977892ebb47fd044b99568a08421fc4a6a0d8 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 29 Oct 2025 16:47:02 +0900 Subject: [PATCH 14/17] Use better iconography and colour for queue completion notification --- .../OnlinePlay/Matchmaking/Queue/QueueController.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs index 3b9fc145d6..80cc6e1bd7 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs @@ -7,7 +7,10 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Sprites; using osu.Framework.Screens; +using osu.Game.Graphics; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Overlays; @@ -200,7 +203,14 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue public partial class MatchFoundNotification : ProgressCompletionNotification { - // for future use. + protected override IconUsage CloseButtonIcon => FontAwesome.Solid.Times; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Icon = FontAwesome.Solid.Bolt; + IconContent.Colour = ColourInfo.GradientVertical(colours.YellowDark, colours.YellowLight); + } } } } From ee7c52465b608af12ca9a8ef9f8b52c0260e0642 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 29 Oct 2025 16:55:14 +0900 Subject: [PATCH 15/17] Allow queue completion notification to show even during gameplay --- osu.Game/Overlays/NotificationOverlay.cs | 7 +++++-- osu.Game/Overlays/NotificationOverlayToastTray.cs | 5 +++++ osu.Game/Overlays/Notifications/Notification.cs | 5 +++++ .../OnlinePlay/Matchmaking/Queue/QueueController.cs | 5 +++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/osu.Game/Overlays/NotificationOverlay.cs b/osu.Game/Overlays/NotificationOverlay.cs index f56e5e6ac3..7ef2fffeda 100644 --- a/osu.Game/Overlays/NotificationOverlay.cs +++ b/osu.Game/Overlays/NotificationOverlay.cs @@ -162,16 +162,17 @@ namespace osu.Game.Overlays private int runningDepth; private readonly Scheduler postScheduler = new Scheduler(); + private readonly Scheduler criticalPostScheduler = new Scheduler(); public override bool IsPresent => // Delegate presence as we need to consider the toast tray in addition to the main overlay. - State.Value == Visibility.Visible || mainContent.IsPresent || toastTray.IsPresent || postScheduler.HasPendingTasks; + State.Value == Visibility.Visible || mainContent.IsPresent || toastTray.IsPresent || postScheduler.HasPendingTasks || criticalPostScheduler.HasPendingTasks; private bool processingPosts = true; private double? lastSamplePlayback; - public void Post(Notification notification) => postScheduler.Add(() => + public void Post(Notification notification) => (notification.IsCritical ? criticalPostScheduler : postScheduler).Add(() => { ++runningDepth; @@ -220,6 +221,8 @@ namespace osu.Game.Overlays { base.Update(); + criticalPostScheduler.Update(); + if (processingPosts) postScheduler.Update(); } diff --git a/osu.Game/Overlays/NotificationOverlayToastTray.cs b/osu.Game/Overlays/NotificationOverlayToastTray.cs index dd60e303f6..e66b999540 100644 --- a/osu.Game/Overlays/NotificationOverlayToastTray.cs +++ b/osu.Game/Overlays/NotificationOverlayToastTray.cs @@ -91,7 +91,12 @@ namespace osu.Game.Overlays public void FlushAllToasts() { foreach (var notification in toastFlow.ToArray()) + { + if (notification.IsCritical) + continue; + forwardNotification(notification); + } } public void Post(Notification notification) diff --git a/osu.Game/Overlays/Notifications/Notification.cs b/osu.Game/Overlays/Notifications/Notification.cs index ccfd1adb39..99d575da56 100644 --- a/osu.Game/Overlays/Notifications/Notification.cs +++ b/osu.Game/Overlays/Notifications/Notification.cs @@ -39,6 +39,11 @@ namespace osu.Game.Overlays.Notifications /// public bool IsImportant { get; init; } = true; + /// + /// Critical notifications show even during gameplay or other scenarios where notifications would usually be suppressed. + /// + public bool IsCritical { get; init; } = false; + /// /// Transient notifications only show as a toast, and do not linger in notification history. /// diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs index 80cc6e1bd7..468e024a65 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs @@ -205,6 +205,11 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Queue { protected override IconUsage CloseButtonIcon => FontAwesome.Solid.Times; + public MatchFoundNotification() + { + IsCritical = true; + } + [BackgroundDependencyLoader] private void load(OsuColour colours) { From 3afc7b045cf3eeab71be3eef85577387d804f64a Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 29 Oct 2025 17:27:33 +0900 Subject: [PATCH 16/17] Remove redundant default value --- osu.Game/Overlays/Notifications/Notification.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Overlays/Notifications/Notification.cs b/osu.Game/Overlays/Notifications/Notification.cs index 99d575da56..8a2a7cee81 100644 --- a/osu.Game/Overlays/Notifications/Notification.cs +++ b/osu.Game/Overlays/Notifications/Notification.cs @@ -42,7 +42,7 @@ namespace osu.Game.Overlays.Notifications /// /// Critical notifications show even during gameplay or other scenarios where notifications would usually be suppressed. /// - public bool IsCritical { get; init; } = false; + public bool IsCritical { get; init; } /// /// Transient notifications only show as a toast, and do not linger in notification history. From 4c60df21db701e66876c2afaaba5f6282c447802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 29 Oct 2025 11:50:28 +0100 Subject: [PATCH 17/17] Fix `DrawableDate` not updating Co-authored-by: Dean Herbert --- osu.Game/Graphics/DrawableDate.cs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/osu.Game/Graphics/DrawableDate.cs b/osu.Game/Graphics/DrawableDate.cs index 7af4df2d25..e5383bf3a9 100644 --- a/osu.Game/Graphics/DrawableDate.cs +++ b/osu.Game/Graphics/DrawableDate.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; using osu.Game.Utils; @@ -80,7 +81,7 @@ namespace osu.Game.Graphics public DateTimeOffset TooltipContent => Date; - private class HumanisedDate : IEquatable, ILocalisableStringData + private class HumanisedDate : ILocalisableStringData { public readonly DateTimeOffset Date; @@ -89,11 +90,18 @@ namespace osu.Game.Graphics Date = date; } - public bool Equals(HumanisedDate? other) - => other?.Date != null && Date.Equals(other.Date); - - public bool Equals(ILocalisableStringData? other) - => other is HumanisedDate otherDate && Equals(otherDate); + /// + /// Humanizer formats the relative to the local computer time. + /// Therefore, replacing a instance with another instance of the class with the same + /// should have the effect of replacing and re-formatting the text. + /// Including in equality members would stop this from happening, as + /// has equality-based early guards to prevent redundant text replaces. + /// Thus, instances of these class just compare to any to ensure re-formatting happens correctly. + /// There are "technically" more "correct" ways to do this (like also including the current time into equality checks), + /// but they are simultaneously functionally equivalent to this and overly convoluted. + /// This is a private hack-job of a wrapper around humanizer anyway. + /// + public bool Equals(ILocalisableStringData? other) => false; public string GetLocalised(LocalisationParameters parameters) => HumanizerUtils.Humanize(Date);