diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 82a8212a94..2831c94429 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -45,7 +45,7 @@ namespace osu.Game.Tests.Visual.Multiplayer /// /// Guaranteed up-to-date playlist. /// - private readonly List serverSidePlaylist = new List(); + private List serverSidePlaylist = new List(); private MultiplayerPlaylistItem? currentItem => Room?.Playlist[currentIndex]; private int currentIndex; @@ -189,6 +189,7 @@ namespace osu.Game.Tests.Visual.Multiplayer Host = localUser }; + await updatePlaylistOrder(room).ConfigureAwait(false); await updateCurrentItem(room, false).ConfigureAwait(false); RoomSetupAction?.Invoke(room); @@ -308,12 +309,14 @@ namespace osu.Game.Tests.Visual.Multiplayer if (Room.Settings.QueueMode == QueueMode.HostOnly && Room.Host?.UserID != LocalUser?.UserID) throw new InvalidOperationException("Local user is not the room host."); + item.OwnerID = userId; + switch (Room.Settings.QueueMode) { case QueueMode.HostOnly: // In host-only mode, the current item is re-used. item.ID = currentItem.ID; - item.OwnerID = currentItem.OwnerID; + item.GameplayOrder = currentItem.GameplayOrder; serverSidePlaylist[currentIndex] = item; await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false); @@ -323,12 +326,9 @@ namespace osu.Game.Tests.Visual.Multiplayer break; default: - item.ID = serverSidePlaylist.Last().ID + 1; - item.OwnerID = userId; - - serverSidePlaylist.Add(item); - await ((IMultiplayerClient)this).PlaylistItemAdded(item).ConfigureAwait(false); + await addItem(item).ConfigureAwait(false); + // The current item can change as a result of an item being added. For example, if all items earlier in the queue were expired. await updateCurrentItem(Room).ConfigureAwait(false); break; } @@ -385,7 +385,7 @@ namespace osu.Game.Tests.Visual.Multiplayer if (newMode == QueueMode.HostOnly && serverSidePlaylist.All(item => item.Expired)) await duplicateCurrentItem().ConfigureAwait(false); - // When changing modes, items could have been added (above) or the queueing order could have changed. + await updatePlaylistOrder(Room).ConfigureAwait(false); await updateCurrentItem(Room).ConfigureAwait(false); } @@ -408,47 +408,99 @@ namespace osu.Game.Tests.Visual.Multiplayer private async Task duplicateCurrentItem() { - Debug.Assert(Room != null); - Debug.Assert(APIRoom != null); Debug.Assert(currentItem != null); - var newItem = new MultiplayerPlaylistItem + await addItem(new MultiplayerPlaylistItem { - ID = serverSidePlaylist.Last().ID + 1, BeatmapID = currentItem.BeatmapID, BeatmapChecksum = currentItem.BeatmapChecksum, RulesetID = currentItem.RulesetID, RequiredMods = currentItem.RequiredMods, AllowedMods = currentItem.AllowedMods - }; + }).ConfigureAwait(false); + } - serverSidePlaylist.Add(newItem); - await ((IMultiplayerClient)this).PlaylistItemAdded(newItem).ConfigureAwait(false); + private async Task addItem(MultiplayerPlaylistItem item) + { + Debug.Assert(Room != null); + + // Add the item to the list first in order to compute gameplay order. + serverSidePlaylist.Add(item); + await updatePlaylistOrder(Room).ConfigureAwait(false); + + item.ID = serverSidePlaylist[^2].ID + 1; + await ((IMultiplayerClient)this).PlaylistItemAdded(item).ConfigureAwait(false); } private async Task updateCurrentItem(MultiplayerRoom room, bool notify = true) { - MultiplayerPlaylistItem newItem; + // The playlist is already in correct gameplay order, so pick the next non-expired item or default to the last item. + MultiplayerPlaylistItem nextItem = serverSidePlaylist.FirstOrDefault(i => !i.Expired) ?? room.Playlist.Last(); + currentIndex = serverSidePlaylist.IndexOf(nextItem); + + long lastItem = room.Settings.PlaylistItemId; + room.Settings.PlaylistItemId = nextItem.ID; + + if (notify && nextItem.ID != lastItem) + await ((IMultiplayerClient)this).SettingsChanged(room.Settings).ConfigureAwait(false); + } + + private async Task updatePlaylistOrder(MultiplayerRoom room) + { + List orderedItems; switch (room.Settings.QueueMode) { default: - // Pick the single non-expired playlist item. - newItem = serverSidePlaylist.FirstOrDefault(i => !i.Expired) ?? serverSidePlaylist.Last(); + orderedItems = serverSidePlaylist.OrderBy(item => item.ID == 0 ? int.MaxValue : item.ID).ToList(); break; case QueueMode.AllPlayersRoundRobin: - // Group playlist items by (user_id -> count_expired), and select the first available playlist item from a user that has available beatmaps where count_expired is the lowest. - throw new NotImplementedException(); + // Todo: This could probably be more efficient, likely at the cost of increased complexity. + // Number of "expired" or "used" items per player. + Dictionary perUserCounts = serverSidePlaylist + .GroupBy(item => item.OwnerID) + .ToDictionary(group => group.Key, group => group.Count(item => item.Expired)); + + // We'll run a simulation over all items which are not expired ("unprocessed"). Expired items will not have their ordering updated. + List processedItems = serverSidePlaylist.Where(item => item.Expired).ToList(); + List unprocessedItems = serverSidePlaylist.Where(item => !item.Expired).ToList(); + + // In every iteration of the simulation, pick the first available item from the user with the lowest number of items in the queue to add to the result set. + // If multiple users have the same number of items in the queue, then the item with the lowest ID is chosen. + while (unprocessedItems.Count > 0) + { + MultiplayerPlaylistItem candidateItem = unprocessedItems + .OrderBy(item => perUserCounts[item.OwnerID]) + .ThenBy(item => item.ID == 0 ? int.MaxValue : item.ID) + .First(); + + unprocessedItems.Remove(candidateItem); + processedItems.Add(candidateItem); + + perUserCounts[candidateItem.OwnerID]++; + } + + orderedItems = processedItems; + break; } - currentIndex = serverSidePlaylist.IndexOf(newItem); + for (int i = 0; i < orderedItems.Count; i++) + { + // Items which are already ordered correct don't need to be updated. + if (orderedItems[i].GameplayOrder == i) + continue; - long lastItem = room.Settings.PlaylistItemId; - room.Settings.PlaylistItemId = newItem.ID; + orderedItems[i].GameplayOrder = i; - if (notify && newItem.ID != lastItem) - await ((IMultiplayerClient)this).SettingsChanged(room.Settings).ConfigureAwait(false); + // Items which have an ID of 0 are not in the database, so avoid propagating database/hub events for them. + if (orderedItems[i].ID <= 0) + continue; + + await ((IMultiplayerClient)this).PlaylistItemChanged(orderedItems[i]).ConfigureAwait(false); + } + + serverSidePlaylist = orderedItems; } } }