1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-15 06:52:56 +08:00

Merge branch 'master' into realm-integration/skins-filename-lookup-performance

This commit is contained in:
Dean Herbert 2021-12-02 13:20:56 +09:00
commit 9483a3013c
19 changed files with 445 additions and 118 deletions

View File

@ -77,10 +77,6 @@ jobs:
run: msbuild osu.Android/osu.Android.csproj /restore /p:Configuration=Debug
build-only-ios:
# While this workflow technically *can* run, it fails as iOS builds are blocked by multiple issues.
# See https://github.com/ppy/osu-framework/issues/4677 for the details.
# The job can be unblocked once those issues are resolved and game deployments can happen again.
if: false
name: Build only (iOS)
runs-on: macos-latest
timeout-minutes: 60

View File

@ -10,3 +10,6 @@ T:Microsoft.EntityFrameworkCore.Internal.EnumerableExtensions;Don't use internal
T:Microsoft.EntityFrameworkCore.Internal.TypeExtensions;Don't use internal extension methods.
T:NuGet.Packaging.CollectionExtensions;Don't use internal extension methods.
M:System.Enum.HasFlag(System.Enum);Use osu.Framework.Extensions.EnumExtensions.HasFlagFast<T>() instead.
M:Realms.IRealmCollection`1.SubscribeForNotifications`1(Realms.NotificationCallbackDelegate{``0});Use osu.Game.Database.RealmObjectExtensions.QueryAsyncWithNotifications(IRealmCollection<T>,NotificationCallbackDelegate<T>) instead.
M:Realms.CollectionExtensions.SubscribeForNotifications`1(System.Linq.IQueryable{``0},Realms.NotificationCallbackDelegate{``0});Use osu.Game.Database.RealmObjectExtensions.QueryAsyncWithNotifications(IQueryable<T>,NotificationCallbackDelegate<T>) instead.
M:Realms.CollectionExtensions.SubscribeForNotifications`1(System.Collections.Generic.IList{``0},Realms.NotificationCallbackDelegate{``0});Use osu.Game.Database.RealmObjectExtensions.QueryAsyncWithNotifications(IList<T>,NotificationCallbackDelegate<T>) instead.

View File

