Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ppy
GitHub Repository: ppy/osu
Path: blob/master/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
4334 views
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using MessagePack;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Game.Beatmaps;
using osu.Game.Online;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Matchmaking;
using osu.Game.Online.Matchmaking.Events;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.Countdown;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Mods;
using osu.Game.Tests.Visual.OnlinePlay;

namespace osu.Game.Tests.Visual.Multiplayer
{
    /// <summary>
    /// A <see cref="MultiplayerClient"/> for use in multiplayer test scenes. Should generally not be used by itself outside of a <see cref="MultiplayerTestScene"/>.
    /// </summary>
    public partial class TestMultiplayerClient : MultiplayerClient
    {
        public override IBindable<bool> IsConnected => isConnected;
        private readonly Bindable<bool> isConnected = new Bindable<bool>(true);

        /// <summary>
        /// The local client's <see cref="Room"/>. This is not always equivalent to the server-side room.
        /// </summary>
        public Room? ClientAPIRoom => base.APIRoom;

        /// <summary>
        /// The local client's <see cref="MultiplayerRoom"/>. This is not always equivalent to the server-side room.
        /// </summary>
        public MultiplayerRoom? ClientRoom => base.Room;

        /// <summary>
        /// The server's <see cref="Room"/>. This is always up-to-date.
        /// </summary>
        public Room? ServerAPIRoom { get; private set; }

        /// <summary>
        /// The server's <see cref="MultiplayerRoom"/>. This is always up-to-date.
        /// </summary>
        public MultiplayerRoom? ServerRoom { get; private set; }

        [Obsolete]
        protected new Room APIRoom => throw new InvalidOperationException($"Accessing the client-side API room via {nameof(TestMultiplayerClient)} is unsafe. "
                                                                          + $"Use {nameof(ClientAPIRoom)} if this was intended.");

        [Obsolete]
        public new MultiplayerRoom Room => throw new InvalidOperationException($"Accessing the client-side room via {nameof(TestMultiplayerClient)} is unsafe. "
                                                                               + $"Use {nameof(ClientRoom)} if this was intended.");

        public new MultiplayerRoomUser? LocalUser => ServerRoom?.Users.SingleOrDefault(u => u.User?.Id == API.LocalUser.Value.Id);

        public Action<MultiplayerRoom>? RoomSetupAction;

        public bool RoomJoined { get; private set; }

        [Resolved]
        private IAPIProvider api { get; set; } = null!;

        private MultiplayerPlaylistItem? currentItem => ServerRoom?.Playlist[currentIndex];
        private int currentIndex;
        private long lastPlaylistItemId;
        private int lastCountdownId;

        private readonly Dictionary<int, long> matchmakingUserPicks = new Dictionary<int, long>();

        private readonly TestRoomRequestsHandler apiRequestHandler;

        public TestMultiplayerClient(TestRoomRequestsHandler? apiRequestHandler = null)
        {
            this.apiRequestHandler = apiRequestHandler ?? new TestRoomRequestsHandler();
        }

        public void Connect() => isConnected.Value = true;

        public void Disconnect() => isConnected.Value = false;

        public MultiplayerRoomUser AddUser(APIUser user, bool markAsPlaying = false)
            => AddUser(new MultiplayerRoomUser(user.Id) { User = user }, markAsPlaying);

        public MultiplayerRoomUser AddUser(MultiplayerRoomUser roomUser, bool markAsPlaying = false)
        {
            addUser(roomUser);

            if (markAsPlaying)
                PlayingUserIds.Add(roomUser.UserID);

            return roomUser;
        }

        public void TestAddUnresolvedUser() => addUser(new MultiplayerRoomUser(TestUserLookupCache.UNRESOLVED_USER_ID));

