diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs new file mode 100644 index 0000000000..ee5cb7f32c --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs @@ -0,0 +1,255 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Screens.OnlinePlay; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Tests.Resources; +using osuTK; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMultiplayerPlaylist : MultiplayerTestScene + { + private BeatmapManager beatmaps; + private RulesetStore rulesets; + private BeatmapSetInfo importedSet; + private BeatmapInfo importedBeatmap; + + [Cached(typeof(UserLookupCache))] + private UserLookupCache lookupCache = new TestUserLookupCache(); + + [BackgroundDependencyLoader] + private void load(GameHost host, AudioManager audio) + { + Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default)); + } + + [SetUp] + public new void Setup() => Schedule(() => + { + Child = new MultiplayerPlaylist + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.4f, 0.8f) + }; + }); + + [SetUpSteps] + public new void SetUpSteps() + { + AddStep("import beatmap", () => + { + beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); + importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First(); + importedBeatmap = importedSet.Beatmaps.First(b => b.RulesetID == 0); + }); + } + + [Test] + public void DoTest() + { + AddStep("change to round robin mode", () => Client.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayersRoundRobin })); + AddStep("add playlist item for user 1", () => Client.AddPlaylistItem(new MultiplayerPlaylistItem + { + BeatmapID = importedBeatmap.OnlineID!.Value + })); + } + + public class MultiplayerPlaylist : MultiplayerRoomComposite + { + private QueueList queueList; + private DrawableRoomPlaylist historyList; + private bool firstPopulation = true; + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + queueList = new QueueList(false, false, true) + { + RelativeSizeAxes = Axes.Both + }, + historyList = new DrawableRoomPlaylist(false, false, true) + { + RelativeSizeAxes = Axes.Both + } + } + } + }; + } + + protected override void OnRoomUpdated() + { + base.OnRoomUpdated(); + + if (Room == null) + return; + + if (!firstPopulation) return; + + foreach (var item in Room.Playlist) + PlaylistItemAdded(item); + + firstPopulation = false; + } + + protected override void PlaylistItemAdded(MultiplayerPlaylistItem item) + { + base.PlaylistItemAdded(item); + + if (item.Expired) + historyList.Items.Add(getPlaylistItem(item)); + else + queueList.Items.Add(getPlaylistItem(item)); + } + + protected override void PlaylistItemRemoved(long item) + { + base.PlaylistItemRemoved(item); + + queueList.Items.RemoveAll(i => i.ID == item); + } + + protected override void PlaylistItemChanged(MultiplayerPlaylistItem item) + { + base.PlaylistItemChanged(item); + + PlaylistItemRemoved(item.ID); + PlaylistItemAdded(item); + } + + private PlaylistItem getPlaylistItem(MultiplayerPlaylistItem item) => Playlist.Single(i => i.ID == item.ID); + } + + public class QueueList : DrawableRoomPlaylist + { + public readonly IBindable QueueMode = new Bindable(); + + public QueueList(bool allowEdit, bool allowSelection, bool reverse = false) + : base(allowEdit, allowSelection, reverse) + { + } + + protected override FillFlowContainer> CreateListFillFlowContainer() => new QueueFillFlowContainer + { + QueueMode = { BindTarget = QueueMode }, + Spacing = new Vector2(0, 2) + }; + + private class QueueFillFlowContainer : FillFlowContainer> + { + public readonly IBindable QueueMode = new Bindable(); + + protected override void LoadComplete() + { + base.LoadComplete(); + QueueMode.BindValueChanged(_ => InvalidateLayout()); + } + + public override IEnumerable FlowingChildren + { + get + { + switch (QueueMode.Value) + { + default: + return AliveInternalChildren.Where(d => d.IsPresent) + .OfType>() + .OrderBy(item => item.Model.ID); + + case Game.Online.Multiplayer.QueueMode.AllPlayersRoundRobin: + // TODO: THIS IS SO INEFFICIENT, can it be done any better? + + // Group all items by their owners. + var groups = AliveInternalChildren.Where(d => d.IsPresent) + .OfType>() + .GroupBy(item => item.Model.OwnerID) + .Select(g => g.ToArray()) + .ToArray(); + + if (groups.Length == 0) + return Enumerable.Empty(); + + // Find the initial picking order for the groups. The group with the smallest 'weight' picks first. + int[] groupWeights = new int[groups.Length]; + + for (int i = 0; i < groups.Length; i++) + { + groupWeights[i] = groups[i].Count(item => item.Model.Expired); + groups[i] = groups[i].Where(item => !item.Model.Expired).ToArray(); + } + + var result = new List(); + + // Simulate the playlist by picking in order from the smallest-weighted room each time until no longer able to. + while (true) + { + var candidateGroup = groups + // Map each group to an index. + .Select((items, index) => new { index, items }) + // Order groups by their weights. + .OrderBy(group => groupWeights[group.index]) + // Select the first group with remaining items (null is set from previous iterations). + .FirstOrDefault(group => group.items.Any(i => i != null)); + + // Iteration ends when all groups have been exhausted of items. + if (candidateGroup == null) + break; + + // Find the index of the first non-null (i.e. unused) item in the group. + int candidateItemIndex = 0; + RearrangeableListItem candidateItem = null; + + for (int i = 0; i < candidateGroup.items.Length; i++) + { + if (candidateGroup.items[i] != null) + { + candidateItemIndex = i; + candidateItem = candidateGroup.items[i]; + } + } + + // The item is guaranteed to not be expired, since we've previously removed all expired items. + Debug.Assert(candidateItem?.Model.Expired == false); + + // Add the item to the result set. + result.Add(candidateItem); + + // Update the group for the next iteration. + candidateGroup.items[candidateItemIndex] = null; + groupWeights[candidateGroup.index]++; + } + + return result; + } + } + } + } + } + } +} diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index df16fb3042..fb716c9814 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -33,11 +33,13 @@ namespace osu.Game.Online.Multiplayer public event Action? RoomUpdated; public event Action? UserJoined; - public event Action? UserLeft; - public event Action? UserKicked; + public event Action? ItemAdded; + public event Action? ItemRemoved; + public event Action? ItemChanged; + /// /// Invoked when the multiplayer server requests the current beatmap to be loaded into play. /// @@ -619,6 +621,7 @@ namespace osu.Game.Online.Multiplayer Room.Playlist.Add(item); APIRoom.Playlist.Add(playlistItem); + ItemAdded?.Invoke(item); RoomUpdated?.Invoke(); }); } @@ -638,6 +641,7 @@ namespace osu.Game.Online.Multiplayer Room.Playlist.Remove(Room.Playlist.Single(existing => existing.ID == playlistItemId)); APIRoom.Playlist.RemoveAll(existing => existing.ID == playlistItemId); + ItemRemoved?.Invoke(playlistItemId); RoomUpdated?.Invoke(); }); @@ -668,6 +672,7 @@ namespace osu.Game.Online.Multiplayer if (CurrentMatchPlayingItem.Value?.ID == playlistItem.ID) CurrentMatchPlayingItem.Value = playlistItem; + ItemChanged?.Invoke(item); RoomUpdated?.Invoke(); }); } diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs index f5522cd25d..dc04d9a77b 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs @@ -1,9 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using System.Collections.Specialized; -using System.Linq; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; @@ -25,8 +23,6 @@ namespace osu.Game.Screens.OnlinePlay { this.allowEdit = allowEdit; this.allowSelection = allowSelection; - - ((ReversibleFillFlowContainer)ListContainer).Reverse = reverse; } protected override void LoadComplete() @@ -51,7 +47,7 @@ namespace osu.Game.Screens.OnlinePlay d.ScrollbarVisible = false; }); - protected override FillFlowContainer> CreateListFillFlowContainer() => new ReversibleFillFlowContainer + protected override FillFlowContainer> CreateListFillFlowContainer() => new FillFlowContainer> { Spacing = new Vector2(0, 2) }; @@ -74,22 +70,5 @@ namespace osu.Game.Screens.OnlinePlay Items.Remove(item); } - - private class ReversibleFillFlowContainer : FillFlowContainer> - { - private bool reverse; - - public bool Reverse - { - get => reverse; - set - { - reverse = value; - Invalidate(); - } - } - - public override IEnumerable FlowingChildren => Reverse ? base.FlowingChildren.OrderBy(d => -GetLayoutPosition(d)) : base.FlowingChildren; - } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs index a380ddef25..f2cac708e4 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs @@ -4,6 +4,7 @@ using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Multiplayer { @@ -23,6 +24,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Client.UserLeft += invokeUserLeft; Client.UserKicked += invokeUserKicked; Client.UserJoined += invokeUserJoined; + Client.ItemAdded += invokeItemAdded; + Client.ItemRemoved += invokeItemRemoved; + Client.ItemChanged += invokeItemChanged; OnRoomUpdated(); } @@ -31,6 +35,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private void invokeUserJoined(MultiplayerRoomUser user) => Scheduler.AddOnce(UserJoined, user); private void invokeUserKicked(MultiplayerRoomUser user) => Scheduler.AddOnce(UserKicked, user); private void invokeUserLeft(MultiplayerRoomUser user) => Scheduler.AddOnce(UserLeft, user); + private void invokeItemAdded(MultiplayerPlaylistItem item) => Scheduler.AddOnce(PlaylistItemAdded, item); + private void invokeItemRemoved(long item) => Scheduler.AddOnce(PlaylistItemRemoved, item); + private void invokeItemChanged(MultiplayerPlaylistItem item) => Scheduler.AddOnce(PlaylistItemChanged, item); /// /// Invoked when a user has joined the room. @@ -56,6 +63,30 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { } + /// + /// Invoked when a playlist item is added to the room. + /// + /// The added playlist item. + protected virtual void PlaylistItemAdded(MultiplayerPlaylistItem item) + { + } + + /// + /// Invoked when a playlist item is removed from the room. + /// + /// The ID of the removed playlist item. + protected virtual void PlaylistItemRemoved(long item) + { + } + + /// + /// Invoked when a playlist item is changed in the room. + /// + /// The new playlist item, with an existing item's ID. + protected virtual void PlaylistItemChanged(MultiplayerPlaylistItem item) + { + } + /// /// Invoked when any change occurs to the multiplayer room. /// @@ -71,6 +102,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Client.UserLeft -= invokeUserLeft; Client.UserKicked -= invokeUserKicked; Client.UserJoined -= invokeUserJoined; + Client.ItemAdded -= invokeItemAdded; + Client.ItemRemoved -= invokeItemRemoved; + Client.ItemChanged -= invokeItemChanged; } base.Dispose(isDisposing);