From 5ad514ca9872b7110817eca3021e6c7c48b907f0 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 21 May 2026 15:38:30 +0900 Subject: [PATCH 1/5] Fix popup dialogs not appearing if pushed when `OverlayActivationMode` is wrong (#37838) This change is a prerequisite for making a migration dialog which runs on startup to let users know that something hs changed. A few cases this could happen: - During start (intro still playing) - During gameplay Basically making dialogs get poofed without the user ever seeing them. Arguably, we should also change the way dialogs are still poofed when activation mode becomes not-`All` (deferring for later response rather than dismissing?). --- .../UserInterface/TestSceneDialogOverlay.cs | 50 ++++++++++++++++++- osu.Game/Overlays/Dialog/PopupDialog.cs | 3 +- osu.Game/Overlays/DialogOverlay.cs | 13 ++++- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs index f2313022ec4b..97f3dd455d2d 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs @@ -7,6 +7,7 @@ using System.Threading; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Testing; @@ -16,10 +17,12 @@ namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public partial class TestSceneDialogOverlay : OsuTestScene + public partial class TestSceneDialogOverlay : OsuTestScene, IOverlayManager { private DialogOverlay overlay; + private readonly Bindable overlayActivationMode = new Bindable(OverlayActivation.All); + [SetUpSteps] public void SetUpSteps() { @@ -99,7 +102,8 @@ public void TestTooMuchText() { Icon = FontAwesome.Regular.TrashAlt, HeaderText = @"Confirm deletion ofConfirm deletion ofConfirm deletion ofConfirm deletion ofConfirm deletion ofConfirm deletion of", - BodyText = @"Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver. ", + BodyText = + @"Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver.Ayase Rie - Yuima-ru*World TVver. ", Buttons = new PopupDialogButton[] { new PopupDialogOkButton @@ -116,6 +120,36 @@ public void TestTooMuchText() })); } + [Test] + public void TestPushWhileOverlayActivationDisabled() + { + PopupDialog dialog = null; + + AddStep("set activation mode disabled", () => overlayActivationMode.Value = OverlayActivation.Disabled); + + AddStep("push dialog", () => + { + overlay.Push(dialog = new TestPopupDialog + { + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton { Text = @"OK" }, + }, + }); + }); + + AddUntilStep("overlay not visible", () => overlay.State.Value, () => Is.EqualTo(Visibility.Hidden)); + + AddStep("set activation mode enabled", () => overlayActivationMode.Value = OverlayActivation.All); + + AddUntilStep("overlay visible", () => overlay.State.Value, () => Is.EqualTo(Visibility.Visible)); + AddUntilStep("dialog displayed", () => dialog.State.Value, () => Is.EqualTo(Visibility.Visible)); + AddStep("set activation mode disabled", () => overlayActivationMode.Value = OverlayActivation.Disabled); + + AddUntilStep("dialog hidden", () => dialog.State.Value, () => Is.EqualTo(Visibility.Hidden)); + AddAssert("dialog dismissed", () => overlay.CurrentDialog, () => Is.Null); + } + [Test] public void TestPushBeforeLoad() { @@ -194,5 +228,17 @@ public void TestDismissBeforePushViaButtonPress() private partial class TestPopupDialog : PopupDialog { } + + public IBindable OverlayActivationMode => overlayActivationMode; + + public IDisposable RegisterBlockingOverlay(OverlayContainer overlayContainer) => throw new NotImplementedException(); + + public void ShowBlockingOverlay(OverlayContainer overlay) + { + } + + public void HideBlockingOverlay(OverlayContainer overlay) + { + } } } diff --git a/osu.Game/Overlays/Dialog/PopupDialog.cs b/osu.Game/Overlays/Dialog/PopupDialog.cs index 0fec1625ebe9..4881c1f1e671 100644 --- a/osu.Game/Overlays/Dialog/PopupDialog.cs +++ b/osu.Game/Overlays/Dialog/PopupDialog.cs @@ -14,7 +14,6 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Framework.Localisation; -using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; using osuTK; @@ -243,7 +242,7 @@ protected PopupDialog() } [BackgroundDependencyLoader] - private void load(AudioManager audio, OsuColour colours) + private void load(AudioManager audio) { flashSample = audio.Samples.Get(@"UI/default-select-disabled"); } diff --git a/osu.Game/Overlays/DialogOverlay.cs b/osu.Game/Overlays/DialogOverlay.cs index 4e7aff84bcb1..a827b498bed0 100644 --- a/osu.Game/Overlays/DialogOverlay.cs +++ b/osu.Game/Overlays/DialogOverlay.cs @@ -13,6 +13,7 @@ using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Input.Events; +using osu.Framework.Logging; namespace osu.Game.Overlays { @@ -28,8 +29,14 @@ public partial class DialogOverlay : OsuFocusedOverlayContainer, IDialogOverlay public PopupDialog CurrentDialog { get; private set; } - public override bool IsPresent => Scheduler.HasPendingTasks - || dialogContainer.Children.Count > 0; + public override bool IsPresent => (Scheduler.HasPendingTasks || dialogContainer.Children.Count > 0) + // The following line ensures that dialogs are not presented while the dialog overlay + // cannot be displayed. This is due to the `Schedule` usage inside `Push()`. + // + // Without this, a dialog pushed during disabled overlay activation mode would be presented, + // but immediately dismissed without ever being seen by the user (see + // https://github.com/ppy/osu/blob/ce5e54c9d27b17d460d99e774de502f9480fb710/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs#L131-L136). + && OverlayActivationMode.Value == OverlayActivation.All; [CanBeNull] private IDisposable duckOperation; @@ -77,6 +84,7 @@ public void Push(PopupDialog dialog) return; } + Logger.Log($"{nameof(DialogOverlay)}: Showing dialog {dialog}"); dialogContainer.Add(dialog); Show(); @@ -98,6 +106,7 @@ void dismiss() // Handle the case where the dialog is the currently displayed dialog. // In this scenario, the overlay itself should also be hidden. Hide(); + Logger.Log($"{nameof(DialogOverlay)}: Dismissing dialog {dialog}"); CurrentDialog = null; } } From 160bc26fb13e811e31d9c15a949c9435e9ea5978 Mon Sep 17 00:00:00 2001 From: pacowoc <107379868+pacowoc@users.noreply.github.com> Date: Thu, 21 May 2026 15:19:37 +0800 Subject: [PATCH 2/5] Remove the ability to "Invite to room" and "Duel" in Ranked Rooms (#37795) Closes https://github.com/ppy/osu/issues/37723 --------- Co-authored-by: Dan Balasescu --- .../Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs | 3 +++ osu.Game/Users/UserPanel.cs | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs index aef8ee51d544..ba30f08f304c 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Queue/QueueController.cs @@ -89,6 +89,9 @@ public void LeaveQueue() public void IssueDuel(MatchmakingPool pool, int userId) { + if (client.Room?.Settings.MatchType.IsMatchmakingType() == true) + return; + lastDuelUser = userId; lastDuelPool = pool; diff --git a/osu.Game/Users/UserPanel.cs b/osu.Game/Users/UserPanel.cs index d711178798dc..de0923cd66e3 100644 --- a/osu.Game/Users/UserPanel.cs +++ b/osu.Game/Users/UserPanel.cs @@ -30,6 +30,7 @@ using osu.Game.Screens.Play; using osu.Game.Users.Drawables; using osuTK; +using osu.Game.Online.Rooms; namespace osu.Game.Users { @@ -209,9 +210,9 @@ public MenuItem[] ContextMenuItems return items.ToArray(); bool isUserOnline() => metadataClient?.GetPresence(User.OnlineID) != null; - bool canInviteUser() => isUserOnline() && multiplayerClient?.Room?.Users.All(u => u.UserID != User.Id) == true; + bool canInviteUser() => isUserOnline() && multiplayerClient?.Room?.Users.All(u => u.UserID != User.Id) == true && multiplayerClient?.Room?.Settings.MatchType.IsMatchmakingType() != true; bool isUserBlocked() => api.LocalUserState.Blocks.Any(b => b.TargetID == User.OnlineID); - bool canDuelUser() => isUserOnline() && queueController?.SelectedPool.Value != null; + bool canDuelUser() => isUserOnline() && queueController?.SelectedPool.Value != null && multiplayerClient?.Room?.Settings.MatchType.IsMatchmakingType() != true; } } From f6cd5f87ee233657c058c506c030a82eb1d01646 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 21 May 2026 17:41:11 +0900 Subject: [PATCH 3/5] Move configuration migrations to `OsuGame` (#37839) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As we go forward, migrations are going to likely become more complex, requiring access to more components and also at a point in time where they are ready. In the upcoming case, `DialogOverlay` and `Audio` are important. Access to `Audio` is a killer as migrations were run before the `GameHost` has a chance to initialise it. --------- Co-authored-by: Bartłomiej Dach --- .../NonVisual/CustomDataDirectoryTest.cs | 24 ++++----- .../DevelopmentOsuConfigManager.cs | 4 +- osu.Game/Configuration/OsuConfigManager.cs | 46 +---------------- osu.Game/OsuGame.cs | 51 +++++++++++++++++++ osu.Game/OsuGameBase.cs | 6 +-- .../Maintenance/MigrationRunScreen.cs | 2 +- osu.Game/Updater/UpdateManager.cs | 4 -- 7 files changed, 70 insertions(+), 67 deletions(-) diff --git a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs index f556a2a1cde6..96f211811542 100644 --- a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs +++ b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs @@ -126,7 +126,7 @@ public void TestMigration() Assert.That(storage.GetFullPath("."), Is.EqualTo(defaultStorageLocation)); - osu.Migrate(customPath); + osu.MigrateUserData(customPath); Assert.That(storage.GetFullPath("."), Is.EqualTo(customPath)); @@ -183,16 +183,16 @@ public void TestMigrationBetweenTwoTargets() { var osu = LoadOsuIntoHost(host); - Assert.DoesNotThrow(() => osu.Migrate(customPath)); + Assert.DoesNotThrow(() => osu.MigrateUserData(customPath)); Assert.That(File.Exists(Path.Combine(customPath, OsuGameBase.CLIENT_DATABASE_FILENAME))); - Assert.DoesNotThrow(() => osu.Migrate(customPath2)); + Assert.DoesNotThrow(() => osu.MigrateUserData(customPath2)); Assert.That(File.Exists(Path.Combine(customPath2, OsuGameBase.CLIENT_DATABASE_FILENAME))); // some files may have been left behind for whatever reason, but that's not what we're testing here. cleanupPath(customPath); - Assert.DoesNotThrow(() => osu.Migrate(customPath)); + Assert.DoesNotThrow(() => osu.MigrateUserData(customPath)); Assert.That(File.Exists(Path.Combine(customPath, OsuGameBase.CLIENT_DATABASE_FILENAME))); } finally @@ -212,8 +212,8 @@ public void TestMigrationToSameTargetFails() { var osu = LoadOsuIntoHost(host); - Assert.DoesNotThrow(() => osu.Migrate(customPath)); - Assert.Throws(() => osu.Migrate(customPath)); + Assert.DoesNotThrow(() => osu.MigrateUserData(customPath)); + Assert.Throws(() => osu.MigrateUserData(customPath)); } finally { @@ -238,14 +238,14 @@ public void TestMigrationFailsOnExistingData() string originalDirectory = storage.GetFullPath("."); - Assert.DoesNotThrow(() => osu.Migrate(customPath)); + Assert.DoesNotThrow(() => osu.MigrateUserData(customPath)); Assert.That(File.Exists(Path.Combine(customPath, OsuGameBase.CLIENT_DATABASE_FILENAME))); Directory.CreateDirectory(customPath2); File.WriteAllText(Path.Combine(customPath2, OsuGameBase.CLIENT_DATABASE_FILENAME), "I am a text"); // Fails because file already exists. - Assert.Throws(() => osu.Migrate(customPath2)); + Assert.Throws(() => osu.MigrateUserData(customPath2)); osuStorage?.ChangeDataPath(customPath2); @@ -269,7 +269,7 @@ public void TestMigrationToNestedTargetFails() { var osu = LoadOsuIntoHost(host); - Assert.DoesNotThrow(() => osu.Migrate(customPath)); + Assert.DoesNotThrow(() => osu.MigrateUserData(customPath)); string subFolder = Path.Combine(customPath, "sub"); @@ -278,7 +278,7 @@ public void TestMigrationToNestedTargetFails() Directory.CreateDirectory(subFolder); - Assert.Throws(() => osu.Migrate(subFolder)); + Assert.Throws(() => osu.MigrateUserData(subFolder)); } finally { @@ -297,7 +297,7 @@ public void TestMigrationToSeeminglyNestedTarget() { var osu = LoadOsuIntoHost(host); - Assert.DoesNotThrow(() => osu.Migrate(customPath)); + Assert.DoesNotThrow(() => osu.MigrateUserData(customPath)); string seeminglySubFolder = customPath + "sub"; @@ -306,7 +306,7 @@ public void TestMigrationToSeeminglyNestedTarget() Directory.CreateDirectory(seeminglySubFolder); - osu.Migrate(seeminglySubFolder); + osu.MigrateUserData(seeminglySubFolder); } finally { diff --git a/osu.Game/Configuration/DevelopmentOsuConfigManager.cs b/osu.Game/Configuration/DevelopmentOsuConfigManager.cs index 0d8fe90423d8..c979ebf453ee 100644 --- a/osu.Game/Configuration/DevelopmentOsuConfigManager.cs +++ b/osu.Game/Configuration/DevelopmentOsuConfigManager.cs @@ -9,8 +9,8 @@ public class DevelopmentOsuConfigManager : OsuConfigManager { protected override string Filename => base.Filename.Replace(".ini", ".dev.ini"); - public DevelopmentOsuConfigManager(Storage storage, GameHost? host = null) - : base(storage, host) + public DevelopmentOsuConfigManager(Storage storage) + : base(storage) { } } diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index b48421cafdb9..f5fdc8223f99 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -2,15 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; using osu.Framework; using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Configuration.Tracking; using osu.Framework.Extensions; using osu.Framework.Extensions.LocalisationExtensions; -using osu.Framework.Input.Handlers.Mouse; -using osu.Framework.Input.Handlers.Pen; using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Game.Beatmaps.Drawables.Cards; @@ -33,14 +30,9 @@ namespace osu.Game.Configuration { public class OsuConfigManager : IniConfigManager, IGameplaySettings { - private readonly GameHost? host; - - public OsuConfigManager(Storage storage, GameHost? host = null) + public OsuConfigManager(Storage storage) : base(storage) { - this.host = host; - - Migrate(); } protected override void InitialiseDefaults() @@ -258,42 +250,6 @@ protected override bool CheckLookupContainsPrivateInformation(OsuSetting lookup) return false; } - public void Migrate() - { - // arrives as 2020.123.0-lazer - string rawVersion = Get(OsuSetting.Version); - - if (rawVersion.Length < 6) - return; - - string[] pieces = rawVersion.Split('.'); - - // on a fresh install or when coming from a non-release build, execution will end here. - // we don't want to run migrations in such cases. - if (!int.TryParse(pieces[0], out int year)) return; - if (!int.TryParse(pieces[1], out int monthDay)) return; - - int combined = year * 10000 + monthDay; - - if (combined < 20250214) - { - // UI scaling on mobile platforms has been internally adjusted such that 1x UI scale looks correctly zoomed in than before. - if (RuntimeInfo.IsMobile) - GetBindable(OsuSetting.UIScale).SetDefault(); - } - - if (combined < 20250428) - { - // Pen tablet sensitivity is now separated from cursor sensitivity. - // Most users will want the default to be what they already had set on cursor sensitivity so let's transfer it. - var mouseHandler = host?.AvailableInputHandlers.OfType().SingleOrDefault(); - var penHandler = host?.AvailableInputHandlers.OfType().SingleOrDefault(); - - if (penHandler != null && mouseHandler != null && penHandler.Sensitivity.IsDefault) - penHandler.Sensitivity.Value = mouseHandler.Sensitivity.Value; - } - } - public override TrackedSettings CreateTrackedSettings() { return new TrackedSettings diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index caf2a6279a8e..b71baaeadad2 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -26,6 +26,8 @@ using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Input.Handlers.Mouse; +using osu.Framework.Input.Handlers.Pen; using osu.Framework.Input.Handlers.Tablet; using osu.Framework.Localisation; using osu.Framework.Logging; @@ -1293,6 +1295,55 @@ protected override void LoadComplete() // Importantly, this should be run after binding PostNotification to the import handlers so they can present the import after game startup. handleStartupImport(); + + applyConfigMigrations(); + + // finally, update the version stored to the configuration. + // this MUST happen after `applyConfigMigrations()` call, as it relies on comparing the previous version. + // debug / local compilations will reset to a non-release string. + LocalConfig.SetValue(OsuSetting.Version, Version); + } + + /// + /// Apply any migrations to configuration. + /// + /// + /// For database migrations, see . + /// + private void applyConfigMigrations() + { + // arrives as 2020.123.0-lazer + string rawVersion = LocalConfig.Get(OsuSetting.Version); + + if (rawVersion.Length < 6) + return; + + string[] pieces = rawVersion.Split('.'); + + // on a fresh install or when coming from a non-release build, execution will end here. + // we don't want to run migrations in such cases. + if (!int.TryParse(pieces[0], out int year)) return; + if (!int.TryParse(pieces[1], out int monthDay)) return; + + int combined = year * 10000 + monthDay; + + if (combined < 20250214) + { + // UI scaling on mobile platforms has been internally adjusted such that 1x UI scale looks correctly zoomed in than before. + if (RuntimeInfo.IsMobile) + LocalConfig.GetBindable(OsuSetting.UIScale).SetDefault(); + } + + if (combined < 20260520) + { + // Pen tablet sensitivity is now separated from cursor sensitivity. + // Most users will want the default to be what they already had set on cursor sensitivity so let's transfer it. + var mouseHandler = Host?.AvailableInputHandlers.OfType().SingleOrDefault(); + var penHandler = Host?.AvailableInputHandlers.OfType().SingleOrDefault(); + + if (penHandler != null && mouseHandler != null && penHandler.Sensitivity.IsDefault) + penHandler.Sensitivity.Value = mouseHandler.Sensitivity.Value; + } } private void handleBackButton() diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 703444a92f7b..cc4fa5b618ca 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -541,8 +541,8 @@ public override void SetHost(GameHost host) Storage ??= host.Storage; LocalConfig ??= UseDevelopmentServer - ? new DevelopmentOsuConfigManager(Storage, host) - : new OsuConfigManager(Storage, host); + ? new DevelopmentOsuConfigManager(Storage) + : new OsuConfigManager(Storage); host.ExceptionThrown += onExceptionThrown; } @@ -590,7 +590,7 @@ public void CancelRestartOnExit() /// The path to migrate to. /// Whether migration succeeded to completion. If false, some files were left behind. /// - public bool Migrate(string path) + public bool MigrateUserData(string path) { Logger.Log($@"Migrating osu! data from ""{Storage.GetFullPath(string.Empty)}"" to ""{path}""..."); diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs index ce33039d688f..8c6ee64d57d2 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs @@ -102,7 +102,7 @@ protected override void LoadComplete() }); } - protected virtual bool PerformMigration() => game?.Migrate(destination.FullName) != false; + protected virtual bool PerformMigration() => game?.MigrateUserData(destination.FullName) != false; public override void OnEntering(ScreenTransitionEvent e) { diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index c74adc7ee238..1db509bd5278 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.cs @@ -80,10 +80,6 @@ protected override void LoadComplete() Logger.Log(NotificationsStrings.NotOfficialBuild.ToString()); } - // debug / local compilations will reset to a non-release string. - // can be useful to check when an install has transitioned between release and otherwise (see OsuConfigManager's migrations). - config.SetValue(OsuSetting.Version, version); - config.BindWith(OsuSetting.ReleaseStream, releaseStream); releaseStream.BindValueChanged(_ => CheckForUpdate()); From 4671d1fe7424e82a784c447bd700ec5ca420349c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 21 May 2026 13:01:34 +0200 Subject: [PATCH 4/5] Add client-side support for slots in multiplayer rooms (#37741) - Part of https://github.com/ppy/osu-server-spectator/issues/405 Screenshot 2026-05-13 at 12 40 07 Screenshot 2026-05-13 at 12 31 40 Will not work until relevant server-side support is in. --- I was in two minds whether to PR this all at once or to PR only https://github.com/ppy/osu/commit/693e4ef4b095042f7a171672224e10f9c4f47c1c to begin with to unblock server-side implementation. In the end I opted for one PR because usage informs the model, so I find everything else relevant as part of review of the model design. If there are concerns about this making it into a release without server-side support and therefore things looking broken I will split the commit out on request. I put in some effort to add relevant logic in test multiplayer client to simulate the server side but I may well have missed something. --------- Co-authored-by: Dean Herbert --- .../Multiplayer/TestSceneMultiplayer.cs | 21 ++++ .../TestSceneMultiplayerParticipantsList.cs | 81 ++++++++++++-- .../Visual/Multiplayer/TestSceneRoomPanel.cs | 17 +++ .../Online/Multiplayer/ChangeSlotRequest.cs | 20 ++++ osu.Game/Online/Multiplayer/MatchRoomState.cs | 1 + .../TeamVersus/TeamVersusRoomState.cs | 10 +- .../Online/Multiplayer/MatchUserRequest.cs | 1 + .../Online/Multiplayer/MultiplayerClient.cs | 6 +- .../Multiplayer/MultiplayerRoomSettings.cs | 10 +- .../Online/Multiplayer/SetLockStateRequest.cs | 5 +- .../Multiplayer/StandardMatchRoomState.cs | 39 +++++++ osu.Game/Online/Rooms/Room.cs | 7 +- osu.Game/Online/SignalRWorkaroundTypes.cs | 2 + .../DrawableRoomParticipantsList.cs | 3 +- .../Match/MultiplayerMatchSettingsOverlay.cs | 50 +++++++-- .../Participants/ParticipantPanel.cs | 97 +++++++++++----- .../Participants/ParticipantsList.cs | 104 +++++++++++++----- .../Participants/ParticipantsListHeader.cs | 2 +- .../Multiplayer/Participants/StateDisplay.cs | 10 +- .../Multiplayer/Participants/TeamDisplay.cs | 12 +- .../Playlists/PlaylistsRoomSettingsOverlay.cs | 2 +- .../Multiplayer/TestMultiplayerClient.cs | 35 +++++- 22 files changed, 431 insertions(+), 104 deletions(-) create mode 100644 osu.Game/Online/Multiplayer/ChangeSlotRequest.cs create mode 100644 osu.Game/Online/Multiplayer/StandardMatchRoomState.cs diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index 397cd0fd3814..aaf6b024d17a 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -1206,6 +1206,27 @@ public void TestUserStyleSelectionExitedWhenBeatmapSetChanged() AddUntilStep("style selection screen closed", () => this.ChildrenOfType().SingleOrDefault()?.IsCurrentScreen() != true); } + [Test] + public void TestMaxParticipantsAndSlots() + { + createRoom(() => new Room + { + Name = "Test Room", + Password = "password", + Playlist = + [ + new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + } + ], + MaxParticipants = 10 + }); + + AddStep("turn max participants off", () => multiplayerClient.ChangeSettings(maxParticipants: null)); + AddStep("turn max participants back on", () => multiplayerClient.ChangeSettings(maxParticipants: 8)); + } + private void enterGameplay() { pressReadyButton(); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs index 8aaf85923c90..d81343762937 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading; using NUnit.Framework; +using osu.Framework.Extensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; @@ -55,6 +56,68 @@ public void TestAddUser() AddAssert("two unique panels", () => this.ChildrenOfType().Select(p => p.Current.Value).Distinct().Count() == 2); } + [Test] + public void TestSlots() + { + setUpList(); + AddAssert("one unique panel", () => this.ChildrenOfType().Select(p => p.Current.Value).Distinct().Count() == 1); + + AddStep("add user", () => MultiplayerClient.AddUser(new APIUser + { + Id = 3, + Username = "Second", + CoverUrl = TestResources.COVER_IMAGE_3, + })); + + AddAssert("two unique panels", () => this.ChildrenOfType().Select(p => p.Current.Value).Distinct().Count() == 2); + + AddStep("introduce slots", () => MultiplayerClient.ChangeMatchRoomState(new StandardMatchRoomState + { + Slots = [null, 3, null, null, 1001, null, null] + }).WaitSafely()); + + AddStep("click first slot", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("slots changed", () => ((StandardMatchRoomState)MultiplayerClient.ClientRoom!.MatchState!).Slots, + () => Is.EquivalentTo(new int?[] { 1001, 3, null, null, null, null, null })); + + AddStep("click second slot", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().ElementAt(1)); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("slots not changed", () => ((StandardMatchRoomState)MultiplayerClient.ClientRoom!.MatchState!).Slots, + () => Is.EquivalentTo(new int?[] { 1001, 3, null, null, null, null, null })); + + AddStep("click last slot", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Last()); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("slots changed", () => ((StandardMatchRoomState)MultiplayerClient.ClientRoom!.MatchState!).Slots, + () => Is.EquivalentTo(new int?[] { null, 3, null, null, null, null, 1001 })); + + AddStep("shuffle slots", () => MultiplayerClient.ChangeMatchRoomState(new StandardMatchRoomState + { + Slots = [null, null, 1001, null, null, null, 3] + }).WaitSafely()); + AddStep("remove slots", () => MultiplayerClient.ChangeMatchRoomState(new StandardMatchRoomState + { + Slots = [null, 3, null, 1001] + }).WaitSafely()); + AddStep("add slots", () => MultiplayerClient.ChangeMatchRoomState(new StandardMatchRoomState + { + Slots = [null, null, 3, null, 1001, null] + }).WaitSafely()); + AddStep("turn off slots", () => MultiplayerClient.ChangeMatchRoomState(new StandardMatchRoomState + { + Slots = null + }).WaitSafely()); + } + [Test] public void TestAddReferee() { @@ -86,7 +149,7 @@ public void TestAddUnresolvedUser() AddUntilStep("two unique panels", () => this.ChildrenOfType().Select(p => p.Current.Value).Distinct().Count() == 2); - AddStep("kick null user", () => this.ChildrenOfType().Single(p => p.Current.Value.User == null) + AddStep("kick null user", () => this.ChildrenOfType().Single(p => p.Current.Value.User?.User == null) .ChildrenOfType().Single().TriggerClick()); AddUntilStep("null user kicked", () => MultiplayerClient.ClientRoom.AsNonNull().Users.Count == 1); @@ -111,7 +174,7 @@ public void TestRemoveUser() AddStep("remove host", () => MultiplayerClient.RemoveUser(API.LocalUser.Value)); - AddAssert("single panel is for second user", () => this.ChildrenOfType().Single().Current.Value.UserID == secondUser?.Id); + AddAssert("single panel is for second user", () => this.ChildrenOfType().Single().Current.Value.User?.UserID == secondUser?.Id); } [Test] @@ -150,7 +213,7 @@ public void TestBeatmapDownloadingStates() AddRepeatStep("increment progress", () => { - float progress = this.ChildrenOfType().Single().Current.Value.BeatmapAvailability.DownloadProgress ?? 0; + float progress = this.ChildrenOfType().Single().Current.Value.User?.BeatmapAvailability.DownloadProgress ?? 0; MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(progress + RNG.NextSingle(0.1f))); }, 25); @@ -195,16 +258,16 @@ public void TestCrownChangesStateWhenHostTransferred() })); AddUntilStep("first user crown visible", - () => this.ChildrenOfType().Single(p => p.Current.Value.UserID == 1001).ChildrenOfType().First().Alpha == 1); + () => this.ChildrenOfType().Single(p => p.Current.Value.User?.UserID == 1001).ChildrenOfType().First().Alpha == 1); AddUntilStep("second user crown hidden", - () => this.ChildrenOfType().Single(p => p.Current.Value.UserID == 3).ChildrenOfType().First().Alpha == 0); + () => this.ChildrenOfType().Single(p => p.Current.Value.User?.UserID == 3).ChildrenOfType().First().Alpha == 0); AddStep("make second user host", () => MultiplayerClient.TransferHost(3)); AddUntilStep("first user crown visible", - () => this.ChildrenOfType().Single(p => p.Current.Value.UserID == 1001).ChildrenOfType().First().Alpha == 0); + () => this.ChildrenOfType().Single(p => p.Current.Value.User?.UserID == 1001).ChildrenOfType().First().Alpha == 0); AddUntilStep("second user crown hidden", - () => this.ChildrenOfType().Single(p => p.Current.Value.UserID == 3).ChildrenOfType().First().Alpha == 1); + () => this.ChildrenOfType().Single(p => p.Current.Value.User?.UserID == 3).ChildrenOfType().First().Alpha == 1); } [Test] @@ -221,8 +284,8 @@ public void TestHostGetsPinnedToTop() AddStep("make second user host", () => MultiplayerClient.TransferHost(3)); AddAssert("second user above first", () => { - var first = this.ChildrenOfType().Single(u => u.Current.Value.UserID == 1001); - var second = this.ChildrenOfType().Single(u => u.Current.Value.UserID == 3); + var first = this.ChildrenOfType().Single(u => u.Current.Value.User?.UserID == 1001); + var second = this.ChildrenOfType().Single(u => u.Current.Value.User?.UserID == 3); return second.ScreenSpaceDrawQuad.TopLeft.Y < first.ScreenSpaceDrawQuad.TopLeft.Y; }); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomPanel.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomPanel.cs index aa9dddae4dd8..0385ecb56626 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomPanel.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomPanel.cs @@ -178,6 +178,23 @@ public void TestEnableAndDisablePassword() AddAssert("password icon hidden", () => Precision.AlmostEquals(0, panel.ChildrenOfType().First().Alpha)); } + [Test] + public void TestSetAndUnsetMaxParticipants() + { + RoomPanel panel = null!; + Room room = null!; + + AddStep("create room", () => Child = panel = createLoungeRoom(room = new Room + { + Name = "A room", + Type = MatchType.HeadToHead, + })); + + AddUntilStep("wait for panel load", () => panel.ChildrenOfType().Any()); + AddStep("set max participants", () => room.MaxParticipants = 5); + AddStep("unset max participants", () => room.MaxParticipants = null); + } + [Test] public void TestMultiplayerRooms() { diff --git a/osu.Game/Online/Multiplayer/ChangeSlotRequest.cs b/osu.Game/Online/Multiplayer/ChangeSlotRequest.cs new file mode 100644 index 000000000000..04884081968f --- /dev/null +++ b/osu.Game/Online/Multiplayer/ChangeSlotRequest.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using MessagePack; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// User requests to change their slot in the room. + /// + [MessagePackObject] + public class ChangeSlotRequest : MatchUserRequest + { + /// + /// The zero-based ID of the desired slot. + /// + [Key(0)] + public byte SlotID { get; set; } + } +} diff --git a/osu.Game/Online/Multiplayer/MatchRoomState.cs b/osu.Game/Online/Multiplayer/MatchRoomState.cs index 531395980674..a2b700da7e87 100644 --- a/osu.Game/Online/Multiplayer/MatchRoomState.cs +++ b/osu.Game/Online/Multiplayer/MatchRoomState.cs @@ -18,6 +18,7 @@ namespace osu.Game.Online.Multiplayer [Union(0, typeof(TeamVersusRoomState))] // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types. [Union(1, typeof(MatchmakingRoomState))] [Union(2, typeof(RankedPlayRoomState))] + [Union(3, typeof(StandardMatchRoomState))] public abstract class MatchRoomState { } diff --git a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusRoomState.cs b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusRoomState.cs index d5e30bb2e08b..9d9512622109 100644 --- a/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusRoomState.cs +++ b/osu.Game/Online/Multiplayer/MatchTypes/TeamVersus/TeamVersusRoomState.cs @@ -7,22 +7,20 @@ namespace osu.Game.Online.Multiplayer.MatchTypes.TeamVersus { [MessagePackObject] - public class TeamVersusRoomState : MatchRoomState + public class TeamVersusRoomState : StandardMatchRoomState { [Key(0)] public List Teams { get; set; } = new List(); - [Key(1)] - public bool Locked { get; set; } - - public static TeamVersusRoomState CreateDefault() => + public static TeamVersusRoomState CreateDefault(byte? maxParticipants = null) => new TeamVersusRoomState { Teams = { new MultiplayerTeam { ID = 0, Name = "Team Red" }, new MultiplayerTeam { ID = 1, Name = "Team Blue" }, - } + }, + Slots = maxParticipants == null ? null : new int?[maxParticipants.Value] }; } } diff --git a/osu.Game/Online/Multiplayer/MatchUserRequest.cs b/osu.Game/Online/Multiplayer/MatchUserRequest.cs index bacc1a7632b4..270c437519a8 100644 --- a/osu.Game/Online/Multiplayer/MatchUserRequest.cs +++ b/osu.Game/Online/Multiplayer/MatchUserRequest.cs @@ -23,6 +23,7 @@ namespace osu.Game.Online.Multiplayer [Union(4, typeof(RankedPlayCardHandReplayRequest))] [Union(5, typeof(SetLockStateRequest))] [Union(6, typeof(RollRequest))] + [Union(7, typeof(ChangeSlotRequest))] public abstract class MatchUserRequest { } diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 341739dbed8d..850811feefe9 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -403,8 +403,9 @@ await runOnUpdateThreadAsync(() => /// The new queue mode, if any. /// The new auto-start countdown duration, if any. /// The new auto-skip setting. + /// The new participant count limit, if any. public Task ChangeSettings(Optional name = default, Optional password = default, Optional matchType = default, Optional queueMode = default, - Optional autoStartDuration = default, Optional autoSkip = default) + Optional autoStartDuration = default, Optional autoSkip = default, Optional maxParticipants = default) { if (Room == null) throw new InvalidOperationException("Must be joined to a match to change settings."); @@ -416,7 +417,8 @@ public Task ChangeSettings(Optional name = default, Optional pas MatchType = matchType.GetOr(Room.Settings.MatchType), QueueMode = queueMode.GetOr(Room.Settings.QueueMode), AutoStartDuration = autoStartDuration.GetOr(Room.Settings.AutoStartDuration), - AutoSkip = autoSkip.GetOr(Room.Settings.AutoSkip) + AutoSkip = autoSkip.GetOr(Room.Settings.AutoSkip), + MaxParticipants = maxParticipants.GetOr(Room.Settings.MaxParticipants), }); } diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs index c264ec1eefb6..d78302e080a5 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs @@ -32,6 +32,9 @@ public class MultiplayerRoomSettings : IEquatable [Key(6)] public bool AutoSkip { get; set; } + [Key(7)] + public byte? MaxParticipants { get; set; } + [IgnoreMember] public bool AutoStartEnabled => AutoStartDuration != TimeSpan.Zero; @@ -47,6 +50,7 @@ public MultiplayerRoomSettings(Room room) QueueMode = room.QueueMode; AutoStartDuration = room.AutoStartDuration; AutoSkip = room.AutoSkip; + MaxParticipants = room.MaxParticipants; } public bool Equals(MultiplayerRoomSettings? other) @@ -60,7 +64,8 @@ public bool Equals(MultiplayerRoomSettings? other) && MatchType == other.MatchType && QueueMode == other.QueueMode && AutoStartDuration == other.AutoStartDuration - && AutoSkip == other.AutoSkip; + && AutoSkip == other.AutoSkip + && MaxParticipants == other.MaxParticipants; } public override string ToString() => $"Name:{Name}" @@ -69,6 +74,7 @@ public override string ToString() => $"Name:{Name}" + $" Item:{PlaylistItemId}" + $" Queue:{QueueMode}" + $" Start:{AutoStartDuration}" - + $" AutoSkip:{AutoSkip}"; + + $" AutoSkip:{AutoSkip}" + + $" MaxParticipants:{MaxParticipants?.ToString() ?? "no limit"}"; } } diff --git a/osu.Game/Online/Multiplayer/SetLockStateRequest.cs b/osu.Game/Online/Multiplayer/SetLockStateRequest.cs index 8f1451fdab7a..fd501de2e933 100644 --- a/osu.Game/Online/Multiplayer/SetLockStateRequest.cs +++ b/osu.Game/Online/Multiplayer/SetLockStateRequest.cs @@ -10,14 +10,13 @@ public class SetLockStateRequest : MatchUserRequest { /// /// - /// If , s will not be able to change teams by themselves in the room, + /// If , s will not be able to change teams and slots by themselves in the room, /// only s will be able to change teams for the s. /// /// - /// If , any user can change their team in the room. + /// If , any user can change their team and slot in the room. /// /// - // TODO: mention slots as well when slots are reimplemented [Key(0)] public bool Locked { get; set; } } diff --git a/osu.Game/Online/Multiplayer/StandardMatchRoomState.cs b/osu.Game/Online/Multiplayer/StandardMatchRoomState.cs new file mode 100644 index 000000000000..478c8c120af4 --- /dev/null +++ b/osu.Game/Online/Multiplayer/StandardMatchRoomState.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using MessagePack; + +namespace osu.Game.Online.Multiplayer +{ + [MessagePackObject] + public class StandardMatchRoomState : MatchRoomState + { + /// + /// Whether the room is currently locked. + /// When locked, changes to slots (and teams, in team versus) cannot be performed by anyone but room referees. + /// + [Key(1)] + public bool Locked { get; set; } + + /// + /// The state of slots in the room. + /// Linked to . + /// + /// When is , this property is also . + /// + /// When is not , this property is an array of that length. + /// The items of that array represent either an empty slot (represented by ), + /// or an user occupying that slot (represented by the ID of the relevant user). + /// + /// + /// + [Key(2)] + public int?[]? Slots { get; set; } + + public static StandardMatchRoomState Create(byte? maxParticipants = null) => + new StandardMatchRoomState + { + Slots = maxParticipants == null ? null : new int?[maxParticipants.Value] + }; + } +} diff --git a/osu.Game/Online/Rooms/Room.cs b/osu.Game/Online/Rooms/Room.cs index dda069bba01b..42d09c9ee326 100644 --- a/osu.Game/Online/Rooms/Room.cs +++ b/osu.Game/Online/Rooms/Room.cs @@ -119,7 +119,7 @@ public DateTimeOffset? EndDate /// /// The maximum number of users allowed in the room. /// - public int? MaxParticipants + public byte? MaxParticipants { get => maxParticipants; set => SetField(ref maxParticipants, value); @@ -297,8 +297,8 @@ public bool Pinned [JsonProperty("ends_at")] private DateTimeOffset? endDate; - // Not yet serialised (not implemented). - private int? maxParticipants; + [JsonProperty("max_participants")] + private byte? maxParticipants; [JsonProperty("participant_count")] private int participantCount; @@ -365,6 +365,7 @@ public Room(MultiplayerRoom room) QueueMode = room.Settings.QueueMode; AutoStartDuration = room.Settings.AutoStartDuration; AutoSkip = room.Settings.AutoSkip; + MaxParticipants = room.Settings.MaxParticipants; Host = room.Host != null ? new APIUser { Id = room.Host.UserID } : null; Playlist = room.Playlist.Select(p => new PlaylistItem(p)).ToArray(); } diff --git a/osu.Game/Online/SignalRWorkaroundTypes.cs b/osu.Game/Online/SignalRWorkaroundTypes.cs index 06e8451205b2..02ca9d098dde 100644 --- a/osu.Game/Online/SignalRWorkaroundTypes.cs +++ b/osu.Game/Online/SignalRWorkaroundTypes.cs @@ -24,6 +24,7 @@ internal static class SignalRWorkaroundTypes internal static readonly IReadOnlyList<(Type derivedType, Type baseType)> BASE_TYPE_MAPPING = new[] { // multiplayer + (typeof(ChangeSlotRequest), typeof(MatchUserRequest)), (typeof(ChangeTeamRequest), typeof(MatchUserRequest)), (typeof(StartMatchCountdownRequest), typeof(MatchUserRequest)), (typeof(StopCountdownRequest), typeof(MatchUserRequest)), @@ -32,6 +33,7 @@ internal static class SignalRWorkaroundTypes (typeof(CountdownStartedEvent), typeof(MatchServerEvent)), (typeof(CountdownStoppedEvent), typeof(MatchServerEvent)), (typeof(RollEvent), typeof(MatchServerEvent)), + (typeof(StandardMatchRoomState), typeof(MatchRoomState)), (typeof(TeamVersusRoomState), typeof(MatchRoomState)), (typeof(TeamVersusUserState), typeof(MatchUserState)), (typeof(MatchStartCountdown), typeof(MultiplayerCountdown)), diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs index 135b2b4db20c..d1c537cf904f 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs @@ -262,6 +262,7 @@ private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) break; case nameof(Room.ParticipantCount): + case nameof(Room.MaxParticipants): updateRoomParticipantCount(); break; @@ -286,7 +287,7 @@ private void updateRoomHost() private void updateRoomParticipantCount() { updateHiddenUsers(); - totalCount.Text = room.ParticipantCount.ToString(); + totalCount.Text = room.MaxParticipants == null ? room.ParticipantCount.ToString() : $@"{room.ParticipantCount} / {room.MaxParticipants}"; } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index 2faaec401e8a..8fa3f265c923 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -52,14 +52,13 @@ public MultiplayerMatchSettingsOverlay(Room room) protected partial class MatchSettings : CompositeDrawable { - private const float disabled_alpha = 0.2f; - public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; public Action? SettingsApplied; public OsuTextBox NameField = null!; - public OsuTextBox MaxParticipantsField = null!; + private FormSliderBar maximumParticipantsSliderBar = null!; + private FormCheckBox maximumParticipantsCheckbox = null!; public MatchTypePicker TypePicker = null!; public OsuEnumDropdown QueueModeDropdown = null!; public OsuTextBox PasswordTextBox = null!; @@ -221,14 +220,26 @@ private void load(OverlayColourProvider colourProvider, OsuColour colours) Padding = new MarginPadding { Left = FIELD_PADDING / 2 }, Children = new[] { - new Section("Max participants") + new Section("Player count") { - Alpha = disabled_alpha, - Child = MaxParticipantsField = new OsuNumberBox + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - TabbableContentContainer = this, - ReadOnly = true, + maximumParticipantsCheckbox = new FormCheckBox + { + Caption = "Limited slots", + HintText = "When enabled, total players allowed in a room will be limited. Unlimited when disabled." + }, + maximumParticipantsSliderBar = new FormSliderBar + { + Caption = "Slot count", + RelativeSizeAxes = Axes.X, + Margin = new MarginPadding { Top = 5 }, + Current = new BindableNumber(16) + { + MinValue = 2, + MaxValue = 16, + } + }, }, }, new Section("Password (optional)") @@ -365,6 +376,11 @@ protected override void LoadComplete() updateRoomMaxParticipants(); updateRoomAutoStartDuration(); updateRoomPlaylist(); + + maximumParticipantsCheckbox.Current.BindValueChanged(enabled => + { + maximumParticipantsSliderBar.Alpha = enabled.NewValue ? 1 : 0; + }, true); } private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) @@ -421,7 +437,15 @@ private void updateRoomAutoSkip() => AutoSkipCheckbox.Current.Value = room.AutoSkip; private void updateRoomMaxParticipants() - => MaxParticipantsField.Text = room.MaxParticipants?.ToString(); + { + if (room.MaxParticipants.HasValue) + { + maximumParticipantsCheckbox.Current.Value = true; + maximumParticipantsSliderBar.Current.Value = room.MaxParticipants.Value; + } + else + maximumParticipantsCheckbox.Current.Value = false; + } private void updateRoomAutoStartDuration() => startModeDropdown.Current.Value = (StartMode)room.AutoStartDuration.TotalSeconds; @@ -442,6 +466,8 @@ private void apply() if (!ApplyButton.Enabled.Value) return; + byte? maxParticipants = maximumParticipantsCheckbox.Current.Value ? maximumParticipantsSliderBar.Current.Value : null; + ErrorText.FadeOut(50); Debug.Assert(applyingSettingsOperation == null); @@ -457,7 +483,8 @@ private void apply() matchType: TypePicker.Current.Value, queueMode: QueueModeDropdown.Current.Value, autoStartDuration: TimeSpan.FromSeconds((int)startModeDropdown.Current.Value), - autoSkip: AutoSkipCheckbox.Current.Value) + autoSkip: AutoSkipCheckbox.Current.Value, + maxParticipants: maxParticipants) .ContinueWith(t => Schedule(() => { if (t.IsCompletedSuccessfully) @@ -475,6 +502,7 @@ private void apply() room.AutoStartDuration = TimeSpan.FromSeconds((int)startModeDropdown.Current.Value); room.AutoSkip = AutoSkipCheckbox.Current.Value; room.Playlist = drawablePlaylist.Items.ToArray(); + room.MaxParticipants = maxParticipants; client.CreateRoom(room).ContinueWith(t => Schedule(() => { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs index 352deb375a40..3f7e654025e9 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs @@ -21,6 +21,7 @@ using osu.Game.Beatmaps.Drawables; using osu.Game.Database; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Online; @@ -37,17 +38,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { - public partial class ParticipantPanel : PoolableDrawable, IHasContextMenu, IHasCurrentValue + public partial class ParticipantPanel : PoolableDrawable, IHasContextMenu, IHasCurrentValue { public const int HEIGHT = 40; - public Bindable Current + public Bindable Current { get => current.Current; set => current.Current = value; } - private readonly BindableWithCurrent current = new BindableWithCurrent(new MultiplayerRoomUser(-1)); + private readonly BindableWithCurrent current = new BindableWithCurrent(Slot.FromUser(new MultiplayerRoomUser(-1))); [Resolved] private IAPIProvider api { get; set; } = null!; @@ -61,6 +62,7 @@ public Bindable Current private SpriteIcon crown = null!; private UserCoverBackground userCover = null!; + private FillFlowContainer userContent = null!; private UpdateableAvatar userAvatar = null!; private UpdateableFlag userFlag = null!; private OsuSpriteText username = null!; @@ -69,6 +71,7 @@ public Bindable Current private StyleDisplayIcon userStyleDisplay = null!; private ModDisplay userModsDisplay = null!; private StateDisplay userStateDisplay = null!; + private ClickableContainer emptySlotMarker = null!; private IconButton kickButton = null!; @@ -127,7 +130,7 @@ private void load() Width = 0.75f, Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(0), Color4.White.Opacity(0.25f)) }, - new FillFlowContainer + userContent = new FillFlowContainer { RelativeSizeAxes = Axes.Both, Spacing = new Vector2(10), @@ -195,6 +198,18 @@ private void load() Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, Margin = new MarginPadding { Right = 10 }, + }, + emptySlotMarker = new OsuClickableContainer + { + RelativeSizeAxes = Axes.Both, + Child = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Style.Caption1, + Text = "(empty slot)" + }, + Action = moveToSlot, } } }, @@ -204,7 +219,11 @@ private void load() Origin = Anchor.Centre, Alpha = 0, Margin = new MarginPadding(4), - Action = () => client.KickUser(current.Value.UserID).FireAndForget(), + Action = () => + { + if (!current.Value.IsEmpty) + client.KickUser(current.Value.User!.UserID).FireAndForget(); + }, }, }, } @@ -216,7 +235,7 @@ protected override void PrepareForUse() base.PrepareForUse(); client.RoomUpdated += onRoomUpdated; - updateUser(); + Current.BindValueChanged(_ => updateUser(), true); FinishTransforms(true); } @@ -236,18 +255,28 @@ protected override void FreeAfterUse() current.SetDefault(); } + private const double fade_time = 50; + private void updateUser() { - var user = current.Value.User; + userCover.FadeTo(current.Value.IsEmpty ? 0 : 1, fade_time); + userContent.FadeTo(current.Value.IsEmpty ? 0 : 1, fade_time); + emptySlotMarker.Enabled.Value = current.Value.IsEmpty; + emptySlotMarker.FadeTo(current.Value.IsEmpty ? 1 : 0, fade_time); - userCover.User = user; - userAvatar.User = user; - userFlag.CountryCode = user?.CountryCode ?? default; - teamFlagContainer.Child = new UpdateableTeamFlag(user?.Team) + if (!current.Value.IsEmpty) { - Size = new Vector2(40, 20), - }; - username.Text = user?.Username ?? string.Empty; + var user = current.Value.User.User; + + userCover.User = user; + userAvatar.User = user; + userFlag.CountryCode = user?.CountryCode ?? default; + teamFlagContainer.Child = new UpdateableTeamFlag(user?.Team) + { + Size = new Vector2(40, 20), + }; + username.Text = user?.Username ?? string.Empty; + } updateState(); } @@ -259,17 +288,15 @@ private void updateState() if (client.Room == null || client.LocalUser == null) return; - const double fade_time = 50; - - var user = current.Value; + var slot = current.Value; - if (client.Room.GetCurrentItem() is MultiplayerPlaylistItem currentItem) + if (!slot.IsEmpty && client.Room.GetCurrentItem() is MultiplayerPlaylistItem currentItem) { - int userBeatmapId = user.BeatmapId ?? currentItem.BeatmapID; - int userRulesetId = user.RulesetId ?? currentItem.RulesetID; + int userBeatmapId = slot.User.BeatmapId ?? currentItem.BeatmapID; + int userRulesetId = slot.User.RulesetId ?? currentItem.RulesetID; Ruleset? userRuleset = rulesets.GetRuleset(userRulesetId)?.CreateInstance(); - int? currentModeRank = userRuleset == null ? null : user.User?.RulesetsStatistics?.GetValueOrDefault(userRuleset.ShortName)?.GlobalRank; + int? currentModeRank = userRuleset == null ? null : slot.User.User?.RulesetsStatistics?.GetValueOrDefault(userRuleset.ShortName)?.GlobalRank; userRankText.Text = currentModeRank != null ? $"#{currentModeRank.Value:N0}" : string.Empty; if (userBeatmapId == currentItem.BeatmapID && userRulesetId == currentItem.RulesetID) @@ -279,12 +306,12 @@ private void updateState() // If the mods are updated at the end of the frame, the flow container will skip a reflow cycle: https://github.com/ppy/osu-framework/issues/4187 // This looks particularly jarring here, so re-schedule the update to that start of our frame as a fix. - Schedule(() => userModsDisplay.Current.Value = userRuleset == null ? Array.Empty() : user.Mods.Select(m => m.ToMod(userRuleset)).ToList()); + Schedule(() => userModsDisplay.Current.Value = userRuleset == null ? Array.Empty() : slot.User.Mods.Select(m => m.ToMod(userRuleset)).ToList()); } - userStateDisplay.UpdateStatus(user); + userStateDisplay.UpdateStatus(current.Value); - if (user.BeatmapAvailability.State == DownloadState.LocallyAvailable && user.State != MultiplayerUserState.Spectating) + if (!slot.IsEmpty && slot.User.BeatmapAvailability.State == DownloadState.LocallyAvailable && slot.User.State != MultiplayerUserState.Spectating) { userModsDisplay.FadeIn(fade_time); userStyleDisplay.FadeIn(fade_time); @@ -295,8 +322,8 @@ private void updateState() userStyleDisplay.FadeOut(fade_time); } - kickButton.Alpha = (client.IsHost || client.IsReferee) && !user.Equals(client.LocalUser) ? 1 : 0; - crown.Alpha = client.Room.Host?.Equals(user) == true ? 1 : 0; + kickButton.Alpha = (client.IsHost || client.IsReferee) && !slot.IsEmpty && !slot.User.Equals(client.LocalUser) ? 1 : 0; + crown.Alpha = !slot.IsEmpty && client.Room.Host?.Equals(slot.User) == true ? 1 : 0; } public MenuItem[]? ContextMenuItems @@ -306,7 +333,15 @@ public MenuItem[]? ContextMenuItems if (client.Room == null) return null; - var user = current.Value; + if (current.Value.IsEmpty) + { + return new MenuItem[] + { + new OsuMenuItem("Move to slot", MenuItemType.Highlighted, moveToSlot) + }; + } + + var user = current.Value.User; // If the local user is targetted. if (user.UserID == api.LocalUser.Value.Id) @@ -339,6 +374,14 @@ public MenuItem[]? ContextMenuItems } } + private void moveToSlot() + { + if (!current.Value.IsEmpty) + return; + + client.SendMatchRequest(new ChangeSlotRequest { SlotID = current.Value.SlotId.Value }).FireAndForget(); + } + public partial class KickButton : IconButton { public KickButton() diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs index 7429fc817ca3..cbd56ab5c23b 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -12,11 +14,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { - public partial class ParticipantsList : VirtualisedListContainer + public partial class ParticipantsList : VirtualisedListContainer { - private BindableList participants => RowData; + private BindableList slots => RowData; - private MultiplayerRoomUser? currentHost; + private Slot? currentHost; [Resolved] private MultiplayerClient client { get; set; } = null!; @@ -44,38 +46,66 @@ protected override void LoadComplete() private void updateState() { if (client.Room == null) - participants.Clear(); - else { - // Remove panels for users no longer in the room. - for (int i = participants.Count - 1; i >= 0; i--) + slots.Clear(); + return; + } + + // pathway for handling rooms with participant count limit and slots + if (client.Room.MatchState is StandardMatchRoomState standardMatchRoomState && standardMatchRoomState.Slots is int?[] slotUserIds) + { + // reset host tracking - in slots mode the host's position is decided solely by their slot + // the reset has the side benefit of getting the host pinned to top of list again if slots are turned off (see logic lower down). + currentHost = null; + + if (slots.Count > slotUserIds.Length) + slots.RemoveRange(slotUserIds.Length, slots.Count - slotUserIds.Length); + + for (byte i = 0; i < slotUserIds.Length; ++i) { - var participant = participants[i]; + var participant = slotUserIds[i] == null ? Slot.Empty(i) : Slot.FromUser(client.Room.Users.Single(u => u.UserID == slotUserIds[i])); - // Note that we *must* use reference equality here, as this call is scheduled and a user may have left and joined since it was last run. - if (client.Room.Users.All(u => !ReferenceEquals(participant, u))) - participants.RemoveAt(i); + if (i >= slots.Count) + slots.Add(participant); + if (!participant.Equals(slots[i])) + slots[i] = participant; } - // Add panels for all users new to the room. - foreach (var user in client.Room.Users.Except(participants)) - participants.Add(user); + return; + } + + // Remove panels for empty slots & users no longer in the room. + for (int i = slots.Count - 1; i >= 0; i--) + { + var slot = slots[i]; + + // Note that we *must* use reference equality here, as this call is scheduled and a user may have left and joined since it was last run. + if (slot.IsEmpty || client.Room.Users.All(u => !ReferenceEquals(slot.User, u))) + slots.RemoveAt(i); + } + + // This assertion guarantees that all subsequent accesses to `User` of any `Slot` is safe. + // Unfortunately static analysis is not smart enough to pick this up, so there'll be a lot of `.AsNonNull()` lower down. + Debug.Assert(slots.All(p => !p.IsEmpty)); + + // Add panels for all users new to the room. + foreach (var user in client.Room.Users.Except(slots.Select(u => u.User.AsNonNull()))) + slots.Add(Slot.FromUser(user)); - if (currentHost == null || !currentHost.Equals(client.Room.Host)) + if (currentHost == null || !currentHost.User.AsNonNull().Equals(client.Room.Host)) + { + currentHost = null; + + // Change position of new host to display above all participants. + if (client.Room.Host != null) { - currentHost = null; + currentHost = slots.SingleOrDefault(u => u.User.AsNonNull().Equals(client.Room.Host)); + int currentHostIndex = currentHost == null ? -1 : slots.IndexOf(currentHost); - // Change position of new host to display above all participants. - if (client.Room.Host != null) + if (currentHostIndex > 0) { - currentHost = participants.SingleOrDefault(u => u.Equals(client.Room.Host)); - int currentHostIndex = participants.IndexOf(client.Room.Host); - - if (currentHostIndex > 0) - { - participants.Move(currentHostIndex, 0); - currentHost = participants[0]; - } + slots.Move(currentHostIndex, 0); + currentHost = slots[0]; } } } @@ -89,4 +119,26 @@ protected override void Dispose(bool isDisposing) client.RoomUpdated -= onRoomUpdated; } } + + public record Slot + { + [MemberNotNullWhen(false, nameof(User))] + [MemberNotNullWhen(true, nameof(SlotId))] + public bool IsEmpty { get; } + + public MultiplayerRoomUser? User { get; } + + public byte? SlotId { get; } + + private Slot(bool isEmpty, MultiplayerRoomUser? user, byte? slotId) + { + IsEmpty = isEmpty; + User = user; + SlotId = slotId; + } + + public static Slot FromUser(MultiplayerRoomUser user) => new Slot(false, user, null); + + public static Slot Empty(byte slotId) => new Slot(true, null, slotId); + } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs index cd695a0143f8..1c4692652f47 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs @@ -26,7 +26,7 @@ protected override void Update() if (room == null) return; - DetailsText.Value = $"{room.Users.Count}"; + DetailsText.Value = room.Settings.MaxParticipants == null ? $@"{room.Users.Count}" : $@"{room.Users.Count} / {room.Settings.MaxParticipants}"; } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs index d4291e91a7d6..4718cac8eb0f 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs @@ -85,12 +85,20 @@ private void load(OsuColour colours) private OsuColour colours = null!; - public void UpdateStatus(MultiplayerRoomUser user) + public void UpdateStatus(Slot slot) { // the only case where the progress bar is used does its own local fade in. // starting by fading out is a sane default. progressBar.FadeOut(fade_time); + + if (slot.IsEmpty) + { + this.FadeOut(fade_time); + return; + } + this.FadeIn(fade_time); + var user = slot.User!; if (user.Role == MultiplayerRoomUserRole.Referee) { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs index f4438599ce39..2fb55c56b165 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs @@ -22,15 +22,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { - internal partial class TeamDisplay : CompositeDrawable, IHasCurrentValue + internal partial class TeamDisplay : CompositeDrawable, IHasCurrentValue { - public Bindable Current + public Bindable Current { get => current.Current; set => current.Current = value; } - private readonly BindableWithCurrent current = new BindableWithCurrent(new MultiplayerRoomUser(-1)); + private readonly BindableWithCurrent current = new BindableWithCurrent(Slot.FromUser(new MultiplayerRoomUser(-1))); [Resolved] private OsuColour colours { get; set; } = null!; @@ -116,12 +116,12 @@ private void updateState(bool playSamples) { // we don't have a way of knowing when an individual user's state has updated, so just handle on RoomUpdated for now. - var user = current.Value; - var userRoomState = client.Room?.Users.FirstOrDefault(u => u.Equals(user))?.MatchState; + var slot = current.Value; + var userRoomState = slot.IsEmpty ? null : client.Room?.Users.FirstOrDefault(u => u.Equals(slot.User))?.MatchState; bool roomLocked = (client.Room?.MatchState as TeamVersusRoomState)?.Locked == true; - if (client.LocalUser?.Equals(user) == true && !roomLocked) + if (!slot.IsEmpty && client.LocalUser?.Equals(slot.User) == true && !roomLocked) { clickableContent.Action = changeTeam; clickableContent.TooltipText = "Change team"; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs index 378410d77df0..4571a3308268 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs @@ -441,7 +441,7 @@ private void apply() room.Name = NameField.Text; room.Availability = AvailabilityPicker.Current.Value; - room.MaxParticipants = int.TryParse(MaxParticipantsField.Text, out int maxParticipants) ? maxParticipants : null; + room.MaxParticipants = !string.IsNullOrWhiteSpace(MaxParticipantsField.Text) && byte.TryParse(MaxParticipantsField.Text, out byte maxParticipants) ? maxParticipants : null; room.MaxAttempts = int.TryParse(MaxAttemptsField.Text, out int maxAttempts) ? maxAttempts : null; room.Duration = DurationField.Current.Value; diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 17455c1b4353..77ce9d4b3c30 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -258,7 +258,8 @@ protected override async Task JoinRoomInternal(long roomId, str MatchType = ServerAPIRoom.Type, Password = password ?? string.Empty, QueueMode = ServerAPIRoom.QueueMode, - AutoStartDuration = ServerAPIRoom.AutoStartDuration + AutoStartDuration = ServerAPIRoom.AutoStartDuration, + MaxParticipants = ServerAPIRoom.MaxParticipants, }, Playlist = ServerAPIRoom.Playlist.Select(item => new MultiplayerPlaylistItem(item)).ToList(), Users = { localUser }, @@ -429,6 +430,21 @@ public async Task SendUserMatchRequest(int userId, MatchUserRequest request) break; + case ChangeSlotRequest changeSlot: + if (ServerRoom.MatchState is not StandardMatchRoomState standardMatchRoomState || standardMatchRoomState.Slots is not int?[] slots) + break; + + byte slotId = changeSlot.SlotID; + if (slotId >= slots.Length || slots[slotId] != null) + break; + + int previousSlotId = Array.IndexOf(slots, LocalUser.UserID); + if (previousSlotId >= 0) + slots[previousSlotId] = null; + slots[slotId] = LocalUser.UserID; + await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(standardMatchRoomState)).ConfigureAwait(false); + break; + case StartMatchCountdownRequest startCountdown: await StartCountdown(new MatchStartCountdown { TimeRemaining = startCountdown.Duration }).ConfigureAwait(false); break; @@ -621,31 +637,40 @@ protected override Task CreateRoomInternal(MultiplayerRoom room private async Task changeMatchType(MatchType type) { Debug.Assert(ServerRoom != null); + int i = 0; switch (type) { case MatchType.HeadToHead: - ServerRoom.MatchState = null; - await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false); + var headToHeadRoomState = StandardMatchRoomState.Create(ServerRoom.Settings.MaxParticipants); foreach (var user in ServerRoom.Users) { + if (headToHeadRoomState.Slots != null) + headToHeadRoomState.Slots[i++] = user.UserID; + user.MatchState = null; await ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).ConfigureAwait(false); } + ServerRoom.MatchState = headToHeadRoomState; + await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false); break; case MatchType.TeamVersus: - ServerRoom.MatchState = TeamVersusRoomState.CreateDefault(); - await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false); + var teamVersusRoomState = TeamVersusRoomState.CreateDefault(ServerRoom.Settings.MaxParticipants); foreach (var user in ServerRoom.Users) { + if (teamVersusRoomState.Slots != null) + teamVersusRoomState.Slots[i++] = user.UserID; + user.MatchState = new TeamVersusUserState(); await ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).ConfigureAwait(false); } + ServerRoom.MatchState = teamVersusRoomState; + await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false); break; case MatchType.Matchmaking: From 6678ecebda6c0acd1b6f980301455e564bd9486b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 13:09:31 +0000 Subject: [PATCH 5/5] fix: resolve multiplayer compile and style CI failures Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/d825f8ce-9334-4f69-94c3-e55a5c9b1871 Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com> --- .../OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs | 2 +- osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs index db769d948b93..6b4d3cf918ee 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs @@ -304,7 +304,7 @@ private void updateState() // If the mods are updated at the end of the frame, the flow container will skip a reflow cycle: https://github.com/ppy/osu-framework/issues/4187 // This looks particularly jarring here, so re-schedule the update to that start of our frame as a fix. - Schedule(() => userModsDisplay.Current.Value = userRuleset == null ? [] : user.Mods.Select(m => m.ToMod(userRuleset)).ToList()); + Schedule(() => userModsDisplay.Current.Value = userRuleset == null ? [] : slot.User.Mods.Select(m => m.ToMod(userRuleset)).ToList()); } userStateDisplay.UpdateStatus(current.Value); diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index f8663c90a976..4a71f52be25b 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -643,8 +643,7 @@ private async Task changeMatchType(MatchType type) foreach (var user in ServerRoom.Users) { - if (headToHeadRoomState.Slots != null) - headToHeadRoomState.Slots[i++] = user.UserID; + headToHeadRoomState.Slots?[i++] = user.UserID; user.MatchState = null; await ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).ConfigureAwait(false); @@ -659,8 +658,7 @@ private async Task changeMatchType(MatchType type) foreach (var user in ServerRoom.Users) { - if (teamVersusRoomState.Slots != null) - teamVersusRoomState.Slots[i++] = user.UserID; + teamVersusRoomState.Slots?[i++] = user.UserID; user.MatchState = new TeamVersusUserState(); await ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).ConfigureAwait(false);