        private void addUser(MultiplayerRoomUser user)
        {
            Debug.Assert(ServerRoom != null);

            ServerRoom.Users.Add(user);
            ((IMultiplayerClient)this).UserJoined(clone(user)).WaitSafely();

            switch (ServerRoom?.MatchState)
            {
                case TeamVersusRoomState teamVersus:
                    // simulate the server's automatic assignment of users to teams on join.
                    // the "best" team is the one with the least users on it.
                    int bestTeam = teamVersus.Teams
                                             .Select(team => (teamID: team.ID, userCount: ServerRoom.Users.Count(u => (u.MatchState as TeamVersusUserState)?.TeamID == team.ID)))
                                             .MinBy(pair => pair.userCount).teamID;

                    user.MatchState = new TeamVersusUserState { TeamID = bestTeam };
                    ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).WaitSafely();
                    break;
            }
        }

        public void RemoveUser(APIUser user)
        {
            Debug.Assert(ServerRoom != null);

            ServerRoom.Users.Remove(ServerRoom.Users.Single(u => u.UserID == user.Id));
            ((IMultiplayerClient)this).UserLeft(clone(new MultiplayerRoomUser(user.Id)));

            if (ServerRoom.Users.Any())
                TransferHost(ServerRoom.Users.First().UserID);
        }

        public void ChangeRoomState(MultiplayerRoomState newState)
        {
            Debug.Assert(ServerRoom != null);

            ServerRoom.State = clone(newState);

            ((IMultiplayerClient)this).RoomStateChanged(clone(ServerRoom.State));
        }

        public void ChangeUserState(int userId, MultiplayerUserState newState)
        {
            Debug.Assert(ServerRoom != null);

            var user = ServerRoom.Users.Single(u => u.UserID == userId);
            user.State = clone(newState);

            ((IMultiplayerClient)this).UserStateChanged(clone(userId), clone(user.State));

            updateRoomStateIfRequired();
        }

        private void updateRoomStateIfRequired()
        {
            Debug.Assert(ServerRoom != null);

            switch (ServerRoom.State)
            {
                case MultiplayerRoomState.Open:
                    break;

                case MultiplayerRoomState.WaitingForLoad:
                    if (ServerRoom.Users.All(u => u.State != MultiplayerUserState.WaitingForLoad))
                    {
                        var loadedUsers = ServerRoom.Users.Where(u => u.State == MultiplayerUserState.Loaded).ToArray();

                        if (loadedUsers.Length == 0)
                        {
                            // all users have bailed from the load sequence. cancel the game start.
                            ChangeRoomState(MultiplayerRoomState.Open);
                            return;
                        }

                        foreach (var u in ServerRoom.Users.Where(u => u.State == MultiplayerUserState.Loaded))
                            ChangeUserState(u.UserID, MultiplayerUserState.Playing);

                        ((IMultiplayerClient)this).GameplayStarted();

                        ChangeRoomState(MultiplayerRoomState.Playing);
                    }

                    break;

                case MultiplayerRoomState.Playing:
                    if (ServerRoom.Users.All(u => u.State != MultiplayerUserState.Playing))
                    {
                        foreach (var u in ServerRoom.Users.Where(u => u.State == MultiplayerUserState.FinishedPlay))
                            ChangeUserState(u.UserID, MultiplayerUserState.Results);

                        ChangeRoomState(MultiplayerRoomState.Open);
                        ((IMultiplayerClient)this).ResultsReady();

                        FinishCurrentItem().WaitSafely();
                    }

                    break;
            }
        }

        public void ChangeUserBeatmapAvailability(int userId, BeatmapAvailability newBeatmapAvailability)
        {
            Debug.Assert(ServerRoom != null);

            var user = ServerRoom.Users.Single(u => u.UserID == userId);
            user.BeatmapAvailability = newBeatmapAvailability;

            ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged(clone(userId), clone(user.BeatmapAvailability));
        }

