From 43c53b5a8db909482ee69d462282100a3cb7291e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 20 Mar 2026 10:56:45 +0100 Subject: [PATCH] Various tournament-minded improvements for multiplayer (#37000) https://github.com/user-attachments/assets/042311f1-b8c3-479b-a173-13b93fe2c5cc - `/roll` command is now supported in multiplayer chat for all players (don't need to be a referee). - Referees are shown in the room with a special status. - Tournament mode rooms can be locked, which prevents users from changing team (and slot, whenever those get brought back). When the room is locked, the user team indicator shows a padlock icon on top to indicate the lock state. --- - Related: https://github.com/ppy/osu-server-spectator/issues/406 Grab-bag because I really don't think splitting this into 3 PRs is very helpful. I was going to add an animation for rolling but I had a go on Friday and my attempt got more or less the same reception as a wet fart so I'm not trying again. Someone else can if so inclined. I have completely lost trust in my design senses. Contrary to stable the roll results are completely ephemeral and go away when the room is re-entered. This could be both considered good and bad. Not for me to say. --- .../TestSceneMultiplayerMatchSubScreen.cs | 23 +++++++ .../TestSceneMultiplayerParticipantsList.cs | 19 ++++++ .../Visual/Multiplayer/TestSceneTeamVersus.cs | 49 +++++++++++++++ osu.Game/Online/Chat/ChannelManager.cs | 51 ++++++++++++--- .../Multiplayer/MultiplayerMatchSubScreen.cs | 19 +++++- .../Participants/ParticipantPanel.cs | 2 +- .../Multiplayer/Participants/StateDisplay.cs | 16 +++-- .../Multiplayer/Participants/TeamDisplay.cs | 62 ++++++++++++------- .../Multiplayer/TestMultiplayerClient.cs | 11 ++++ 9 files changed, 215 insertions(+), 37 deletions(-) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index 792bff63d3..906cb3436c 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -17,6 +17,7 @@ using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Overlays; @@ -57,6 +58,7 @@ namespace osu.Game.Tests.Visual.Multiplayer Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); Dependencies.CacheAs(new RealmDetachedBeatmapStore()); + Dependencies.Cache(new ChannelManager(API)); beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); @@ -439,6 +441,27 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("countdown started", () => MultiplayerClient.ServerRoom!.ActiveCountdowns.Any()); } + [Test] + public void TestRoll() + { + AddStep("set playlist", () => + { + room.Playlist = + [ + new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + } + ]; + }); + ClickButtonWhenEnabled(); + AddStep("set channel", () => room.ChannelId = 1); + + AddUntilStep("wait for room join", () => RoomJoined); + + AddStep("roll", () => MultiplayerClient.SendMatchRequest(new RollRequest())); + } + [Test] public void TestSettingsRemainsOpenOnRoomUpdate() { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs index 50c4c3439b..7f1eb50ac2 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs @@ -56,6 +56,25 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("two unique panels", () => this.ChildrenOfType().Select(p => p.Current.Value).Distinct().Count() == 2); } + [Test] + public void TestAddReferee() + { + AddAssert("one unique panel", () => this.ChildrenOfType().Select(p => p.Current.Value).Distinct().Count() == 1); + + AddStep("add user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(3) + { + User = new APIUser + { + Id = 3, + Username = "Second", + CoverUrl = TestResources.COVER_IMAGE_3, + }, + Role = MultiplayerRoomUserRole.Referee + })); + + AddAssert("two unique panels", () => this.ChildrenOfType().Select(p => p.Current.Value).Distinct().Count() == 2); + } + [Test] public void TestAddUnresolvedUser() { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs index 2e08b494bd..c9b88c99bb 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs @@ -116,6 +116,55 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("user still on team 0", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0); } + [Test] + public void TestTeamChangesLocked() + { + createRoom(() => new Room + { + Name = "Test Room", + Type = MatchType.TeamVersus, + Playlist = + [ + new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + } + ] + }); + + AddUntilStep("user on team 0", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0); + AddStep("add another user", () => multiplayerClient.AddUser(new APIUser { Username = "otheruser", Id = 44 })); + + AddStep("lock room", () => + { + var roomState = TeamVersusRoomState.CreateDefault(); + roomState.Locked = true; + multiplayerClient.ChangeMatchRoomState(roomState).WaitSafely(); + }); + AddStep("press own button", () => + { + InputManager.MoveMouseTo(multiplayerComponents.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("user on team 0", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0); + + AddStep("unlock room", () => + { + var roomState = TeamVersusRoomState.CreateDefault(); + roomState.Locked = false; + multiplayerClient.ChangeMatchRoomState(roomState).WaitSafely(); + }); + AddStep("press own button", () => + { + InputManager.MoveMouseTo(multiplayerComponents.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("user on team 1", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 1); + + AddStep("press own button again", () => InputManager.Click(MouseButton.Left)); + AddUntilStep("user on team 0", () => (multiplayerClient.ClientRoom?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0); + } + [Test] public void TestSettingsUpdatedWhenChangingMatchType() { diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index aec7928ba8..cacea7636b 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -7,6 +7,8 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; +using JetBrains.Annotations; +using Microsoft.AspNetCore.SignalR; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -18,6 +20,7 @@ using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; using osu.Game.Overlays.Chat.Listing; namespace osu.Game.Online.Chat @@ -70,6 +73,10 @@ namespace osu.Game.Online.Chat [Resolved] private UserLookupCache users { get; set; } + [Resolved(CanBeNull = true)] + [CanBeNull] + private MultiplayerClient multiplayerClient { get; set; } + private readonly IBindable localUser = new Bindable(); private readonly IBindable apiState = new Bindable(); private readonly IBindableList localUserBlocks = new BindableList(); @@ -264,11 +271,11 @@ namespace osu.Game.Online.Chat switch (command.ToLowerInvariant()) { - case "np": + case @"np": AddInternal(new NowPlayingCommand(target)); break; - case "me": + case @"me": if (string.IsNullOrWhiteSpace(content)) { target.AddNewMessages(new ErrorMessage("Usage: /me [action]")); @@ -278,7 +285,7 @@ namespace osu.Game.Online.Chat PostMessage(content, true, target); break; - case "join": + case @"join": if (string.IsNullOrWhiteSpace(content)) { target.AddNewMessages(new ErrorMessage("Usage: /join [channel]")); @@ -296,9 +303,9 @@ namespace osu.Game.Online.Chat JoinChannel(channel); break; - case "chat": - case "msg": - case "query": + case @"chat": + case @"msg": + case @"query": if (string.IsNullOrWhiteSpace(content)) { target.AddNewMessages(new ErrorMessage($"Usage: /{command} [user]")); @@ -323,8 +330,36 @@ namespace osu.Game.Online.Chat api.Queue(request); break; - case "help": - target.AddNewMessages(new InfoMessage("Supported commands: /help, /me [action], /join [channel], /chat [user], /np")); + case @"roll": + if (target.Type != ChannelType.Multiplayer || multiplayerClient?.Room?.ChannelID != target.Id) + { + target.AddNewMessages(new ErrorMessage("Cannot roll when not in a multiplayer room.")); + break; + } + + uint max = 100; + + if (!string.IsNullOrEmpty(content)) + { + if (!uint.TryParse(content, out max) || max < 2 || max > 100) + { + target.AddNewMessages(new ErrorMessage("Usage: /roll [2-100]")); + break; + } + } + + var rollRequest = new RollRequest { Max = max }; + multiplayerClient.SendMatchRequest(rollRequest).FireAndForget(onError: ex => + { + string message = ex is HubException + ? $"Failed to roll: {ex.Message}" + : "Failed to roll."; + target.AddNewMessages(new ErrorMessage(message)); + }); + break; + + case @"help": + target.AddNewMessages(new InfoMessage("Supported commands: /help, /me [action], /join [channel], /chat [user], /np, /roll [2-100] (multiplayer only)")); break; default: diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index 40c1309e90..9b66c37402 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using Humanizer; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -21,6 +22,7 @@ using osu.Game.Graphics.Cursor; using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Overlays; @@ -145,6 +147,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private FillFlowContainer userStyleSection = null!; private Container userStyleDisplayContainer = null!; + private MatchChatDisplay chat = null!; + private Sample? sampleStart; private IDisposable? userModsSelectOverlayRegistration; @@ -370,7 +374,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer }, new Drawable[] { - new MatchChatDisplay(room) + chat = new MatchChatDisplay(room) { RelativeSizeAxes = Axes.Both } @@ -431,6 +435,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer client.UserStyleChanged += onUserStyleChanged; client.UserModsChanged += onUserModsChanged; client.LoadRequested += onLoadRequested; + client.MatchEvent += onMatchEvent; beatmapAvailabilityTracker.Availability.BindValueChanged(onBeatmapAvailabilityChanged, true); @@ -594,6 +599,18 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer } } + private void onMatchEvent(MatchServerEvent ev) + { + switch (ev) + { + case RollEvent rollEvent: + var user = client.Room?.Users.SingleOrDefault(u => u.UserID == rollEvent.UserID)?.User ?? new APIUser { Username = "Unknown user" }; + string text = $"{user.Username} rolled {"point".ToQuantity(rollEvent.Result)} out of {rollEvent.Max}."; + chat.Channel.Value?.AddNewMessages(new InfoMessage(text)); + break; + } + } + /// /// Responds to changes in the local user's beatmap availability to notify the server and prepare the gameplay session. /// diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs index c9804a1bf8..fb6001bda0 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs @@ -282,7 +282,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants Schedule(() => userModsDisplay.Current.Value = userRuleset == null ? Array.Empty() : user.Mods.Select(m => m.ToMod(userRuleset)).ToList()); } - userStateDisplay.UpdateStatus(user.State, user.BeatmapAvailability); + userStateDisplay.UpdateStatus(user); if (user.BeatmapAvailability.State == DownloadState.LocallyAvailable && user.State != MultiplayerUserState.Spectating) { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs index b0cc13d645..d4291e91a7 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs @@ -85,17 +85,25 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants private OsuColour colours = null!; - public void UpdateStatus(MultiplayerUserState state, BeatmapAvailability availability) + public void UpdateStatus(MultiplayerRoomUser user) { // 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); this.FadeIn(fade_time); - switch (state) + if (user.Role == MultiplayerRoomUserRole.Referee) + { + text.Text = "referee"; + icon.Icon = OsuIcon.EditorWhistle; + icon.Colour = colours.BlueLight; + return; + } + + switch (user.State) { case MultiplayerUserState.Idle: - showBeatmapAvailability(availability); + showBeatmapAvailability(user.BeatmapAvailability); break; case MultiplayerUserState.Ready: @@ -142,7 +150,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants break; default: - throw new ArgumentOutOfRangeException(nameof(state), state, null); + throw new ArgumentOutOfRangeException(nameof(user.State), user.State, null); } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs index 1550cbb8df..f4438599ce 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/TeamDisplay.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -39,6 +40,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants private OsuClickableContainer clickableContent = null!; private Drawable box = null!; + private SpriteIcon lockIcon = null!; private Sample? sampleTeamSwap; public TeamDisplay() @@ -57,20 +59,32 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants Alpha = 0, Scale = new Vector2(0, 1), RelativeSizeAxes = Axes.Y, - Child = box = new Container + Children = new[] { - RelativeSizeAxes = Axes.Both, - CornerRadius = 5, - Masking = true, - Scale = new Vector2(0, 1), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Child = new Box + box = new Container { - Colour = Color4.White, RelativeSizeAxes = Axes.Both, + CornerRadius = 5, + Masking = true, + Scale = new Vector2(0, 1), Anchor = Anchor.Centre, Origin = Anchor.Centre, + Child = new Box + { + Colour = Color4.White, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }, + lockIcon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(12), + Icon = FontAwesome.Solid.Lock, + Colour = Colour4.Black, + Alpha = 0, } } }; @@ -83,7 +97,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants base.LoadComplete(); client.RoomUpdated += onRoomUpdated; - current.BindValueChanged(_ => updateUser(), true); + current.BindValueChanged(_ => updateState(false), true); } private void changeTeam() @@ -96,19 +110,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants public int? DisplayedTeam { get; private set; } - private void updateUser() - { - var user = current.Value; - - if (client.LocalUser?.Equals(user) == true) - { - clickableContent.Action = changeTeam; - clickableContent.TooltipText = "Change team"; - } - - updateState(false); - } - private void onRoomUpdated() => Scheduler.AddOnce(() => updateState(true)); private void updateState(bool playSamples) @@ -118,8 +119,23 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants var user = current.Value; var userRoomState = client.Room?.Users.FirstOrDefault(u => u.Equals(user))?.MatchState; + bool roomLocked = (client.Room?.MatchState as TeamVersusRoomState)?.Locked == true; + + if (client.LocalUser?.Equals(user) == true && !roomLocked) + { + clickableContent.Action = changeTeam; + clickableContent.TooltipText = "Change team"; + } + else + { + clickableContent.Action = null; + clickableContent.TooltipText = default; + } + const double duration = 400; + lockIcon.FadeTo(roomLocked ? 0.4f : 0, duration, Easing.OutQuint); + int? newTeam = (userRoomState as TeamVersusUserState)?.TeamID; if (newTeam == DisplayedTeam) diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index c6e39016f0..64ebaab783 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -10,6 +10,7 @@ using MessagePack; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Online; using osu.Game.Online.API; @@ -433,6 +434,16 @@ namespace osu.Game.Tests.Visual.Multiplayer await StopCountdown(ServerRoom.ActiveCountdowns.First(c => c.ID == stopCountdown.ID)).ConfigureAwait(false); break; + case RollRequest rollRequest: + int max = (int)(rollRequest.Max ?? 100); + await ((IMultiplayerClient)this).MatchEvent(new RollEvent + { + UserID = userId, + Max = (uint)max, + Result = (uint)RNG.Next(1, max + 1) + }).ConfigureAwait(false); + break; + case MatchmakingAvatarActionRequest avatarAction: await ((IMultiplayerClient)this).MatchEvent(new MatchmakingAvatarActionEvent {