diff --git a/osu.Game/Online/Chat/StandAloneChatDisplay.cs b/osu.Game/Online/Chat/StandAloneChatDisplay.cs index 2dad62e07a..50606aca41 100644 --- a/osu.Game/Online/Chat/StandAloneChatDisplay.cs +++ b/osu.Game/Online/Chat/StandAloneChatDisplay.cs @@ -27,6 +27,7 @@ using osu.Game.Models; using osu.Game.Online.Broadcasts; using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Metadata; using osu.Game.Online.Multiplayer; @@ -117,6 +118,11 @@ public partial class StandAloneChatDisplay : CompositeDrawable [Resolved(CanBeNull = true)] // not sure if it actually can be null private ChatTimerHandler? chatTimerHandler { get; set; } + [Resolved] + private MultiplayerRefereeTracker multiplayerRefereeTracker { get; set; } = null!; + + private GetUserRequest? userReq; + /// /// Construct a new instance. /// @@ -364,309 +370,346 @@ public void EnqueueUserMessage(string message) messageQueue.Enqueue(new Tuple(message, Channel.Value)); } - private void postMessage(TextBox sender, bool newText) + private void processChatCommands(string[] parts) { - Debug.Assert(TextBox != null); - - string text = TextBox.Text.Trim(); - - if (string.IsNullOrWhiteSpace(text)) - return; - - if (text[0] == '/') - channelManager?.PostCommand(text.Substring(1), Channel.Value); - else + for (;;) { - EnqueueUserMessage(text); - - string[] parts = text.Split(); + // 3 part commands + if (!(parts.Length == 3 && parts[0] == @"!mp")) + break; - for (;;) + // commands with numerical parameter + if (int.TryParse(parts[2], out int numericParam)) { - // 3 part commands - if (!(parts.Length == 3 && parts[0] == @"!mp")) - break; - - // commands with numerical parameter - if (int.TryParse(parts[2], out int numericParam)) + switch (parts[1]) { - switch (parts[1]) - { - case @"map": - beatmapLookupCache.GetBeatmapAsync(numericParam).ContinueWith(task => Schedule(() => - { - APIBeatmap? beatmapInfo = task.GetResultSafely(); + case @"map": + beatmapLookupCache.GetBeatmapAsync(numericParam).ContinueWith(task => Schedule(() => + { + APIBeatmap? beatmapInfo = task.GetResultSafely(); - if (beatmapInfo?.BeatmapSet == null) - { - EnqueueBotMessage($@"Couldn't retrieve metadata for map ID {numericParam}"); - return; - } + if (beatmapInfo?.BeatmapSet == null) + { + EnqueueBotMessage($@"Couldn't retrieve metadata for map ID {numericParam}"); + return; + } - addPlaylistItem(beatmapInfo); + addPlaylistItem(beatmapInfo); - RemoveInternal(beatmapDownloadTracker, true); - AddInternal(beatmapDownloadTracker = new BeatmapDownloadTracker(beatmapInfo.BeatmapSet)); - beatmapDownloadTracker.State.BindValueChanged(changeEvent => - { - if (changeEvent.NewValue != DownloadState.NotDownloaded) return; + RemoveInternal(beatmapDownloadTracker, true); + AddInternal(beatmapDownloadTracker = new BeatmapDownloadTracker(beatmapInfo.BeatmapSet)); + beatmapDownloadTracker.State.BindValueChanged(changeEvent => + { + if (changeEvent.NewValue != DownloadState.NotDownloaded) return; - if (autoDownload.Value) - beatmapsDownloader.Download(beatmapInfo.BeatmapSet); + if (autoDownload.Value) + beatmapsDownloader.Download(beatmapInfo.BeatmapSet); - beatmapDownloadTracker.State.UnbindAll(); - RemoveInternal(beatmapDownloadTracker, true); - }); - })); - break; + beatmapDownloadTracker.State.UnbindAll(); + RemoveInternal(beatmapDownloadTracker, true); + }); + })); + break; - case @"timer": - chatTimerHandler?.SetTimer(TimeSpan.FromSeconds(numericParam), Time.Current); - break; + case @"timer": + chatTimerHandler?.SetTimer(TimeSpan.FromSeconds(numericParam), Time.Current); + break; - case @"start": - // we intentionally do this check both in startMatch and here - if (!Client.IsHost) - { - Logger.Log(@"Tried to start match when user is not host of the room. Cancelling!", LoggingTarget.Runtime, LogLevel.Important); - return; - } + case @"start": + // we intentionally do this check both in startMatch and here + if (!Client.IsHost) + { + Logger.Log(@"Tried to start match when user is not host of the room. Cancelling!", LoggingTarget.Runtime, LogLevel.Important); + return; + } - chatTimerHandler?.SetTimer(TimeSpan.FromSeconds(numericParam), Time.Current, messagePrefix: @"Match starts in", onTimerComplete: startMatch); - break; - } - } - // special-case player invites - else if (parts[2].StartsWith('#') && int.TryParse(parts[2].AsSpan(1), out numericParam) && parts[1] == @"invite") - { - inviteUserToRoom(numericParam); + chatTimerHandler?.SetTimer(TimeSpan.FromSeconds(numericParam), Time.Current, messagePrefix: @"Match starts in", onTimerComplete: startMatch); + break; } - else + } + // special-case player invites + else if (parts[2].StartsWith('#') && int.TryParse(parts[2].AsSpan(1), out numericParam) && parts[1] == @"invite") + { + inviteUserToRoom(numericParam); + } + else + { + switch (parts[1]) { - switch (parts[1]) - { - // i don't think this belongs here in the first place... whatever - // ReSharper disable once StringLiteralTypo - case @"aborttimer": + // i don't think this belongs here in the first place... whatever + // ReSharper disable once StringLiteralTypo + case @"aborttimer": + abortTimer(); + break; + + case @"timer": + if (parts[2] == @"abort") abortTimer(); - break; - case @"timer": - if (parts[2] == @"abort") - abortTimer(); + break; - break; + case @"invite": + EnqueueBotMessage(@"use their user IDs from the sheets please kthx"); + break; + + case @"mods": + var itemToEdit = Client.Room?.Playlist.SingleOrDefault(i => i.ID == Client.Room?.Settings.PlaylistItemId); - case @"invite": - // // parameter is a username since it didn't start with `#` - // if (string.IsNullOrEmpty(parts[2])) - // { - // EnqueueBotMessage(@"Invalid username provided"); - // break; - // } - // - // string username = parts[2].Replace('_', ' '); - // - // // check if user is already in the lobby - // var matchingUser = Client.Room?.Users.FirstOrDefault(u => string.Equals(u.User?.Username, username, StringComparison.OrdinalIgnoreCase)); - // - // if (matchingUser != null) - // { - // EnqueueBotMessage($@"User {matchingUser.User?.Username ?? ""} is already in the room!"); - // break; - // } - // - // // try to resolve the username - // userReq?.Cancel(); - // userReq = new GetUserRequest(username); - // userReq.Success += u => inviteUserToRoom(u.Id); - // userReq.Failure += e => - // { - // EnqueueBotMessage($@"Couldn't find user {username}: {e.InnerException?.Message}"); - // }; - // - // API.Queue(userReq); - EnqueueBotMessage("use their user IDs from the sheets please kthx"); + if (itemToEdit == null) break; - case @"mods": - var itemToEdit = Client.Room?.Playlist.SingleOrDefault(i => i.ID == Client.Room?.Settings.PlaylistItemId); + string[] mods = parts[2].Split("+"); + List modInstances = new List(); - if (itemToEdit == null) - break; + foreach (string mod in mods) + { + if (mod.Length < 2) + { + Logger.Log($@"[!mp mods] Unknown mod '{mod}', ignoring", LoggingTarget.Runtime, LogLevel.Important); + continue; + } - string[] mods = parts[2].Split("+"); - List modInstances = new List(); + string modAcronym = mod[..2]; + var rulesetInstance = RulesetStore.GetRuleset(itemToEdit.RulesetID)?.CreateInstance(); - foreach (string mod in mods) + if (rulesetInstance == null) { - if (mod.Length < 2) - { - Logger.Log($@"[!mp mods] Unknown mod '{mod}', ignoring", LoggingTarget.Runtime, LogLevel.Important); - continue; - } + Logger.Log($@"[!mp mods] Couldn't create ruleset instance with ruleset ID {itemToEdit.RulesetID}, ignoring mod '{mod}'", + LoggingTarget.Runtime, LogLevel.Important); + continue; + } - string modAcronym = mod[..2]; - var rulesetInstance = RulesetStore.GetRuleset(itemToEdit.RulesetID)?.CreateInstance(); + Mod? modInstance; - if (rulesetInstance == null) - { - Logger.Log($@"[!mp mods] Couldn't create ruleset instance with ruleset ID {itemToEdit.RulesetID}, ignoring mod '{mod}'", - LoggingTarget.Runtime, LogLevel.Important); - continue; - } + // mod with no params + if (mod.Length == 2) + { + modInstance = ParseMod(rulesetInstance, modAcronym, Array.Empty()); + if (modInstance != null) + modInstances.Add(modInstance); + continue; + } - Mod? modInstance; + // mod has parameters + { + JsonNode? modParamsNode; - // mod with no params - if (mod.Length == 2) + try { - modInstance = ParseMod(rulesetInstance, modAcronym, Array.Empty()); - if (modInstance != null) - modInstances.Add(modInstance); - continue; + modParamsNode = JsonNode.Parse(mod[2..]); + } + catch (JsonException) + { + modParamsNode = null; } - // mod has parameters + if (modParamsNode is JsonArray modParams) { - JsonNode? modParamsNode; + List parsedParamsList = new List(); - try + foreach (JsonNode? node in modParams) { - modParamsNode = JsonNode.Parse(mod[2..]); - } - catch (JsonException) - { - modParamsNode = null; - } + if (node?.GetValueKind() is not (JsonValueKind.Number or JsonValueKind.False or JsonValueKind.True)) + continue; - if (modParamsNode is JsonArray modParams) - { - List parsedParamsList = new List(); + if (node.AsValue().TryGetValue(out int parsedInt)) + { + parsedParamsList.Add(parsedInt); + continue; + } - foreach (JsonNode? node in modParams) + if (node.AsValue().TryGetValue(out double parsedDouble)) { - if (node?.GetValueKind() is not (JsonValueKind.Number or JsonValueKind.False or JsonValueKind.True)) - continue; - - if (node.AsValue().TryGetValue(out int parsedInt)) - { - parsedParamsList.Add(parsedInt); - continue; - } - - if (node.AsValue().TryGetValue(out double parsedDouble)) - { - parsedParamsList.Add(parsedDouble); - continue; - } - - if (node.AsValue().TryGetValue(out bool parsedBool)) - parsedParamsList.Add(parsedBool); + parsedParamsList.Add(parsedDouble); + continue; } - modInstance = ParseMod(rulesetInstance, modAcronym, parsedParamsList); - if (modInstance != null) - modInstances.Add(modInstance); - } - else - { - Logger.Log($@"[!mp mods] Couldn't parse mod parameter(s) '{mod[2..]}', ignoring", LoggingTarget.Runtime, LogLevel.Important); + if (node.AsValue().TryGetValue(out bool parsedBool)) + parsedParamsList.Add(parsedBool); } + + modInstance = ParseMod(rulesetInstance, modAcronym, parsedParamsList); + if (modInstance != null) + modInstances.Add(modInstance); + } + else + { + Logger.Log($@"[!mp mods] Couldn't parse mod parameter(s) '{mod[2..]}', ignoring", LoggingTarget.Runtime, LogLevel.Important); } } + } + + if (!ModUtils.CheckCompatibleSet(modInstances)) + { + Logger.Log($@"[!mp mods] Mods {string.Join(", ", modInstances.Select(mod => mod.Acronym))} are not compatible together", LoggingTarget.Runtime, LogLevel.Important); + break; + } - if (!ModUtils.CheckCompatibleSet(modInstances)) + // get playlist item to edit: + beatmapLookupCache.GetBeatmapAsync(itemToEdit.BeatmapID).ContinueWith(task => Schedule(() => + { + APIBeatmap? beatmapInfo = task.GetResultSafely(); + + if (beatmapInfo == null) { - Logger.Log($@"[!mp mods] Mods {string.Join(", ", modInstances.Select(mod => mod.Acronym))} are not compatible together", LoggingTarget.Runtime, LogLevel.Important); - break; + Logger.Log($@"Couldn't retrieve metadata for map ID {itemToEdit.BeatmapID}, not modifying playlist!", LoggingTarget.Runtime, LogLevel.Important); + return; } - // get playlist item to edit: - beatmapLookupCache.GetBeatmapAsync(itemToEdit.BeatmapID).ContinueWith(task => Schedule(() => + var multiplayerItem = new MultiplayerPlaylistItem { - APIBeatmap? beatmapInfo = task.GetResultSafely(); - - if (beatmapInfo == null) - { - Logger.Log($@"Couldn't retrieve metadata for map ID {itemToEdit.BeatmapID}, not modifying playlist!", LoggingTarget.Runtime, LogLevel.Important); - return; - } - - var multiplayerItem = new MultiplayerPlaylistItem - { - ID = itemToEdit.ID, - BeatmapID = beatmapInfo.OnlineID, - BeatmapChecksum = beatmapInfo.MD5Hash, - RulesetID = itemToEdit.RulesetID, - RequiredMods = modInstances.Select(mod => new APIMod(mod)).ToArray(), - AllowedMods = Array.Empty() - }; - - selectionOperation = operationTracker.BeginOperation(); - Task editPlaylistTask = Client.EditPlaylistItem(multiplayerItem); - - editPlaylistTask.FireAndForget(onSuccess: () => - { - selectionOperation?.Dispose(); - }, onError: _ => - { - selectionOperation?.Dispose(); - }); - })); - break; - } + ID = itemToEdit.ID, + BeatmapID = beatmapInfo.OnlineID, + BeatmapChecksum = beatmapInfo.MD5Hash, + RulesetID = itemToEdit.RulesetID, + RequiredMods = modInstances.Select(mod => new APIMod(mod)).ToArray(), + AllowedMods = Array.Empty() + }; + + selectionOperation = operationTracker.BeginOperation(); + Task editPlaylistTask = Client.EditPlaylistItem(multiplayerItem); + + editPlaylistTask.FireAndForget(onSuccess: () => + { + selectionOperation?.Dispose(); + }, onError: _ => + { + selectionOperation?.Dispose(); + }); + })); + break; } + } + break; + } + + for (;;) + { + if (!(parts.Length == 2 && parts[0] == @"!mp")) break; - } - for (;;) + // commands with no parameters + switch (parts[1]) { - if (!(parts.Length == 2 && parts[0] == @"!mp")) + case @"abort": + if (!Client.IsHost) + return; + + Client.AbortMatch().FireAndForget(); break; - // commands with no parameters - switch (parts[1]) + case @"settings": + EnqueueBotMessage(@"Look at the lobby on your screen"); + break; + + // start immediately + case @"start": + startMatch(); + break; + + // ReSharper disable once StringLiteralTypo + case @"aborttimer": + abortTimer(); + break; + + case @"results": + if (!lastFetchTask.IsCompleted) + return; + + Schedule(() => + { + long? roomID = Client.Room?.RoomID; + var playlistItem = getLastPlaylistItem(); + + if (roomID != null && playlistItem != null) + lastFetchTask = Task.Run(async () => await postLatestResults((long)roomID, playlistItem).ConfigureAwait(false)); + }); + + break; + } + + break; + } + } + + private void postMessage(TextBox sender, bool newText) + { + Debug.Assert(TextBox != null); + + string text = TextBox.Text.Trim(); + + if (string.IsNullOrWhiteSpace(text)) + return; + + if (text[0] == '/') + channelManager?.PostCommand(text.Substring(1), Channel.Value); + else + { + EnqueueUserMessage(text); + + string[] parts = text.Split(); + + if (parts.Length > 0 && parts[0] == @"!mp") + { + if (!Client.IsHost) { - case @"abort": - if (!Client.IsHost) - return; + Logger.Log(@"Tried to use !mp command when user is not host of the room. Not running command locally", LoggingTarget.Runtime, LogLevel.Debug); + } + else + { + async Task queryUsername(string username) + { + string patchedUsername = username.Replace('_', ' '); - Client.AbortMatch().FireAndForget(); - break; + // check if user is already in the lobby + var matchingUser = Client.Room?.Users.FirstOrDefault(u => string.Equals(u.User?.Username, patchedUsername, StringComparison.OrdinalIgnoreCase)); - case @"settings": - EnqueueBotMessage(@"Look at the lobby on your screen"); - break; + if (matchingUser != null) + { + return matchingUser.User; + } - // start immediately - case @"start": - startMatch(); - break; + // try to resolve the username + userReq?.Cancel(); + var tcs = new TaskCompletionSource(); + userReq = new GetUserRequest(patchedUsername); + userReq.Success += u => + { + Logger.Log($@"[addref] Successfully resolved user ${u}"); + tcs.TrySetResult(u); + }; + userReq.Failure += e => + { + Logger.Log($@"[addref] Could not resolve user {patchedUsername}"); + tcs.TrySetResult(null); + }; - // ReSharper disable once StringLiteralTypo - case @"aborttimer": - abortTimer(); - break; + API.Queue(userReq); - case @"results": - if (!lastFetchTask.IsCompleted) - return; + return await tcs.Task.ConfigureAwait(false); + } - Schedule(() => + // special-case addref. only handle it locally + if (parts.Length == 3 && parts[1] == @"addref") + { + queryUsername(parts[2]).ContinueWith(t => { - long? roomID = Client.Room?.RoomID; - var playlistItem = getLastPlaylistItem(); + APIUser? user = t.GetResultSafely(); - if (roomID != null && playlistItem != null) - lastFetchTask = Task.Run(async () => await postLatestResults((long)roomID, playlistItem).ConfigureAwait(false)); + if (user == null) + { + EnqueueBotMessage($@"Failed to find user {parts[2]}"); + return; + } + + multiplayerRefereeTracker.AddRef(user); + EnqueueBotMessage($@"Match referees: {string.Join(", ", multiplayerRefereeTracker.Referees)}"); }); + } - break; + processChatCommands(parts); } - - break; } } @@ -684,9 +727,10 @@ private void inviteUserToRoom(int userId) } // It's possible the user isn't truly offline, so send the invite anyway. Warn the user in chat though. - EnqueueBotMessage(metadataClient.GetPresence(userId) == null - ? $@"User ID {userId} may be offline, attempting to send an invite anyway." - : $@"Sent an invite to user ID {userId}"); + string msg = metadataClient.GetPresence(userId) == null + ? $@"User ID {userId} may be offline, attempting to send an invite anyway." + : $@"Sent an invite to user ID {userId}"; + EnqueueBotMessage(msg); Client.InvitePlayer(userId); } @@ -826,41 +870,57 @@ private void addPlaylistItem(APIBeatmap beatmapInfo, APIMod[]? requiredMods = nu return new StandAloneMessage(message); } - private void newCommandHandler(IEnumerable messages) + private void newMessageCommandHandler(IEnumerable messages) { foreach (var message in messages) { string[] parts = message.Content.Split(); - if (parts.Length <= 0 || parts[0] != @"!roll" || !Client.IsHost) continue; + if (parts.Length <= 0 || !Client.IsHost) continue; - long limit = 100; + if (parts[0] == @"!roll" && parts.Length <= 2) + { + postRollResult(parts, message); + continue; + } - if (parts.Length > 1) + if (multiplayerRefereeTracker.Referees.Any(refereeApiUser => refereeApiUser.Equals(message.Sender))) { - try - { - limit = long.Parse(parts[1]); - } - catch (OverflowException) - { - limit = long.MaxValue; - } - catch (Exception) - { - limit = 100; - } + // sender is a referee, execute command on their behalf + Logger.Log($@"Executing '{message.Content}' on behalf of referee {message.Sender}"); + processChatCommands(parts); } + } + } + + private void postRollResult(string[] parts, Message message) + { + long limit = 100; - var rnd = new Random(); - long randomNumber = rnd.NextInt64(1, limit + 1); - EnqueueBotMessage($@"{message.Sender} rolls {randomNumber}"); + if (parts.Length > 1) + { + try + { + limit = long.Parse(parts[1]); + } + catch (OverflowException) + { + limit = long.MaxValue; + } + catch (Exception) + { + limit = 100; + } } + + var rnd = new Random(); + long randomNumber = rnd.NextInt64(1, limit + 1); + EnqueueBotMessage($@"{message.Sender} rolls {randomNumber}"); } private void channelChanged(ValueChangedEvent e) { if (drawableChannel != null) - drawableChannel.Channel.NewMessagesArrived -= newCommandHandler; + drawableChannel.Channel.NewMessagesArrived -= newMessageCommandHandler; drawableChannel?.Expire(); if (e.OldValue != null) @@ -873,7 +933,7 @@ private void channelChanged(ValueChangedEvent e) drawableChannel = CreateDrawableChannel(e.NewValue); drawableChannel.CreateChatLineAction = CreateMessage; drawableChannel.Padding = new MarginPadding { Bottom = postingTextBox ? text_box_height : 0 }; - drawableChannel.Channel.NewMessagesArrived += newCommandHandler; + drawableChannel.Channel.NewMessagesArrived += newMessageCommandHandler; chatBroadcaster.Message.ChatMessages.Clear(); AddInternal(drawableChannel); diff --git a/osu.Game/Online/Metadata/OnlineMetadataClient.cs b/osu.Game/Online/Metadata/OnlineMetadataClient.cs index 6637fc8dba..4a1681b711 100644 --- a/osu.Game/Online/Metadata/OnlineMetadataClient.cs +++ b/osu.Game/Online/Metadata/OnlineMetadataClient.cs @@ -185,11 +185,7 @@ public override Task GetChangesSince(int queueId) public override Task UpdateActivity(UserActivity? activity) { - if (connector?.IsConnected.Value != true) - return Task.FromCanceled(new CancellationToken(true)); - - Debug.Assert(connection != null); - return connection.InvokeAsync(nameof(IMetadataServer.UpdateActivity), activity); + return Task.FromCanceled(new CancellationToken(true)); } public override Task UpdateStatus(UserStatus? status) diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 4c2d982d13..16fa1d52ea 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -1127,6 +1127,7 @@ protected override void LoadComplete() loadComponentSingleFile(difficultyRecommender = new DifficultyRecommender(statisticsProvider), Add, true); loadComponentSingleFile(new UserStatisticsWatcher(statisticsProvider), Add, true); loadComponentSingleFile(new ChatTimerHandler(), Add, true); + loadComponentSingleFile(new MultiplayerRefereeTracker(), Add, true); loadComponentSingleFile(Toolbar = new Toolbar { OnHome = delegate diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index 841fb3a25e..cdc8fe7e37 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -72,6 +72,40 @@ public class Pool public readonly BindableList Beatmaps = new BindableList(); } + public partial class MultiplayerRefereeTracker : Component + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + public readonly BindableList Referees = new BindableList(); + private bool isRoomJoined; + + [BackgroundDependencyLoader] + private void load() + { + client.RoomUpdated += onRoomUpdated; + } + + public void AddRef(APIUser user) + { + if (!Referees.Contains(user)) + { + Referees.Add(user); + } + } + + private void onRoomUpdated() + { + bool wasRoomJoined = isRoomJoined; + bool roomJoined = client.Room != null; + + if (wasRoomJoined && !roomJoined) + Referees.Clear(); + + isRoomJoined = roomJoined; + } + } + public partial class ChatTimerHandler : Component { private readonly MultiplayerCountdown multiplayerChatTimerCountdown = new MatchStartCountdown { TimeRemaining = TimeSpan.Zero };