        protected override async Task<MultiplayerRoom> JoinRoomInternal(long roomId, string? password = null)
        {
            if (RoomJoined || ServerAPIRoom != null)
                throw new InvalidOperationException("Already joined a room");

            roomId = clone(roomId);
            password = clone(password);

            ServerAPIRoom = ServerSideRooms.Single(r => r.RoomID == roomId);

            if (password != ServerAPIRoom.Password)
                throw new InvalidOperationException("Invalid password.");

            lastPlaylistItemId = ServerAPIRoom.Playlist.Max(item => item.ID);

            var localUser = new MultiplayerRoomUser(api.LocalUser.Value.Id)
            {
                User = api.LocalUser.Value
            };

            ServerRoom = new MultiplayerRoom(roomId)
            {
                Settings =
                {
                    Name = ServerAPIRoom.Name,
                    MatchType = ServerAPIRoom.Type,
                    Password = password ?? string.Empty,
                    QueueMode = ServerAPIRoom.QueueMode,
                    AutoStartDuration = ServerAPIRoom.AutoStartDuration
                },
                Playlist = ServerAPIRoom.Playlist.Select(item => new MultiplayerPlaylistItem(item)).ToList(),
                Users = { localUser },
                Host = localUser
            };

            await changeMatchType(ServerRoom.Settings.MatchType).ConfigureAwait(false);
            await updatePlaylistOrder(ServerRoom).ConfigureAwait(false);
            await updateCurrentItem(ServerRoom, false).ConfigureAwait(false);

            RoomSetupAction?.Invoke(ServerRoom);
            RoomSetupAction = null;

            return clone(ServerRoom);
        }

        protected override void OnRoomJoined()
        {
            Debug.Assert(ServerRoom != null);
            RoomJoined = true;
        }

        protected override Task LeaveRoomInternal()
        {
            RoomJoined = false;
            ServerAPIRoom = null;
            ServerRoom = null;
            return Task.CompletedTask;
        }

        public override Task InvitePlayer(int userId)
        {
            return Task.CompletedTask;
        }

        public override Task TransferHost(int userId)
        {
            userId = clone(userId);

            Debug.Assert(ServerRoom != null);

            ServerRoom.Host = ServerRoom.Users.Single(u => u.UserID == userId);

            return ((IMultiplayerClient)this).HostChanged(clone(userId));
        }

        public override Task KickUser(int userId)
        {
            userId = clone(userId);

            Debug.Assert(ServerRoom != null);

            var user = ServerRoom.Users.Single(u => u.UserID == userId);
            ServerRoom.Users.Remove(user);

            return ((IMultiplayerClient)this).UserKicked(clone(user));
        }

        /// <summary>
        /// Simulates a change to the server-side room's settings without any other change.
        /// </summary>
        public async Task ChangeServerRoomSettings(MultiplayerRoomSettings settings)
        {
            Debug.Assert(ServerRoom != null);

            ServerRoom.Settings = settings;

            await ((IMultiplayerClient)this).SettingsChanged(clone(settings)).ConfigureAwait(false);
        }

        public override async Task ChangeSettings(MultiplayerRoomSettings settings)
        {
            Debug.Assert(ServerRoom != null);

            // Server is authoritative for the time being.
            settings = clone(settings);
            settings.PlaylistItemId = ServerRoom.Settings.PlaylistItemId;
            ServerRoom.Settings = settings;

            await changeQueueMode(settings.QueueMode).ConfigureAwait(false);

            await ((IMultiplayerClient)this).SettingsChanged(clone(settings)).ConfigureAwait(false);

            foreach (var user in ServerRoom.Users.Where(u => u.State == MultiplayerUserState.Ready))
                ChangeUserState(user.UserID, MultiplayerUserState.Idle);

            await changeMatchType(settings.MatchType).ConfigureAwait(false);
            updateRoomStateIfRequired();
        }

        public override Task ChangeState(MultiplayerUserState newState)
        {
            newState = clone(newState);

            if (newState == MultiplayerUserState.Idle && LocalUser?.State == MultiplayerUserState.WaitingForLoad)
                return Task.CompletedTask;

            ChangeUserState(api.LocalUser.Value.Id, clone(newState));
            return Task.CompletedTask;
        }