@ -5,8 +5,8 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Game.Database;
using osu.Game.Models;
using Realms;
#nullable enable
@ -48,7 +48,7 @@ namespace osu.Game.Tests.Database
using (var context = realmFactory.CreateContext())
{
var subscription = context.All<RealmBeatmap>().SubscribeForNotifications((sender, changes, error) =>
var subscription = context.All<RealmBeatmap>().QueryAsyncWithNotifications((sender, changes, error) =>
{
using (realmFactory.CreateContext())
{
@ -61,7 +61,7 @@ namespace osu.Game.Tests.Database
{
}
subscription.Dispose();
subscription?.Dispose();
}
Assert.IsTrue(callbackRan);

View File

@ -208,7 +208,7 @@ namespace osu.Game.Tests.Database
using (var updateThreadContext = realmFactory.CreateContext())
{
updateThreadContext.All<RealmBeatmap>().SubscribeForNotifications(gotChange);
updateThreadContext.All<RealmBeatmap>().QueryAsyncWithNotifications(gotChange);
ILive<RealmBeatmap>? liveBeatmap = null;
Task.Factory.StartNew(() =>

View File

@ -3,12 +3,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods;
using osu.Game.Utils;
namespace osu.Game.Tests.NonVisual
{
@ -20,8 +22,10 @@ namespace osu.Game.Tests.NonVisual
{
var combinations = new TestLegacyDifficultyCalculator().CreateDifficultyAdjustmentModCombinations();
Assert.AreEqual(1, combinations.Length);
Assert.IsTrue(combinations[0] is ModNoMod);
assertCombinations(new[]
{
new[] { typeof(ModNoMod) }
}, combinations);
}
[Test]
@ -29,9 +33,11 @@ namespace osu.Game.Tests.NonVisual
{
var combinations = new TestLegacyDifficultyCalculator(new ModA()).CreateDifficultyAdjustmentModCombinations();
Assert.AreEqual(2, combinations.Length);
Assert.IsTrue(combinations[0] is ModNoMod);
Assert.IsTrue(combinations[1] is ModA);
assertCombinations(new[]
{
new[] { typeof(ModNoMod) },
new[] { typeof(ModA) }
}, combinations);
}
[Test]
@ -39,14 +45,13 @@ namespace osu.Game.Tests.NonVisual
{
var combinations = new TestLegacyDifficultyCalculator(new ModA(), new ModB()).CreateDifficultyAdjustmentModCombinations();
Assert.AreEqual(4, combinations.Length);
Assert.IsTrue(combinations[0] is ModNoMod);
Assert.IsTrue(combinations[1] is ModA);
Assert.IsTrue(combinations[2] is MultiMod);
Assert.IsTrue(combinations[3] is ModB);
Assert.IsTrue(((MultiMod)combinations[2]).Mods[0] is ModA);
Assert.IsTrue(((MultiMod)combinations[2]).Mods[1] is ModB);
assertCombinations(new[]
{
new[] { typeof(ModNoMod) },
new[] { typeof(ModA) },
new[] { typeof(ModA), typeof(ModB) },
new[] { typeof(ModB) }
}, combinations);
}
[Test]
@ -54,10 +59,12 @@ namespace osu.Game.Tests.NonVisual
{
var combinations = new TestLegacyDifficultyCalculator(new ModA(), new ModIncompatibleWithA()).CreateDifficultyAdjustmentModCombinations();
Assert.AreEqual(3, combinations.Length);
Assert.IsTrue(combinations[0] is ModNoMod);
Assert.IsTrue(combinations[1] is ModA);
Assert.IsTrue(combinations[2] is ModIncompatibleWithA);
assertCombinations(new[]
{
new[] { typeof(ModNoMod) },
new[] { typeof(ModA) },
new[] { typeof(ModIncompatibleWithA) }
}, combinations);
}
[Test]
@ -65,22 +72,17 @@ namespace osu.Game.Tests.NonVisual
{
var combinations = new TestLegacyDifficultyCalculator(new ModA(), new ModB(), new ModIncompatibleWithA(), new ModIncompatibleWithAAndB()).CreateDifficultyAdjustmentModCombinations();
Assert.AreEqual(8, combinations.Length);
Assert.IsTrue(combinations[0] is ModNoMod);
Assert.IsTrue(combinations[1] is ModA);
Assert.IsTrue(combinations[2] is MultiMod);
Assert.IsTrue(combinations[3] is ModB);
Assert.IsTrue(combinations[4] is MultiMod);
Assert.IsTrue(combinations[5] is ModIncompatibleWithA);
Assert.IsTrue(combinations[6] is MultiMod);
Assert.IsTrue(combinations[7] is ModIncompatibleWithAAndB);
Assert.IsTrue(((MultiMod)combinations[2]).Mods[0] is ModA);
Assert.IsTrue(((MultiMod)combinations[2]).Mods[1] is ModB);
Assert.IsTrue(((MultiMod)combinations[4]).Mods[0] is ModB);
Assert.IsTrue(((MultiMod)combinations[4]).Mods[1] is ModIncompatibleWithA);
Assert.IsTrue(((MultiMod)combinations[6]).Mods[0] is ModIncompatibleWithA);
Assert.IsTrue(((MultiMod)combinations[6]).Mods[1] is ModIncompatibleWithAAndB);
assertCombinations(new[]
{
new[] { typeof(ModNoMod) },
new[] { typeof(ModA) },
new[] { typeof(ModA), typeof(ModB) },
new[] { typeof(ModB) },
new[] { typeof(ModB), typeof(ModIncompatibleWithA) },
new[] { typeof(ModIncompatibleWithA) },
new[] { typeof(ModIncompatibleWithA), typeof(ModIncompatibleWithAAndB) },
new[] { typeof(ModIncompatibleWithAAndB) },
}, combinations);
}
[Test]
@ -88,10 +90,12 @@ namespace osu.Game.Tests.NonVisual
{
var combinations = new TestLegacyDifficultyCalculator(new ModAofA(), new ModIncompatibleWithAofA()).CreateDifficultyAdjustmentModCombinations();
Assert.AreEqual(3, combinations.Length);
Assert.IsTrue(combinations[0] is ModNoMod);
Assert.IsTrue(combinations[1] is ModAofA);
Assert.IsTrue(combinations[2] is ModIncompatibleWithAofA);
assertCombinations(new[]
{
new[] { typeof(ModNoMod) },
new[] { typeof(ModAofA) },
new[] { typeof(ModIncompatibleWithAofA) }
}, combinations);
}
[Test]
@ -99,17 +103,13 @@ namespace osu.Game.Tests.NonVisual
{
var combinations = new TestLegacyDifficultyCalculator(new ModA(), new MultiMod(new ModB(), new ModC())).CreateDifficultyAdjustmentModCombinations();
Assert.AreEqual(4, combinations.Length);
Assert.IsTrue(combinations[0] is ModNoMod);
Assert.IsTrue(combinations[1] is ModA);
Assert.IsTrue(combinations[2] is MultiMod);
Assert.IsTrue(combinations[3] is MultiMod);
Assert.IsTrue(((MultiMod)combinations[2]).Mods[0] is ModA);
Assert.IsTrue(((MultiMod)combinations[2]).Mods[1] is ModB);
Assert.IsTrue(((MultiMod)combinations[2]).Mods[2] is ModC);
Assert.IsTrue(((MultiMod)combinations[3]).Mods[0] is ModB);
Assert.IsTrue(((MultiMod)combinations[3]).Mods[1] is ModC);
assertCombinations(new[]
{
new[] { typeof(ModNoMod) },
new[] { typeof(ModA) },
new[] { typeof(ModA), typeof(ModB), typeof(ModC) },
new[] { typeof(ModB), typeof(ModC) }
}, combinations);
}
[Test]
@ -117,13 +117,12 @@ namespace osu.Game.Tests.NonVisual
{
var combinations = new TestLegacyDifficultyCalculator(new ModA(), new MultiMod(new ModB(), new ModIncompatibleWithA())).CreateDifficultyAdjustmentModCombinations();
Assert.AreEqual(3, combinations.Length);
Assert.IsTrue(combinations[0] is ModNoMod);
Assert.IsTrue(combinations[1] is ModA);
Assert.IsTrue(combinations[2] is MultiMod);
Assert.IsTrue(((MultiMod)combinations[2]).Mods[0] is ModB);
Assert.IsTrue(((MultiMod)combinations[2]).Mods[1] is ModIncompatibleWithA);
assertCombinations(new[]
{
new[] { typeof(ModNoMod) },
new[] { typeof(ModA) },
new[] { typeof(ModB), typeof(ModIncompatibleWithA) }
}, combinations);
}
[Test]
@ -131,13 +130,28 @@ namespace osu.Game.Tests.NonVisual
{
var combinations = new TestLegacyDifficultyCalculator(new ModA(), new MultiMod(new ModA(), new ModB())).CreateDifficultyAdjustmentModCombinations();
Assert.AreEqual(3, combinations.Length);
Assert.IsTrue(combinations[0] is ModNoMod);
Assert.IsTrue(combinations[1] is ModA);
Assert.IsTrue(combinations[2] is MultiMod);
assertCombinations(new[]
{
new[] { typeof(ModNoMod) },
new[] { typeof(ModA) },
new[] { typeof(ModA), typeof(ModB) }
}, combinations);
}
Assert.IsTrue(((MultiMod)combinations[2]).Mods[0] is ModA);
Assert.IsTrue(((MultiMod)combinations[2]).Mods[1] is ModB);
private void assertCombinations(Type[][] expectedCombinations, Mod[] actualCombinations)
{
Assert.AreEqual(expectedCombinations.Length, actualCombinations.Length);
Assert.Multiple(() =>
{
for (int i = 0; i < expectedCombinations.Length; ++i)
{
Type[] expectedTypes = expectedCombinations[i];
Type[] actualTypes = ModUtils.FlattenMod(actualCombinations[i]).Select(m => m.GetType()).ToArray();
Assert.That(expectedTypes, Is.EquivalentTo(actualTypes));
}
});
}
private class ModA : Mod

View File

@ -144,7 +144,8 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep("set mods", () => SelectedMods.Value = new[] { new TaikoModDoubleTime() });
AddStep("confirm selection", () => songSelect.FinaliseSelection());
AddStep("exit song select", () => songSelect.Exit());
AddUntilStep("song select exited", () => !songSelect.IsCurrentScreen());
AddAssert("beatmap not changed", () => Beatmap.Value.BeatmapInfo.Equals(selectedBeatmap));
AddAssert("ruleset not changed", () => Ruleset.Value.Equals(new TaikoRuleset().RulesetInfo));

View File

@ -11,6 +11,7 @@ using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets;
@ -118,6 +119,33 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("user still on team 0", () => (client.Room?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0);
}
[Test]
public void TestSettingsUpdatedWhenChangingMatchType()
{
createRoom(() => new Room
{
Name = { Value = "Test Room" },
Type = { Value = MatchType.HeadToHead },
Playlist =
{
new PlaylistItem
{
Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo },
Ruleset = { Value = new OsuRuleset().RulesetInfo },
}
}
});
AddUntilStep("match type head to head", () => client.APIRoom?.Type.Value == MatchType.HeadToHead);
AddStep("change match type", () => client.ChangeSettings(new MultiplayerRoomSettings
{
MatchType = MatchType.TeamVersus
}));
AddUntilStep("api room updated to team versus", () => client.APIRoom?.Type.Value == MatchType.TeamVersus);
}
[Test]
public void TestChangeTypeViaMatchSettings()
{
@ -152,6 +180,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("wait for room open", () => this.ChildrenOfType<MultiplayerMatchSubScreen>().FirstOrDefault()?.IsLoaded == true);
AddWaitStep("wait for transition", 2);
AddUntilStep("create room button enabled", () => this.ChildrenOfType<MultiplayerMatchSettingsOverlay.CreateOrUpdateButton>().Single().Enabled.Value);
AddStep("create room", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<MultiplayerMatchSettingsOverlay.CreateOrUpdateButton>().Single());

View File

@ -0,0 +1,149 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
namespace osu.Game.Database
{
// This class is based on `UserLookupCache` which is well tested.
// If modifications are to be made here, a base abstract implementation should likely be created and shared between the two.
public class BeatmapLookupCache : MemoryCachingComponent<int, APIBeatmap>
{
[Resolved]
private IAPIProvider api { get; set; }
/// <summary>
/// Perform an API lookup on the specified beatmap, populating a <see cref="APIBeatmap"/> model.
/// </summary>
/// <param name="beatmapId">The beatmap to lookup.</param>
/// <param name="token">An optional cancellation token.</param>
/// <returns>The populated beatmap, or null if the beatmap does not exist or the request could not be satisfied.</returns>
[ItemCanBeNull]
public Task<APIBeatmap> GetBeatmapAsync(int beatmapId, CancellationToken token = default) => GetAsync(beatmapId, token);
/// <summary>
/// Perform an API lookup on the specified beatmaps, populating a <see cref="APIBeatmap"/> model.
/// </summary>
/// <param name="beatmapIds">The beatmaps to lookup.</param>
/// <param name="token">An optional cancellation token.</param>
/// <returns>The populated beatmaps. May include null results for failed retrievals.</returns>
public Task<APIBeatmap[]> GetBeatmapsAsync(int[] beatmapIds, CancellationToken token = default)
{
var beatmapLookupTasks = new List<Task<APIBeatmap>>();
foreach (int u in beatmapIds)
{
beatmapLookupTasks.Add(GetBeatmapAsync(u, token).ContinueWith(task =>
{
if (!task.IsCompletedSuccessfully)
return null;
return task.Result;
}, token));
}
return Task.WhenAll(beatmapLookupTasks);
}
protected override async Task<APIBeatmap> ComputeValueAsync(int lookup, CancellationToken token = default)
=> await queryBeatmap(lookup).ConfigureAwait(false);
private readonly Queue<(int id, TaskCompletionSource<APIBeatmap>)> pendingBeatmapTasks = new Queue<(int, TaskCompletionSource<APIBeatmap>)>();
private Task pendingRequestTask;
private readonly object taskAssignmentLock = new object();
private Task<APIBeatmap> queryBeatmap(int beatmapId)
{
lock (taskAssignmentLock)
{
var tcs = new TaskCompletionSource<APIBeatmap>();
// Add to the queue.
pendingBeatmapTasks.Enqueue((beatmapId, tcs));
// Create a request task if there's not already one.
if (pendingRequestTask == null)
createNewTask();
return tcs.Task;
}
}
private void performLookup()
{
// contains at most 50 unique beatmap IDs from beatmapTasks, which is used to perform the lookup.
var beatmapTasks = new Dictionary<int, List<TaskCompletionSource<APIBeatmap>>>();
// Grab at most 50 unique beatmap IDs from the queue.
lock (taskAssignmentLock)
{
while (pendingBeatmapTasks.Count > 0 && beatmapTasks.Count < 50)
{
(int id, TaskCompletionSource<APIBeatmap> task) next = pendingBeatmapTasks.Dequeue();
// Perform a secondary check for existence, in case the beatmap was queried in a previous batch.
if (CheckExists(next.id, out var existing))
next.task.SetResult(existing);
else
{
if (beatmapTasks.TryGetValue(next.id, out var tasks))
tasks.Add(next.task);
else
beatmapTasks[next.id] = new List<TaskCompletionSource<APIBeatmap>> { next.task };
}
}
}
if (beatmapTasks.Count == 0)
return;
// Query the beatmaps.
var request = new GetBeatmapsRequest(beatmapTasks.Keys.ToArray());
// rather than queueing, we maintain our own single-threaded request stream.
// todo: we probably want retry logic here.
api.Perform(request);
// Create a new request task if there's still more beatmaps to query.
lock (taskAssignmentLock)
{
pendingRequestTask = null;
if (pendingBeatmapTasks.Count > 0)
createNewTask();
}
List<APIBeatmap> foundBeatmaps = request.Response?.Beatmaps;
if (foundBeatmaps != null)
{
foreach (var beatmap in foundBeatmaps)
{
if (beatmapTasks.TryGetValue(beatmap.OnlineID, out var tasks))
{
foreach (var task in tasks)
task.SetResult(beatmap);
beatmapTasks.Remove(beatmap.OnlineID);
}
}
}
// if any tasks remain which were not satisfied, return null.
foreach (var tasks in beatmapTasks.Values)
{
foreach (var task in tasks)
task.SetResult(null);
}
}
private void createNewTask() => pendingRequestTask = Task.Run(performLookup);
}
}

View File

@ -1,12 +1,16 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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.Linq;
using AutoMapper;
using osu.Framework.Development;
using osu.Game.Input.Bindings;
using Realms;
#nullable enable
namespace osu.Game.Database
{
public static class RealmObjectExtensions
@ -60,5 +64,109 @@ namespace osu.Game.Database
{
return new RealmLive<T>(realmObject);
}
/// <summary>
/// Register a callback to be invoked each time this <see cref="T:Realms.IRealmCollection`1" /> changes.
/// </summary>
/// <remarks>
/// <para>
/// This adds osu! specific thread and managed state safety checks on top of <see cref="IRealmCollection{T}.SubscribeForNotifications"/>.
/// </para>
/// <para>
/// The first callback will be invoked with the initial <see cref="T:Realms.IRealmCollection`1" /> after the asynchronous query completes,
/// and then called again after each write transaction which changes either any of the objects in the collection, or
/// which objects are in the collection. The <c>changes</c> parameter will
/// be <c>null</c> the first time the callback is invoked with the initial results. For each call after that,
/// it will contain information about which rows in the results were added, removed or modified.
/// </para>
/// <para>
/// If a write transaction did not modify any objects in this <see cref="T:Realms.IRealmCollection`1" />, the callback is not invoked at all.
/// If an error occurs the callback will be invoked with <c>null</c> for the <c>sender</c> parameter and a non-<c>null</c> <c>error</c>.
/// Currently the only errors that can occur are when opening the <see cref="T:Realms.Realm" /> on the background worker thread.
/// </para>
/// <para>
/// At the time when the block is called, the <see cref="T:Realms.IRealmCollection`1" /> object will be fully evaluated
/// and up-to-date, and as long as you do not perform a write transaction on the same thread
/// or explicitly call <see cref="M:Realms.Realm.Refresh" />, accessing it will never perform blocking work.
/// </para>
/// <para>
/// Notifications are delivered via the standard event loop, and so can't be delivered while the event loop is blocked by other activity.
/// When notifications can't be delivered instantly, multiple notifications may be coalesced into a single notification.
/// This can include the notification with the initial collection.
/// </para>
/// </remarks>
/// <param name="collection">The <see cref="IRealmCollection{T}"/> to observe for changes.</param>
/// <param name="callback">The callback to be invoked with the updated <see cref="T:Realms.IRealmCollection`1" />.</param>
/// <returns>
/// A subscription token. It must be kept alive for as long as you want to receive change notifications.
/// To stop receiving notifications, call <see cref="M:System.IDisposable.Dispose" />.
///
/// May be null in the case the provided collection is not managed.
/// </returns>
/// <seealso cref="M:Realms.CollectionExtensions.SubscribeForNotifications``1(System.Collections.Generic.IList{``0},Realms.NotificationCallbackDelegate{``0})" />
/// <seealso cref="M:Realms.CollectionExtensions.SubscribeForNotifications``1(System.Linq.IQueryable{``0},Realms.NotificationCallbackDelegate{``0})" />
public static IDisposable? QueryAsyncWithNotifications<T>(this IRealmCollection<T> collection, NotificationCallbackDelegate<T> callback)
where T : RealmObjectBase
{
// Subscriptions can only work on the main thread.
if (!ThreadSafety.IsUpdateThread)
throw new InvalidOperationException("Cannot subscribe for realm notifications from a non-update thread.");
return collection.SubscribeForNotifications(callback);
}
/// <summary>
/// A convenience method that casts <see cref="IQueryable{T}"/> to <see cref="IRealmCollection{T}"/> and subscribes for change notifications.
/// </summary>
/// <remarks>
/// This adds osu! specific thread and managed state safety checks on top of <see cref="IRealmCollection{T}.SubscribeForNotifications"/>.
/// </remarks>
/// <param name="list">The <see cref="IQueryable{T}"/> to observe for changes.</param>
/// <typeparam name="T">Type of the elements in the list.</typeparam>
/// <seealso cref="IRealmCollection{T}.SubscribeForNotifications"/>
/// <param name="callback">The callback to be invoked with the updated <see cref="IRealmCollection{T}"/>.</param>
/// <returns>
/// A subscription token. It must be kept alive for as long as you want to receive change notifications.
/// To stop receiving notifications, call <see cref="IDisposable.Dispose"/>.
///
/// May be null in the case the provided collection is not managed.
/// </returns>
public static IDisposable? QueryAsyncWithNotifications<T>(this IQueryable<T> list, NotificationCallbackDelegate<T> callback)
where T : RealmObjectBase
{
// Subscribing to non-managed instances doesn't work.
// In this usage, the instance may be non-managed in tests.
if (!(list is IRealmCollection<T> realmCollection))
return null;
return QueryAsyncWithNotifications(realmCollection, callback);
}
/// <summary>
/// A convenience method that casts <see cref="IList{T}"/> to <see cref="IRealmCollection{T}"/> and subscribes for change notifications.
/// </summary>
/// <remarks>
/// This adds osu! specific thread and managed state safety checks on top of <see cref="IRealmCollection{T}.SubscribeForNotifications"/>.
/// </remarks>
/// <param name="list">The <see cref="IList{T}"/> to observe for changes.</param>
/// <typeparam name="T">Type of the elements in the list.</typeparam>
/// <seealso cref="IRealmCollection{T}.SubscribeForNotifications"/>
/// <param name="callback">The callback to be invoked with the updated <see cref="IRealmCollection{T}"/>.</param>
/// <returns>
/// A subscription token. It must be kept alive for as long as you want to receive change notifications.
/// To stop receiving notifications, call <see cref="IDisposable.Dispose"/>.
///
/// May be null in the case the provided collection is not managed.
/// </returns>
public static IDisposable? QueryAsyncWithNotifications<T>(this IList<T> list, NotificationCallbackDelegate<T> callback)
where T : RealmObjectBase
{
// Subscribing to non-managed instances doesn't work.
// In this usage, the instance may be non-managed in tests.
if (!(list is IRealmCollection<T> realmCollection))
return null;
return QueryAsyncWithNotifications(realmCollection, callback);
}
}
}

View File

@ -100,6 +100,9 @@ namespace osu.Game.Database
}
}
if (userTasks.Count == 0)
return;
// Query the users.
var request = new GetUsersRequest(userTasks.Keys.ToArray());

View File

@ -8,7 +8,6 @@ using osu.Framework.Allocation;
using osu.Framework.Input.Bindings;
using osu.Game.Database;
using osu.Game.Rulesets;
using Realms;
namespace osu.Game.Input.Bindings
{
@ -56,7 +55,7 @@ namespace osu.Game.Input.Bindings
.Where(b => b.RulesetName == rulesetName && b.Variant == variant);
realmSubscription = realmKeyBindings
.SubscribeForNotifications((sender, changes, error) =>
.QueryAsyncWithNotifications((sender, changes, error) =>
{
// first subscription ignored as we are handling this in LoadComplete.
if (changes == null)

View File

@ -38,7 +38,12 @@ namespace osu.Game.Online.API
protected override void PostProcess()
{
base.PostProcess();
Response = ((OsuJsonWebRequest<T>)WebRequest)?.ResponseObject;
if (WebRequest != null)
{
Response = ((OsuJsonWebRequest<T>)WebRequest).ResponseObject;
Logger.Log($"{GetType()} finished with response size of {WebRequest.ResponseStream.Length:#,0} bytes");
}
}
internal void TriggerSuccess(T result)

View File

@ -0,0 +1,24 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
namespace osu.Game.Online.API.Requests
{
public class GetBeatmapsRequest : APIRequest<GetBeatmapsResponse>
{
private readonly int[] beatmapIds;
private const int max_ids_per_request = 50;
public GetBeatmapsRequest(int[] beatmapIds)
{
if (beatmapIds.Length > max_ids_per_request)
throw new ArgumentException($"{nameof(GetBeatmapsRequest)} calls only support up to {max_ids_per_request} IDs at once");
this.beatmapIds = beatmapIds;
}
protected override string Target => "beatmaps/?ids[]=" + string.Join("&ids[]=", beatmapIds);
}
}

View File

@ -0,0 +1,15 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using Newtonsoft.Json;
using osu.Game.Online.API.Requests.Responses;
namespace osu.Game.Online.API.Requests
{
public class GetBeatmapsResponse : ResponseWithCursor
{
[JsonProperty("beatmaps")]
public List<APIBeatmap> Beatmaps;
}
}

View File

@ -694,6 +694,7 @@ namespace osu.Game.Online.Multiplayer
Room.Settings = settings;
APIRoom.Name.Value = Room.Settings.Name;
APIRoom.Password.Value = Room.Settings.Password;
APIRoom.Type.Value = Room.Settings.MatchType;
APIRoom.QueueMode.Value = Room.Settings.QueueMode;
RoomUpdated?.Invoke();
@ -702,15 +703,7 @@ namespace osu.Game.Online.Multiplayer
private async Task<PlaylistItem> createPlaylistItem(MultiplayerPlaylistItem item)
{
var set = await GetOnlineBeatmapSet(item.BeatmapID).ConfigureAwait(false);
// The incoming response is deserialised without circular reference handling currently.
// Because we require using metadata from this instance, populate the nested beatmaps' sets manually here.
foreach (var b in set.Beatmaps)
b.BeatmapSet = set;
var beatmap = set.Beatmaps.Single(b => b.OnlineID == item.BeatmapID);
beatmap.Checksum = item.BeatmapChecksum;
var apiBeatmap = await GetAPIBeatmap(item.BeatmapID).ConfigureAwait(false);
var ruleset = Rulesets.GetRuleset(item.RulesetID);
var rulesetInstance = ruleset.CreateInstance();
@ -719,7 +712,7 @@ namespace osu.Game.Online.Multiplayer
{
ID = item.ID,
OwnerID = item.OwnerID,
Beatmap = { Value = beatmap },
Beatmap = { Value = apiBeatmap },
Ruleset = { Value = ruleset },
Expired = item.Expired
};
@ -731,12 +724,12 @@ namespace osu.Game.Online.Multiplayer
}
/// <summary>
/// Retrieves a <see cref="APIBeatmapSet"/> from an online source.
/// Retrieves a <see cref="APIBeatmap"/> from an online source.
/// </summary>
/// <param name="beatmapId">The beatmap set ID.</param>
/// <param name="beatmapId">The beatmap ID.</param>
/// <param name="cancellationToken">A token to cancel the request.</param>
/// <returns>The <see cref="APIBeatmapSet"/> retrieval task.</returns>
protected abstract Task<APIBeatmapSet> GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default);
/// <returns>The <see cref="APIBeatmap"/> retrieval task.</returns>
protected abstract Task<APIBeatmap> GetAPIBeatmap(int beatmapId, CancellationToken cancellationToken = default);
/// <summary>
/// For the provided user ID, update whether the user is included in <see cref="CurrentMatchPlayingUserIds"/>.

View File

@ -9,8 +9,8 @@ using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR.Client;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Game.Database;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms;
@ -29,6 +29,9 @@ namespace osu.Game.Online.Multiplayer
private HubConnection? connection => connector?.CurrentConnection;
[Resolved]
private BeatmapLookupCache beatmapLookupCache { get; set; } = null!;
public OnlineMultiplayerClient(EndpointConfiguration endpoints)
{
endpoint = endpoints.MultiplayerEndpointUrl;
@ -159,27 +162,9 @@ namespace osu.Game.Online.Multiplayer
return connection.InvokeAsync(nameof(IMultiplayerServer.AddPlaylistItem), item);
}
protected override Task<APIBeatmapSet> GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default)
protected override Task<APIBeatmap> GetAPIBeatmap(int beatmapId, CancellationToken cancellationToken = default)
{
var tcs = new TaskCompletionSource<APIBeatmapSet>();
var req = new GetBeatmapSetRequest(beatmapId, BeatmapSetLookupType.BeatmapId);
req.Success += res =>
{
if (cancellationToken.IsCancellationRequested)
{
tcs.SetCanceled();
return;
}
tcs.SetResult(res);
};
req.Failure += e => tcs.SetException(e);
API.Queue(req);
return tcs.Task;
return beatmapLookupCache.GetBeatmapAsync(beatmapId, cancellationToken);
}
protected override void Dispose(bool isDisposing)

View File

@ -142,6 +142,7 @@ namespace osu.Game
private BeatmapDifficultyCache difficultyCache;
private UserLookupCache userCache;
private BeatmapLookupCache beatmapCache;
private FileStore fileStore;
@ -259,6 +260,9 @@ namespace osu.Game
dependencies.Cache(userCache = new UserLookupCache());
AddInternal(userCache);
dependencies.Cache(beatmapCache = new BeatmapLookupCache());
AddInternal(beatmapCache);
var scorePerformanceManager = new ScorePerformanceCache();
dependencies.Cache(scorePerformanceManager);
AddInternal(scorePerformanceManager);

View File

@ -95,13 +95,13 @@ namespace osu.Game.Overlays
/// Displays the provided <see cref="Toast"/> temporarily.
/// </summary>
/// <param name="toast"></param>
public void Display(Toast toast)
public void Display(Toast toast) => Schedule(() =>
{
box.Child = toast;
DisplayTemporarily(box);
}
});
private void displayTrackedSettingChange(SettingDescription description) => Schedule(() => Display(new TrackedSettingToast(description)));
private void displayTrackedSettingChange(SettingDescription description) => Display(new TrackedSettingToast(description));
private TransformSequence<Drawable> fadeIn;
private ScheduledDelegate fadeOut;

View File

@ -336,7 +336,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
public override Task AddPlaylistItem(MultiplayerPlaylistItem item) => AddUserPlaylistItem(api.LocalUser.Value.OnlineID, item);
protected override Task<APIBeatmapSet> GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default)
protected override Task<APIBeatmap> GetAPIBeatmap(int beatmapId, CancellationToken cancellationToken = default)
{
IBeatmapSetInfo? set = roomManager.ServerSideRooms.SelectMany(r => r.Playlist)
.FirstOrDefault(p => p.BeatmapID == beatmapId)?.Beatmap.Value.BeatmapSet
@ -345,13 +345,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
if (set == null)
throw new InvalidOperationException("Beatmap not found.");
var apiSet = new APIBeatmapSet
return Task.FromResult(new APIBeatmap
{
OnlineID = set.OnlineID,
Beatmaps = set.Beatmaps.Select(b => new APIBeatmap { OnlineID = b.OnlineID }).ToArray(),
};
return Task.FromResult(apiSet);
BeatmapSet = new APIBeatmapSet { OnlineID = set.OnlineID },
OnlineID = beatmapId,
Checksum = set.Beatmaps.First(b => b.OnlineID == beatmapId).MD5Hash
});
}
private async Task changeMatchType(MatchType type)