// 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.Diagnostics; using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.RoomStatuses; using osu.Game.Rulesets; using osu.Game.Users; using osu.Game.Utils; namespace osu.Game.Online.RealtimeMultiplayer { public abstract class StatefulMultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer { /// /// Invoked when any change occurs to the multiplayer room. /// public event Action? RoomChanged; /// /// 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. /// public abstract IBindable IsConnected { get; } /// /// The joined . /// public MultiplayerRoom? Room { get; private set; } /// /// The users currently in gameplay. /// public readonly BindableList PlayingUsers = new BindableList(); [Resolved] private UserLookupCache userLookupCache { get; set; } = null!; [Resolved] private IAPIProvider api { get; set; } = null!; [Resolved] private RulesetStore rulesets { get; set; } = null!; private Room? apiRoom; private int playlistItemId; // Todo: THIS IS SUPER TEMPORARY!! /// /// Joins the for a given API . /// /// The API . public async Task JoinRoom(Room room) { Debug.Assert(Room == null); 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); foreach (var user in Room.Users) await PopulateUser(user); updateLocalRoomSettings(Room.Settings); } /// /// Joins the with a given ID. /// /// The room ID. /// The joined . protected abstract Task JoinRoom(long roomId); public virtual Task LeaveRoom() { if (Room == null) return Task.CompletedTask; apiRoom = null; Room = null; Schedule(() => RoomChanged?.Invoke()); return Task.CompletedTask; } /// /// Change the current settings. /// /// /// A room must have been joined via for this to have any effect. /// /// The new room name, if any. /// The new room playlist item, if any. public void ChangeSettings(Optional name = default, Optional item = default) { if (Room == null) return; // 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 }; var newSettings = 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!.RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.Mods }; // Make sure there would be a meaningful change in settings. if (newSettings.Equals(Room.Settings)) return; ChangeSettings(newSettings); } public abstract Task TransferHost(int userId); public abstract Task ChangeSettings(MultiplayerRoomSettings settings); public abstract Task ChangeState(MultiplayerUserState newState); public abstract Task StartMatch(); Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state) { Schedule(() => { 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; } RoomChanged?.Invoke(); }); return Task.CompletedTask; } async Task IMultiplayerClient.UserJoined(MultiplayerRoomUser user) { await PopulateUser(user); Schedule(() => { if (Room == null) return; Room.Users.Add(user); RoomChanged?.Invoke(); }); } Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user) { Schedule(() => { if (Room == null) return; Room.Users.Remove(user); PlayingUsers.Remove(user.UserID); RoomChanged?.Invoke(); }); return Task.CompletedTask; } Task IMultiplayerClient.HostChanged(int userId) { Schedule(() => { 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; RoomChanged?.Invoke(); }); return Task.CompletedTask; } Task IMultiplayerClient.SettingsChanged(MultiplayerRoomSettings newSettings) { updateLocalRoomSettings(newSettings); return Task.CompletedTask; } Task IMultiplayerClient.UserStateChanged(int userId, MultiplayerUserState state) { Schedule(() => { if (Room == null) return; Room.Users.Single(u => u.UserID == userId).State = state; if (state != MultiplayerUserState.Playing) PlayingUsers.Remove(userId); RoomChanged?.Invoke(); }); return Task.CompletedTask; } Task IMultiplayerClient.LoadRequested() { Schedule(() => { if (Room == null) return; LoadRequested?.Invoke(); }); return Task.CompletedTask; } Task IMultiplayerClient.MatchStarted() { Debug.Assert(Room != null); var players = Room.Users.Where(u => u.State == MultiplayerUserState.Playing).Select(u => u.UserID).ToList(); Schedule(() => { if (Room == null) return; PlayingUsers.AddRange(players); MatchStarted?.Invoke(); }); return Task.CompletedTask; } Task IMultiplayerClient.ResultsReady() { Schedule(() => { if (Room == null) return; ResultsReady?.Invoke(); }); return Task.CompletedTask; } /// /// Populates the for a given . /// /// The to populate. protected async Task PopulateUser(MultiplayerRoomUser multiplayerUser) => multiplayerUser.User ??= await userLookupCache.GetUserAsync(multiplayerUser.UserID); /// /// 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; // Update a few instantaneously properties of the room. Schedule(() => { if (Room == null) return; Debug.Assert(apiRoom != null); 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(); RoomChanged?.Invoke(); }); var req = new GetBeatmapSetRequest(settings.BeatmapID, BeatmapSetLookupType.BeatmapId); req.Success += res => { var beatmapSet = res.ToBeatmapSet(rulesets); var beatmap = beatmapSet.Beatmaps.Single(b => b.OnlineBeatmapID == settings.BeatmapID); beatmap.MD5Hash = settings.BeatmapChecksum; var ruleset = rulesets.GetRuleset(settings.RulesetID); var mods = settings.Mods.Select(m => m.ToMod(ruleset.CreateInstance())); PlaylistItem playlistItem = new PlaylistItem { ID = playlistItemId, Beatmap = { Value = beatmap }, Ruleset = { Value = ruleset }, }; playlistItem.RequiredMods.AddRange(mods); Schedule(() => { if (Room == null || !Room.Settings.Equals(settings)) return; Debug.Assert(apiRoom != null); apiRoom.Playlist.Clear(); // Clearing should be unnecessary, but here for sanity. apiRoom.Playlist.Add(playlistItem); }); }; api.Queue(req); } } }