        public override Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability)
        {
            ChangeUserBeatmapAvailability(api.LocalUser.Value.Id, clone(newBeatmapAvailability));
            return Task.CompletedTask;
        }

        public override Task ChangeUserStyle(int? beatmapId, int? rulesetId)
        {
            ChangeUserStyle(api.LocalUser.Value.Id, beatmapId, rulesetId);
            return Task.CompletedTask;
        }

        public void ChangeUserStyle(int userId, int? beatmapId, int? rulesetId)
        {
            Debug.Assert(ServerRoom != null);

            var user = ServerRoom.Users.Single(u => u.UserID == userId);
            user.BeatmapId = beatmapId;
            user.RulesetId = rulesetId;

            ((IMultiplayerClient)this).UserStyleChanged(userId, beatmapId, rulesetId);
        }

        public void ChangeUserMods(int userId, IEnumerable<Mod> newMods)
            => ChangeUserMods(userId, newMods.Select(m => new APIMod(m)));

        public void ChangeUserMods(int userId, IEnumerable<APIMod> newMods)
        {
            Debug.Assert(ServerRoom != null);

            var user = ServerRoom.Users.Single(u => u.UserID == userId);
            user.Mods = newMods.ToArray();

            ((IMultiplayerClient)this).UserModsChanged(clone(userId), clone(user.Mods));
        }

        public override Task ChangeUserMods(IEnumerable<APIMod> newMods)
        {
            ChangeUserMods(api.LocalUser.Value.Id, clone(newMods));
            return Task.CompletedTask;
        }

        public override Task SendMatchRequest(MatchUserRequest request)
            => SendUserMatchRequest(api.LocalUser.Value.OnlineID, request);

        public async Task SendUserMatchRequest(int userId, MatchUserRequest request)
        {
            request = clone(request);

            Debug.Assert(ServerRoom != null);
            Debug.Assert(LocalUser != null);

            switch (request)
            {
                case ChangeTeamRequest changeTeam:
                    TeamVersusRoomState roomState = (TeamVersusRoomState)ServerRoom.MatchState!;
                    TeamVersusUserState userState = (TeamVersusUserState)LocalUser.MatchState!;

                    var targetTeam = roomState.Teams.FirstOrDefault(t => t.ID == changeTeam.TeamID);

                    if (targetTeam != null)
                    {
                        userState.TeamID = targetTeam.ID;
                        await ((IMultiplayerClient)this).MatchUserStateChanged(userId, clone(userState)).ConfigureAwait(false);
                    }

                    break;

                case StartMatchCountdownRequest startCountdown:
                    await StartCountdown(new MatchStartCountdown { TimeRemaining = startCountdown.Duration }).ConfigureAwait(false);
                    break;

                case StopCountdownRequest stopCountdown:
                    await StopCountdown(ServerRoom.ActiveCountdowns.First(c => c.ID == stopCountdown.ID)).ConfigureAwait(false);
                    break;

                case MatchmakingAvatarActionRequest avatarAction:
                    await ((IMultiplayerClient)this).MatchEvent(new MatchmakingAvatarActionEvent
                    {
                        UserId = userId,
                        Action = avatarAction.Action
                    }).ConfigureAwait(false);
                    break;
            }
        }

        public async Task StartCountdown(MultiplayerCountdown countdown)
        {
            countdown.ID = ++lastCountdownId;
            countdown = clone(countdown);

            Debug.Assert(ServerRoom != null);
            Debug.Assert(LocalUser != null);

            if (countdown.IsExclusive)
            {
                MultiplayerCountdown? existingCountdown = ServerRoom.ActiveCountdowns.FirstOrDefault(c => c.GetType() == countdown.GetType());
                if (existingCountdown != null)
                    await StopCountdown(existingCountdown).ConfigureAwait(false);
            }

            ServerRoom.ActiveCountdowns.Add(countdown);
            await ((IMultiplayerClient)this).MatchEvent(clone(new CountdownStartedEvent(ServerRoom.ActiveCountdowns[^1]))).ConfigureAwait(false);
        }

        public async Task StopCountdown(MultiplayerCountdown countdown)
        {
            Debug.Assert(ServerRoom != null);
            Debug.Assert(LocalUser != null);

            ServerRoom.ActiveCountdowns.Remove(ServerRoom.ActiveCountdowns.First(c => c.ID == countdown.ID));
            await ((IMultiplayerClient)this).MatchEvent(clone(new CountdownStoppedEvent(countdown.ID))).ConfigureAwait(false);
        }

        public override Task StartMatch()
        {
            Debug.Assert(ServerRoom != null);

            ChangeRoomState(MultiplayerRoomState.WaitingForLoad);
            foreach (var user in ServerRoom.Users.Where(u => u.State == MultiplayerUserState.Ready))
                ChangeUserState(user.UserID, MultiplayerUserState.WaitingForLoad);

            return ((IMultiplayerClient)this).LoadRequested();
        }

        public override Task AbortGameplay()
        {
            Debug.Assert(LocalUser != null);

            ChangeUserState(LocalUser.UserID, MultiplayerUserState.Idle);

            return Task.CompletedTask;
        }

        public override async Task AbortMatch()
        {
            ChangeUserState(api.LocalUser.Value.Id, MultiplayerUserState.Idle);
            await ((IMultiplayerClient)this).GameplayAborted(GameplayAbortReason.HostAbortedTheMatch).ConfigureAwait(false);
        }

        public async Task AddUserPlaylistItem(int userId, MultiplayerPlaylistItem item)
        {
            Debug.Assert(ServerRoom != null);
            Debug.Assert(currentItem != null);

            if (ServerRoom.Settings.QueueMode == QueueMode.HostOnly && ServerRoom.Host?.UserID != LocalUser?.UserID)
                throw new InvalidOperationException("Local user is not the room host.");

            item.OwnerID = userId;

            await addItem(item).ConfigureAwait(false);
            await updateCurrentItem(ServerRoom).ConfigureAwait(false);
            updateRoomStateIfRequired();
        }

        public override Task AddPlaylistItem(MultiplayerPlaylistItem item) => AddUserPlaylistItem(api.LocalUser.Value.OnlineID, clone(item));

        public async Task EditUserPlaylistItem(int userId, MultiplayerPlaylistItem item)
        {
            Debug.Assert(ServerRoom != null);
            Debug.Assert(currentItem != null);
            Debug.Assert(ServerAPIRoom != null);

            item.OwnerID = userId;

            var existingItem = ServerRoom.Playlist.SingleOrDefault(i => i.ID == item.ID);

            if (existingItem == null)
                throw new InvalidOperationException("Attempted to change an item that doesn't exist.");

            if (existingItem.OwnerID != userId && ServerRoom.Host?.UserID != LocalUser?.UserID)
                throw new InvalidOperationException("Attempted to change an item which is not owned by the user.");

            if (existingItem.Expired)
                throw new InvalidOperationException("Attempted to change an item which has already been played.");

            // Ensure the playlist order doesn't change.
            item.PlaylistOrder = existingItem.PlaylistOrder;

            ServerRoom.Playlist[ServerRoom.Playlist.IndexOf(existingItem)] = item;
            ServerAPIRoom.Playlist = ServerAPIRoom.Playlist.Select((pi, i) => pi.ID == item.ID ? new PlaylistItem(item) : ServerAPIRoom.Playlist[i]).ToArray();

            await ((IMultiplayerClient)this).PlaylistItemChanged(clone(item)).ConfigureAwait(false);
        }

        public override Task EditPlaylistItem(MultiplayerPlaylistItem item) => EditUserPlaylistItem(api.LocalUser.Value.OnlineID, clone(item));

        public async Task RemoveUserPlaylistItem(int userId, long playlistItemId)
        {
            Debug.Assert(ServerRoom != null);
            Debug.Assert(ServerAPIRoom != null);

            var item = ServerRoom.Playlist.FirstOrDefault(i => i.ID == playlistItemId);

            if (item == null)
                throw new InvalidOperationException("Item does not exist in the room.");

            if (item.Equals(currentItem))
                throw new InvalidOperationException("The room's current item cannot be removed.");

            if (item.OwnerID != userId)
                throw new InvalidOperationException("Attempted to remove an item which is not owned by the user.");

            if (item.Expired)
                throw new InvalidOperationException("Attempted to remove an item which has already been played.");

            ServerRoom.Playlist.Remove(item);
            ServerAPIRoom.Playlist = ServerAPIRoom.Playlist.Where(i => i.ID != item.ID).ToArray();
            await ((IMultiplayerClient)this).PlaylistItemRemoved(clone(playlistItemId)).ConfigureAwait(false);

            await updateCurrentItem(ServerRoom).ConfigureAwait(false);
            updateRoomStateIfRequired();
        }

        public override Task RemovePlaylistItem(long playlistItemId) => RemoveUserPlaylistItem(api.LocalUser.Value.OnlineID, clone(playlistItemId));

        public override Task VoteToSkipIntro()
        {
            return UserVoteToSkipIntro(api.LocalUser.Value.OnlineID);
        }

        public async Task UserVoteToSkipIntro(int userId)
        {
            await ((IMultiplayerClient)this).UserVotedToSkipIntro(userId, true).ConfigureAwait(false);
        }

        protected override Task<MultiplayerRoom> CreateRoomInternal(MultiplayerRoom room)
        {
            Room apiRoom = new Room(room)
            {
                Type = room.Settings.MatchType == MatchType.Playlists
                    ? MatchType.HeadToHead
                    : room.Settings.MatchType
            };

            AddServerSideRoom(apiRoom, api.LocalUser.Value);
            return JoinRoomInternal(apiRoom.RoomID!.Value, room.Settings.Password);
        }

        private async Task changeMatchType(MatchType type)
        {
            Debug.Assert(ServerRoom != null);

            switch (type)
            {
                case MatchType.HeadToHead:
                    ServerRoom.MatchState = null;
                    await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false);

                    foreach (var user in ServerRoom.Users)
                    {
                        user.MatchState = null;
                        await ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).ConfigureAwait(false);
                    }

                    break;

                case MatchType.TeamVersus:
                    ServerRoom.MatchState = TeamVersusRoomState.CreateDefault();
                    await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false);

                    foreach (var user in ServerRoom.Users)
                    {
                        user.MatchState = new TeamVersusUserState();
                        await ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).ConfigureAwait(false);
                    }

                    break;

                case MatchType.Matchmaking:
                    ServerRoom.MatchState = new MatchmakingRoomState();
                    await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false);

                    foreach (var user in ServerRoom.Users)
                    {
                        user.MatchState = null;
                        await ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).ConfigureAwait(false);
                    }

                    break;
            }
        }

        private async Task changeQueueMode(QueueMode newMode)
        {
            Debug.Assert(ServerRoom != null);
            Debug.Assert(currentItem != null);

            // When changing to host-only mode, ensure that at least one non-expired playlist item exists by duplicating the current item.
            if (newMode == QueueMode.HostOnly && ServerRoom.Playlist.All(item => item.Expired))
                await duplicateCurrentItem().ConfigureAwait(false);

            await updatePlaylistOrder(ServerRoom).ConfigureAwait(false);
            await updateCurrentItem(ServerRoom).ConfigureAwait(false);
        }

        public async Task FinishCurrentItem()
        {
            Debug.Assert(ServerRoom != null);
            Debug.Assert(currentItem != null);

            // Expire the current playlist item.
            currentItem.Expired = true;
            currentItem.PlayedAt = DateTimeOffset.Now;

            await ((IMultiplayerClient)this).PlaylistItemChanged(clone(currentItem)).ConfigureAwait(false);
            await updatePlaylistOrder(ServerRoom).ConfigureAwait(false);

            // In host-only mode, a duplicate playlist item will be used for the next round.
            if (ServerRoom.Settings.QueueMode == QueueMode.HostOnly && ServerRoom.Playlist.All(item => item.Expired))
                await duplicateCurrentItem().ConfigureAwait(false);

            await updateCurrentItem(ServerRoom).ConfigureAwait(false);
        }

        private async Task duplicateCurrentItem()
        {
            Debug.Assert(currentItem != null);

            await addItem(new MultiplayerPlaylistItem
            {
                BeatmapID = currentItem.BeatmapID,
                BeatmapChecksum = currentItem.BeatmapChecksum,
                RulesetID = currentItem.RulesetID,
                RequiredMods = currentItem.RequiredMods,
                AllowedMods = currentItem.AllowedMods
            }).ConfigureAwait(false);
        }

        private async Task addItem(MultiplayerPlaylistItem item)
        {
            Debug.Assert(ServerRoom != null);
            Debug.Assert(ServerAPIRoom != null);

            item.ID = ++lastPlaylistItemId;

            ServerRoom.Playlist.Add(item);
            ServerAPIRoom.Playlist = ServerAPIRoom.Playlist.Append(new PlaylistItem(item)).ToArray();
            await ((IMultiplayerClient)this).PlaylistItemAdded(clone(item)).ConfigureAwait(false);

            await updatePlaylistOrder(ServerRoom).ConfigureAwait(false);
        }

        private IEnumerable<MultiplayerPlaylistItem> upcomingItems => ServerRoom?.Playlist.Where(i => !i.Expired).OrderBy(i => i.PlaylistOrder) ?? Enumerable.Empty<MultiplayerPlaylistItem>();

        private async Task updateCurrentItem(MultiplayerRoom room, bool notify = true)
        {
            Debug.Assert(ServerRoom != null);

            // Pick the next non-expired playlist item by playlist order, or default to the most-recently-expired item.
            MultiplayerPlaylistItem nextItem = upcomingItems.FirstOrDefault() ?? ServerRoom.Playlist.OrderByDescending(i => i.PlayedAt).First();

            currentIndex = ServerRoom.Playlist.IndexOf(nextItem);

            long lastItem = room.Settings.PlaylistItemId;
            room.Settings.PlaylistItemId = nextItem.ID;

            if (notify && nextItem.ID != lastItem)
                await ((IMultiplayerClient)this).SettingsChanged(clone(room.Settings)).ConfigureAwait(false);
        }

        private async Task updatePlaylistOrder(MultiplayerRoom room)
        {
            Debug.Assert(ServerRoom != null);
            Debug.Assert(ServerAPIRoom != null);

            List<MultiplayerPlaylistItem> orderedActiveItems;

            switch (room.Settings.QueueMode)
            {
                default:
                    orderedActiveItems = ServerRoom.Playlist.Where(item => !item.Expired).OrderBy(item => item.ID).ToList();
                    break;

                case QueueMode.AllPlayersRoundRobin:
                    var itemsByPriority = new List<(MultiplayerPlaylistItem item, int priority)>();

                    // Assign a priority for items from each user, starting from 0 and increasing in order which the user added the items.
                    foreach (var group in ServerRoom.Playlist.Where(item => !item.Expired).OrderBy(item => item.ID).GroupBy(item => item.OwnerID))
                    {
                        int priority = 0;
                        itemsByPriority.AddRange(group.Select(item => (item, priority++)));
                    }

                    orderedActiveItems = itemsByPriority
                                         // Order by each user's priority.
                                         .OrderBy(i => i.priority)
                                         // Many users will have the same priority of items, so attempt to break the tie by maintaining previous ordering.
                                         // Suppose there are two users: User1 and User2. User1 adds two items, and then User2 adds a third. If the previous order is not maintained,
                                         // then after playing the first item by User1, their second item will become priority=0 and jump to the front of the queue (because it was added first).
                                         .ThenBy(i => i.item.PlaylistOrder)
                                         // If there are still ties (normally shouldn't happen), break ties by making items added earlier go first.
                                         // This could happen if e.g. the item orders get reset.
                                         .ThenBy(i => i.item.ID)
                                         .Select(i => i.item)
                                         .ToList();

                    break;
            }

            for (int i = 0; i < orderedActiveItems.Count; i++)
            {
                var item = orderedActiveItems[i];

                if (item.PlaylistOrder == i)
                    continue;

                item.PlaylistOrder = (ushort)i;

                await ((IMultiplayerClient)this).PlaylistItemChanged(clone(item)).ConfigureAwait(false);
            }

            // Also ensure that the API room's playlist is correct.
            foreach (var item in ServerAPIRoom.Playlist)
                item.PlaylistOrder = ServerRoom.Playlist.Single(i => i.ID == item.ID).PlaylistOrder;
        }

        private T clone<T>(T incoming)
        {
            byte[] serialized = MessagePackSerializer.Serialize(typeof(T), incoming, SignalRUnionWorkaroundResolver.OPTIONS);
            return MessagePackSerializer.Deserialize<T>(serialized, SignalRUnionWorkaroundResolver.OPTIONS);
        }

        public override Task DisconnectInternal()
        {
            isConnected.Value = false;
            return Task.CompletedTask;
        }

        public async Task ChangeMatchRoomState(MatchRoomState state)
        {
            Debug.Assert(ServerRoom != null);

            ServerRoom.MatchState = state;
            await ((IMultiplayerClient)this).MatchRoomStateChanged(clone(ServerRoom.MatchState)).ConfigureAwait(false);
        }

        public override Task<MatchmakingPool[]> GetMatchmakingPoolsOfType(MatchmakingPoolType type)
        {
            return Task.FromResult<MatchmakingPool[]>(
            [
                new MatchmakingPool { Id = 0, RulesetId = 0 },
                new MatchmakingPool { Id = 1, RulesetId = 1 },
                new MatchmakingPool { Id = 2, RulesetId = 2 },
                new MatchmakingPool { Id = 3, RulesetId = 3, Variant = 4 },
                new MatchmakingPool { Id = 4, RulesetId = 3, Variant = 7 },
            ]);
        }

        public override Task MatchmakingJoinLobby()
        {
            return Task.CompletedTask;
        }

        public override Task MatchmakingLeaveLobby()
        {
            return Task.CompletedTask;
        }

        public override async Task MatchmakingJoinQueue(int poolId)
        {
            await ((IMatchmakingClient)this).MatchmakingQueueJoined().ConfigureAwait(false);
            await ((IMatchmakingClient)this).MatchmakingQueueStatusChanged(new MatchmakingQueueStatus.Searching()).ConfigureAwait(false);
        }

        public override async Task MatchmakingLeaveQueue()
        {
            await ((IMatchmakingClient)this).MatchmakingQueueLeft().ConfigureAwait(false);
        }

        public override Task MatchmakingAcceptInvitation()
        {
            return Task.CompletedTask;
        }

        public override Task MatchmakingDeclineInvitation()
        {
            return Task.CompletedTask;
        }

        public override Task MatchmakingToggleSelection(long playlistItemId)
            => MatchmakingToggleUserSelection(api.LocalUser.Value.OnlineID, playlistItemId);

        public override Task MatchmakingSkipToNextStage()
            => Task.CompletedTask;

        public async Task MatchmakingToggleUserSelection(int userId, long playlistItemId)
        {
            if (matchmakingUserPicks.TryGetValue(userId, out long existingId))
            {
                if (existingId == playlistItemId)
                    return;

                await ((IMatchmakingClient)this).MatchmakingItemDeselected(clone(userId), clone(existingId)).ConfigureAwait(false);
            }

            matchmakingUserPicks[userId] = playlistItemId;

            await ((IMatchmakingClient)this).MatchmakingItemSelected(clone(userId), clone(playlistItemId)).ConfigureAwait(false);
        }

        public async Task MatchmakingChangeStage(MatchmakingStage stage, Action<MatchmakingRoomState>? prepare = null)
        {
            MatchmakingRoomState state = clone((MatchmakingRoomState)ServerRoom!.MatchState!);

            state.Stage = stage;

            if (stage == MatchmakingStage.RoundWarmupTime)
                state.CurrentRound++;

            prepare?.Invoke(state);

            await ChangeMatchRoomState(state).ConfigureAwait(false);
            await StartCountdown(new MatchmakingStageCountdown
            {
                Stage = stage,
                TimeRemaining = TimeSpan.FromSeconds(stage == MatchmakingStage.UserBeatmapSelect ? 30 : 10)
            }).ConfigureAwait(false);
        }

        #region API Room Handling

        public IReadOnlyList<Room> ServerSideRooms
            => apiRequestHandler.ServerSideRooms;

        public void AddServerSideRoom(Room room, APIUser host)
            => apiRequestHandler.AddServerSideRoom(room, host);

        public bool HandleRequest(APIRequest request, APIUser localUser, BeatmapManager beatmapManager)
            => apiRequestHandler.HandleRequest(request, localUser, beatmapManager);

        #endregion
    }
}