// 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.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.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; // 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) { Logger.Log("Connection to multiplayer server was lost.", LoggingTarget.Runtime, LogLevel.Important); LeaveRoom().FireAndForget(); } }); } /// /// 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); 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 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 }); } 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) { if (Room == null) return Task.CompletedTask; 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) { if (Room == null) return; await PopulateUser(user); Schedule(() => { if (Room == null) return; Room.Users.Add(user); RoomChanged?.Invoke(); }); } Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user) { if (Room == null) return Task.CompletedTask; Schedule(() => { if (Room == null) return; Room.Users.Remove(user); PlayingUsers.Remove(user.UserID); RoomChanged?.Invoke(); }); return Task.CompletedTask; } Task IMultiplayerClient.HostChanged(int userId) { if (Room == null) return Task.CompletedTask; 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) { if (Room == null) return Task.CompletedTask; 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() { if (Room == null) return Task.CompletedTask; Schedule(() => { if (Room == null) return; LoadRequested?.Invoke(); }); return Task.CompletedTask; } Task IMultiplayerClient.MatchStarted() { if (Room == null) return Task.CompletedTask; Schedule(() => { if (Room == null) return; PlayingUsers.AddRange(Room.Users.Where(u => u.State == MultiplayerUserState.Playing).Select(u => u.UserID)); MatchStarted?.Invoke(); }); return Task.CompletedTask; } Task IMultiplayerClient.ResultsReady() { if (Room == null) return Task.CompletedTask; 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 properties of the room instantaneously. 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 => updatePlaylist(settings, res); api.Queue(req); } 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); } } }