// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. #nullable enable using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; using osu.Game.Online.Rooms.RoomStatuses; using osu.Game.Rulesets; using osu.Game.Users; using osu.Game.Utils; namespace osu.Game.Online.Multiplayer { public abstract class StatefulMultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer { /// /// Invoked when any change occurs to the multiplayer room. /// public event Action? RoomUpdated; /// /// Invoked when the multiplayer server requests the current beatmap to be loaded into play. /// public event Action? LoadRequested; /// /// Invoked when the multiplayer server requests gameplay to be started. /// public event Action? MatchStarted; /// /// Invoked when the multiplayer server has finished collating results. /// public event Action? ResultsReady; /// /// Whether the is currently connected. /// This is NOT thread safe and usage should be scheduled. /// public abstract IBindable IsConnected { get; } /// /// The joined . /// public MultiplayerRoom? Room { get; private set; } /// /// The users in the joined which are participating in the current gameplay loop. /// public readonly BindableList CurrentMatchPlayingUserIds = new BindableList(); /// /// The corresponding to the local player, if available. /// public MultiplayerRoomUser? LocalUser => Room?.Users.SingleOrDefault(u => u.User?.Id == api.LocalUser.Value.Id); /// /// Whether the is the host in . /// public bool IsHost { get { var localUser = LocalUser; return localUser != null && Room?.Host != null && localUser.Equals(Room.Host); } } [Resolved] private UserLookupCache userLookupCache { get; set; } = null!; [Resolved] private IAPIProvider api { get; set; } = null!; [Resolved] private RulesetStore rulesets { get; set; } = null!; private Room? apiRoom; // Todo: This is temporary, until the multiplayer server returns the item id on match start or otherwise. private int playlistItemId; protected StatefulMultiplayerClient() { IsConnected.BindValueChanged(connected => { // clean up local room state on server disconnect. if (!connected.NewValue && Room != null) { Logger.Log("Connection to multiplayer server was lost.", LoggingTarget.Runtime, LogLevel.Important); LeaveRoom().CatchUnobservedExceptions(); } }); } /// /// Joins the for a given API . /// /// The API . public async Task JoinRoom(Room room) { if (Room != null) throw new InvalidOperationException("Cannot join a multiplayer room while already in one."); Debug.Assert(room.RoomID.Value != null); apiRoom = room; playlistItemId = room.Playlist.SingleOrDefault()?.ID ?? 0; Room = await JoinRoom(room.RoomID.Value.Value); Debug.Assert(Room != null); var users = await getRoomUsers(); Debug.Assert(users != null); await Task.WhenAll(users.Select(PopulateUser)); updateLocalRoomSettings(Room.Settings); } /// /// Joins the with a given ID. /// /// The room ID. /// The joined . protected abstract Task JoinRoom(long roomId); public virtual Task LeaveRoom() { Scheduler.Add(() => { if (Room == null) return; apiRoom = null; Room = null; CurrentMatchPlayingUserIds.Clear(); RoomUpdated?.Invoke(); }, false); return Task.CompletedTask; } /// /// Change the current settings. /// /// /// A room must be joined for this to have any effect. /// /// The new room name, if any. /// The new room playlist item, if any. public Task ChangeSettings(Optional name = default, Optional item = default) { if (Room == null) throw new InvalidOperationException("Must be joined to a match to change settings."); // A dummy playlist item filled with the current room settings (except mods). var existingPlaylistItem = new PlaylistItem { Beatmap = { Value = new BeatmapInfo { OnlineBeatmapID = Room.Settings.BeatmapID, MD5Hash = Room.Settings.BeatmapChecksum } }, RulesetID = Room.Settings.RulesetID }; return ChangeSettings(new MultiplayerRoomSettings { Name = name.GetOr(Room.Settings.Name), BeatmapID = item.GetOr(existingPlaylistItem).BeatmapID, BeatmapChecksum = item.GetOr(existingPlaylistItem).Beatmap.Value.MD5Hash, RulesetID = item.GetOr(existingPlaylistItem).RulesetID, Mods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.Mods }); } /// /// Toggles the 's ready state. /// /// If a toggle of ready state is not valid at this time. public async Task ToggleReady() { var localUser = LocalUser; if (localUser == null) return; switch (localUser.State) { case MultiplayerUserState.Idle: await ChangeState(MultiplayerUserState.Ready); return; case MultiplayerUserState.Ready: await ChangeState(MultiplayerUserState.Idle); return; default: throw new InvalidOperationException($"Cannot toggle ready when in {localUser.State}"); } } public abstract Task TransferHost(int userId); public abstract Task ChangeSettings(MultiplayerRoomSettings settings); public abstract Task ChangeState(MultiplayerUserState newState); public abstract Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability); public abstract Task StartMatch(); Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state) { if (Room == null) return Task.CompletedTask; Scheduler.Add(() => { if (Room == null) return; Debug.Assert(apiRoom != null); Room.State = state; switch (state) { case MultiplayerRoomState.Open: apiRoom.Status.Value = new RoomStatusOpen(); break; case MultiplayerRoomState.Playing: apiRoom.Status.Value = new RoomStatusPlaying(); break; case MultiplayerRoomState.Closed: apiRoom.Status.Value = new RoomStatusEnded(); break; } RoomUpdated?.Invoke(); }, false); return Task.CompletedTask; } async Task IMultiplayerClient.UserJoined(MultiplayerRoomUser user) { if (Room == null) return; await PopulateUser(user); Scheduler.Add(() => { if (Room == null) return; // for sanity, ensure that there can be no duplicate users in the room user list. if (Room.Users.Any(existing => existing.UserID == user.UserID)) return; Room.Users.Add(user); RoomUpdated?.Invoke(); }, false); } Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user) { if (Room == null) return Task.CompletedTask; Scheduler.Add(() => { if (Room == null) return; Room.Users.Remove(user); CurrentMatchPlayingUserIds.Remove(user.UserID); RoomUpdated?.Invoke(); }, false); return Task.CompletedTask; } Task IMultiplayerClient.HostChanged(int userId) { if (Room == null) return Task.CompletedTask; Scheduler.Add(() => { if (Room == null) return; Debug.Assert(apiRoom != null); var user = Room.Users.FirstOrDefault(u => u.UserID == userId); Room.Host = user; apiRoom.Host.Value = user?.User; RoomUpdated?.Invoke(); }, false); return Task.CompletedTask; } Task IMultiplayerClient.SettingsChanged(MultiplayerRoomSettings newSettings) { updateLocalRoomSettings(newSettings); return Task.CompletedTask; } Task IMultiplayerClient.UserStateChanged(int userId, MultiplayerUserState state) { if (Room == null) return Task.CompletedTask; Scheduler.Add(() => { if (Room == null) return; Room.Users.Single(u => u.UserID == userId).State = state; updateUserPlayingState(userId, state); RoomUpdated?.Invoke(); }, false); return Task.CompletedTask; } Task IMultiplayerClient.UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability) { if (Room == null) return Task.CompletedTask; Scheduler.Add(() => { var user = Room?.Users.SingleOrDefault(u => u.UserID == userId); // errors here are not critical - beatmap availability state is mostly for display. if (user == null) return; user.BeatmapAvailability = beatmapAvailability; RoomUpdated?.Invoke(); }, false); return Task.CompletedTask; } Task IMultiplayerClient.LoadRequested() { if (Room == null) return Task.CompletedTask; Scheduler.Add(() => { if (Room == null) return; LoadRequested?.Invoke(); }, false); return Task.CompletedTask; } Task IMultiplayerClient.MatchStarted() { if (Room == null) return Task.CompletedTask; Scheduler.Add(() => { if (Room == null) return; MatchStarted?.Invoke(); }, false); return Task.CompletedTask; } Task IMultiplayerClient.ResultsReady() { if (Room == null) return Task.CompletedTask; Scheduler.Add(() => { if (Room == null) return; ResultsReady?.Invoke(); }, false); return Task.CompletedTask; } /// /// Populates the for a given . /// /// The to populate. protected async Task PopulateUser(MultiplayerRoomUser multiplayerUser) => multiplayerUser.User ??= await userLookupCache.GetUserAsync(multiplayerUser.UserID); /// /// Retrieve a copy of users currently in the joined in a thread-safe manner. /// This should be used whenever accessing users from outside of an Update thread context (ie. when not calling ). /// /// A copy of users in the current room, or null if unavailable. private Task?> getRoomUsers() { var tcs = new TaskCompletionSource?>(); // at some point we probably want to replace all these schedule calls with Room.LockForUpdate. // for now, as this would require quite some consideration due to the number of accesses to the room instance, // let's just add a manual schedule for the non-scheduled usages instead. Scheduler.Add(() => { var users = Room?.Users.ToList(); tcs.SetResult(users); }, false); return tcs.Task; } /// /// Updates the local room settings with the given . /// /// /// This updates both the joined and the respective API . /// /// The new to update from. private void updateLocalRoomSettings(MultiplayerRoomSettings settings) { if (Room == null) return; Scheduler.Add(() => { if (Room == null) return; Debug.Assert(apiRoom != null); // Update a few properties of the room instantaneously. Room.Settings = settings; apiRoom.Name.Value = Room.Settings.Name; // The playlist update is delayed until an online beatmap lookup (below) succeeds. // In-order for the client to not display an outdated beatmap, the playlist is forcefully cleared here. apiRoom.Playlist.Clear(); RoomUpdated?.Invoke(); var req = new GetBeatmapSetRequest(settings.BeatmapID, BeatmapSetLookupType.BeatmapId); req.Success += res => updatePlaylist(settings, res); api.Queue(req); }, false); } private void updatePlaylist(MultiplayerRoomSettings settings, APIBeatmapSet onlineSet) { if (Room == null || !Room.Settings.Equals(settings)) return; Debug.Assert(apiRoom != null); var beatmapSet = onlineSet.ToBeatmapSet(rulesets); var beatmap = beatmapSet.Beatmaps.Single(b => b.OnlineBeatmapID == settings.BeatmapID); beatmap.MD5Hash = settings.BeatmapChecksum; var ruleset = rulesets.GetRuleset(settings.RulesetID).CreateInstance(); var mods = settings.Mods.Select(m => m.ToMod(ruleset)); PlaylistItem playlistItem = new PlaylistItem { ID = playlistItemId, Beatmap = { Value = beatmap }, Ruleset = { Value = ruleset.RulesetInfo }, }; playlistItem.RequiredMods.AddRange(mods); apiRoom.Playlist.Clear(); // Clearing should be unnecessary, but here for sanity. apiRoom.Playlist.Add(playlistItem); } /// /// For the provided user ID, update whether the user is included in . /// /// The user's ID. /// The new state of the user. private void updateUserPlayingState(int userId, MultiplayerUserState state) { bool wasPlaying = CurrentMatchPlayingUserIds.Contains(userId); bool isPlaying = state >= MultiplayerUserState.WaitingForLoad && state <= MultiplayerUserState.FinishedPlay; if (isPlaying == wasPlaying) return; if (isPlaying) CurrentMatchPlayingUserIds.Add(userId); else CurrentMatchPlayingUserIds.Remove(userId); } } }