1
0
mirror of https://github.com/ppy/osu.git synced 2024-09-21 15:27:24 +08:00

Merge pull request #27156 from bdach/score-statistics-updates-working-2

Watch online statistics changes after every play & display them in toolbar
This commit is contained in:
Dean Herbert 2024-02-15 03:21:42 +08:00 committed by GitHub
commit ebea0d283e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 382 additions and 90 deletions

View File

@ -2,12 +2,17 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Testing;
using osu.Game.Online.API;
using osu.Game.Online.Solo;
using osu.Game.Overlays.Toolbar;
using osu.Game.Scoring;
using osu.Game.Users;
using osuTK;
using osuTK.Graphics;
@ -87,5 +92,91 @@ namespace osu.Game.Tests.Visual.Menus
AddStep($"Change state to {state}", () => dummyAPI.SetState(state));
}
}
[Test]
public void TestTransientUserStatisticsDisplay()
{
AddStep("Log in", () => dummyAPI.Login("wang", "jang"));
AddStep("Gain", () =>
{
var transientUpdateDisplay = this.ChildrenOfType<TransientUserStatisticsUpdateDisplay>().Single();
transientUpdateDisplay.LatestUpdate.Value = new SoloStatisticsUpdate(
new ScoreInfo(),
new UserStatistics
{
GlobalRank = 123_456,
PP = 1234
},
new UserStatistics
{
GlobalRank = 111_111,
PP = 1357
});
});
AddStep("Loss", () =>
{
var transientUpdateDisplay = this.ChildrenOfType<TransientUserStatisticsUpdateDisplay>().Single();
transientUpdateDisplay.LatestUpdate.Value = new SoloStatisticsUpdate(
new ScoreInfo(),
new UserStatistics
{
GlobalRank = 111_111,
PP = 1357
},
new UserStatistics
{
GlobalRank = 123_456,
PP = 1234
});
});
AddStep("No change", () =>
{
var transientUpdateDisplay = this.ChildrenOfType<TransientUserStatisticsUpdateDisplay>().Single();
transientUpdateDisplay.LatestUpdate.Value = new SoloStatisticsUpdate(
new ScoreInfo(),
new UserStatistics
{
GlobalRank = 111_111,
PP = 1357
},
new UserStatistics
{
GlobalRank = 111_111,
PP = 1357
});
});
AddStep("Was null", () =>
{
var transientUpdateDisplay = this.ChildrenOfType<TransientUserStatisticsUpdateDisplay>().Single();
transientUpdateDisplay.LatestUpdate.Value = new SoloStatisticsUpdate(
new ScoreInfo(),
new UserStatistics
{
GlobalRank = null,
PP = null
},
new UserStatistics
{
GlobalRank = 111_111,
PP = 1357
});
});
AddStep("Became null", () =>
{
var transientUpdateDisplay = this.ChildrenOfType<TransientUserStatisticsUpdateDisplay>().Single();
transientUpdateDisplay.LatestUpdate.Value = new SoloStatisticsUpdate(
new ScoreInfo(),
new UserStatistics
{
GlobalRank = 111_111,
PP = 1357
},
new UserStatistics
{
GlobalRank = null,
PP = null
});
});
}
}
}

View File

@ -35,8 +35,6 @@ namespace osu.Game.Tests.Visual.Online
private Action<GetUsersRequest>? handleGetUsersRequest;
private Action<GetUserRequest>? handleGetUserRequest;
private IDisposable? subscription;
private readonly Dictionary<(int userId, string rulesetName), UserStatistics> serverSideStatistics = new Dictionary<(int userId, string rulesetName), UserStatistics>();
[SetUpSteps]
@ -252,26 +250,6 @@ namespace osu.Game.Tests.Visual.Online
AddAssert("values after are correct", () => update!.After.TotalScore, () => Is.EqualTo(6_000_000));
}
[Test]
public void TestStatisticsUpdateNotFiredAfterSubscriptionDisposal()
{
int userId = getUserId();
setUpUser(userId);
long scoreId = getScoreId();
var ruleset = new OsuRuleset().RulesetInfo;
SoloStatisticsUpdate? update = null;
registerForUpdates(scoreId, ruleset, receivedUpdate => update = receivedUpdate);
AddStep("unsubscribe", () => subscription!.Dispose());
feignScoreProcessing(userId, ruleset, 5_000_000);
AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(userId, scoreId));
AddWaitStep("wait a bit", 5);
AddAssert("update not received", () => update == null);
}
[Test]
public void TestGlobalStatisticsUpdatedAfterRegistrationAddedAndScoreProcessed()
{
@ -312,13 +290,20 @@ namespace osu.Game.Tests.Visual.Online
}
private void registerForUpdates(long scoreId, RulesetInfo rulesetInfo, Action<SoloStatisticsUpdate> onUpdateReady) =>
AddStep("register for updates", () => subscription = watcher.RegisterForStatisticsUpdateAfter(
new ScoreInfo(Beatmap.Value.BeatmapInfo, new OsuRuleset().RulesetInfo, new RealmUser())
AddStep("register for updates", () =>
{
watcher.RegisterForStatisticsUpdateAfter(
new ScoreInfo(Beatmap.Value.BeatmapInfo, new OsuRuleset().RulesetInfo, new RealmUser())
{
Ruleset = rulesetInfo,
OnlineID = scoreId
});
watcher.LatestUpdate.BindValueChanged(update =>
{
Ruleset = rulesetInfo,
OnlineID = scoreId
},
onUpdateReady));
if (update.NewValue?.Score.OnlineID == scoreId)
onUpdateReady.Invoke(update.NewValue);
});
});
private void feignScoreProcessing(int userId, RulesetInfo rulesetInfo, long newTotalScore)
=> AddStep("feign score processing", () => serverSideStatistics[(userId, rulesetInfo.ShortName)] = new UserStatistics { TotalScore = newTotalScore });

View File

@ -1,10 +1,10 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Game.Extensions;
@ -22,14 +22,16 @@ namespace osu.Game.Online.Solo
/// </summary>
public partial class SoloStatisticsWatcher : Component
{
public IBindable<SoloStatisticsUpdate?> LatestUpdate => latestUpdate;
private readonly Bindable<SoloStatisticsUpdate?> latestUpdate = new Bindable<SoloStatisticsUpdate?>();
[Resolved]
private SpectatorClient spectatorClient { get; set; } = null!;
[Resolved]
private IAPIProvider api { get; set; } = null!;
private readonly Dictionary<long, StatisticsUpdateCallback> callbacks = new Dictionary<long, StatisticsUpdateCallback>();
private long? lastProcessedScoreId;
private readonly Dictionary<long, ScoreInfo> watchedScores = new Dictionary<long, ScoreInfo>();
private Dictionary<string, UserStatistics>? latestStatistics;
@ -45,9 +47,7 @@ namespace osu.Game.Online.Solo
/// Registers for a user statistics update after the given <paramref name="score"/> has been processed server-side.
/// </summary>
/// <param name="score">The score to listen for the statistics update for.</param>
/// <param name="onUpdateReady">The callback to be invoked once the statistics update has been prepared.</param>
/// <returns>An <see cref="IDisposable"/> representing the subscription. Disposing it is equivalent to unsubscribing from future notifications.</returns>
public IDisposable RegisterForStatisticsUpdateAfter(ScoreInfo score, Action<SoloStatisticsUpdate> onUpdateReady)
public void RegisterForStatisticsUpdateAfter(ScoreInfo score)
{
Schedule(() =>
{
@ -57,24 +57,12 @@ namespace osu.Game.Online.Solo
if (!score.Ruleset.IsLegacyRuleset() || score.OnlineID <= 0)
return;
var callback = new StatisticsUpdateCallback(score, onUpdateReady);
if (lastProcessedScoreId == score.OnlineID)
{
requestStatisticsUpdate(api.LocalUser.Value.Id, callback);
return;
}
callbacks.Add(score.OnlineID, callback);
watchedScores.Add(score.OnlineID, score);
});
return new InvokeOnDisposal(() => Schedule(() => callbacks.Remove(score.OnlineID)));
}
private void onUserChanged(APIUser? localUser) => Schedule(() =>
{
callbacks.Clear();
lastProcessedScoreId = null;
latestStatistics = null;
if (localUser == null || localUser.OnlineID <= 1)
@ -107,25 +95,22 @@ namespace osu.Game.Online.Solo
if (userId != api.LocalUser.Value?.OnlineID)
return;
lastProcessedScoreId = scoreId;
if (!callbacks.TryGetValue(scoreId, out var callback))
if (!watchedScores.Remove(scoreId, out var scoreInfo))
return;
requestStatisticsUpdate(userId, callback);
callbacks.Remove(scoreId);
requestStatisticsUpdate(userId, scoreInfo);
}
private void requestStatisticsUpdate(int userId, StatisticsUpdateCallback callback)
private void requestStatisticsUpdate(int userId, ScoreInfo scoreInfo)
{
var request = new GetUserRequest(userId, callback.Score.Ruleset);
request.Success += user => Schedule(() => dispatchStatisticsUpdate(callback, user.Statistics));
var request = new GetUserRequest(userId, scoreInfo.Ruleset);
request.Success += user => Schedule(() => dispatchStatisticsUpdate(scoreInfo, user.Statistics));
api.Queue(request);
}
private void dispatchStatisticsUpdate(StatisticsUpdateCallback callback, UserStatistics updatedStatistics)
private void dispatchStatisticsUpdate(ScoreInfo scoreInfo, UserStatistics updatedStatistics)
{
string rulesetName = callback.Score.Ruleset.ShortName;
string rulesetName = scoreInfo.Ruleset.ShortName;
api.UpdateStatistics(updatedStatistics);
@ -135,9 +120,7 @@ namespace osu.Game.Online.Solo
latestStatistics.TryGetValue(rulesetName, out UserStatistics? latestRulesetStatistics);
latestRulesetStatistics ??= new UserStatistics();
var update = new SoloStatisticsUpdate(callback.Score, latestRulesetStatistics, updatedStatistics);
callback.OnUpdateReady.Invoke(update);
latestUpdate.Value = new SoloStatisticsUpdate(scoreInfo, latestRulesetStatistics, updatedStatistics);
latestStatistics[rulesetName] = updatedStatistics;
}
@ -148,17 +131,5 @@ namespace osu.Game.Online.Solo
base.Dispose(isDisposing);
}
private class StatisticsUpdateCallback
{
public ScoreInfo Score { get; }
public Action<SoloStatisticsUpdate> OnUpdateReady { get; }
public StatisticsUpdateCallback(ScoreInfo score, Action<SoloStatisticsUpdate> onUpdateReady)
{
Score = score;
OnUpdateReady = onUpdateReady;
}
}
}
}

View File

@ -47,6 +47,7 @@ using osu.Game.Localisation;
using osu.Game.Online;
using osu.Game.Online.Chat;
using osu.Game.Online.Rooms;
using osu.Game.Online.Solo;
using osu.Game.Overlays;
using osu.Game.Overlays.BeatmapListing;
using osu.Game.Overlays.Music;
@ -1021,6 +1022,7 @@ namespace osu.Game
ScreenStack.Push(CreateLoader().With(l => l.RelativeSizeAxes = Axes.Both));
});
loadComponentSingleFile(new SoloStatisticsWatcher(), Add, true);
loadComponentSingleFile(Toolbar = new Toolbar
{
OnHome = delegate

View File

@ -50,7 +50,6 @@ using osu.Game.Online.API;
using osu.Game.Online.Chat;
using osu.Game.Online.Metadata;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Solo;
using osu.Game.Online.Spectator;
using osu.Game.Overlays;
using osu.Game.Overlays.Settings;
@ -207,7 +206,6 @@ namespace osu.Game
protected MultiplayerClient MultiplayerClient { get; private set; }
private MetadataClient metadataClient;
private SoloStatisticsWatcher soloStatisticsWatcher;
private RealmAccess realm;
@ -328,7 +326,6 @@ namespace osu.Game
dependencies.CacheAs(SpectatorClient = new OnlineSpectatorClient(endpoints));
dependencies.CacheAs(MultiplayerClient = new OnlineMultiplayerClient(endpoints));
dependencies.CacheAs(metadataClient = new OnlineMetadataClient(endpoints));
dependencies.CacheAs(soloStatisticsWatcher = new SoloStatisticsWatcher());
base.Content.Add(new BeatmapOnlineChangeIngest(beatmapUpdater, realm, metadataClient));
@ -371,7 +368,6 @@ namespace osu.Game
base.Content.Add(SpectatorClient);
base.Content.Add(MultiplayerClient);
base.Content.Add(metadataClient);
base.Content.Add(soloStatisticsWatcher);
base.Content.Add(rulesetConfigCache);

View File

@ -78,6 +78,13 @@ namespace osu.Game.Overlays.Toolbar
}
});
Flow.Add(new TransientUserStatisticsUpdateDisplay
{
Alpha = 0
});
Flow.AutoSizeEasing = Easing.OutQuint;
Flow.AutoSizeDuration = 250;
apiState = api.State.GetBoundCopy();
apiState.BindValueChanged(onlineStateChanged, true);

View File

@ -0,0 +1,235 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Framework.Threading;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Solo;
using osu.Game.Resources.Localisation.Web;
using osuTK;
namespace osu.Game.Overlays.Toolbar
{
public partial class TransientUserStatisticsUpdateDisplay : CompositeDrawable
{
public Bindable<SoloStatisticsUpdate?> LatestUpdate { get; } = new Bindable<SoloStatisticsUpdate?>();
private Statistic<int> globalRank = null!;
private Statistic<decimal> pp = null!;
[BackgroundDependencyLoader]
private void load(SoloStatisticsWatcher? soloStatisticsWatcher)
{
RelativeSizeAxes = Axes.Y;
AutoSizeAxes = Axes.X;
Alpha = 0;
InternalChild = new FillFlowContainer
{
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
Padding = new MarginPadding { Horizontal = 10 },
Spacing = new Vector2(10),
Direction = FillDirection.Horizontal,
Children = new Drawable[]
{
globalRank = new Statistic<int>(UsersStrings.ShowRankGlobalSimple, @"#", Comparer<int>.Create((before, after) => before - after)),
pp = new Statistic<decimal>(RankingsStrings.StatPerformance, string.Empty, Comparer<decimal>.Create((before, after) => Math.Sign(after - before))),
}
};
if (soloStatisticsWatcher != null)
((IBindable<SoloStatisticsUpdate?>)LatestUpdate).BindTo(soloStatisticsWatcher.LatestUpdate);
}
protected override void LoadComplete()
{
base.LoadComplete();
LatestUpdate.BindValueChanged(val =>
{
if (val.NewValue == null)
return;
var update = val.NewValue;
// null handling here is best effort because it is annoying.
globalRank.Alpha = update.After.GlobalRank == null ? 0 : 1;
pp.Alpha = update.After.PP == null ? 0 : 1;
if (globalRank.Alpha == 0 && pp.Alpha == 0)
return;
FinishTransforms(true);
this.FadeIn(500, Easing.OutQuint);
if (update.After.GlobalRank != null)
{
globalRank.Display(
update.Before.GlobalRank ?? update.After.GlobalRank.Value,
Math.Abs((update.After.GlobalRank.Value - update.Before.GlobalRank) ?? 0),
update.After.GlobalRank.Value);
}
if (update.After.PP != null)
pp.Display(update.Before.PP ?? update.After.PP.Value, Math.Abs((update.After.PP - update.Before.PP) ?? 0M), update.After.PP.Value);
this.Delay(5000).FadeOut(500, Easing.OutQuint);
});
}
private partial class Statistic<T> : CompositeDrawable
where T : struct, IEquatable<T>, IFormattable
{
private readonly LocalisableString title;
private readonly string mainValuePrefix;
private readonly IComparer<T> valueComparer;
private Counter<T> mainValue = null!;
private Counter<T> deltaValue = null!;
private OsuSpriteText titleText = null!;
private ScheduledDelegate? valueUpdateSchedule;
[Resolved]
private OsuColour colours { get; set; } = null!;
public Statistic(LocalisableString title, string mainValuePrefix, IComparer<T> valueComparer)
{
this.title = title;
this.mainValuePrefix = mainValuePrefix;
this.valueComparer = valueComparer;
}
[BackgroundDependencyLoader]
private void load()
{
RelativeSizeAxes = Axes.Y;
AutoSizeAxes = Axes.X;
InternalChild = new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Direction = FillDirection.Vertical,
Children = new Drawable[]
{
mainValue = new Counter<T>
{
ValuePrefix = mainValuePrefix,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
},
new Container
{
AutoSizeAxes = Axes.Both,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Children = new Drawable[]
{
deltaValue = new Counter<T>
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Font = OsuFont.Default.With(size: 12, fixedWidth: true, weight: FontWeight.SemiBold),
AlwaysPresent = true,
},
titleText = new OsuSpriteText
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Font = OsuFont.Default.With(size: 12, weight: FontWeight.SemiBold),
Text = title,
AlwaysPresent = true,
}
}
}
}
};
}
public void Display(T before, T delta, T after)
{
valueUpdateSchedule?.Cancel();
valueUpdateSchedule = null;
int comparison = valueComparer.Compare(before, after);
if (comparison > 0)
{
deltaValue.Colour = colours.Lime1;
deltaValue.ValuePrefix = "+";
}
else if (comparison < 0)
{
deltaValue.Colour = colours.Red1;
deltaValue.ValuePrefix = "-";
}
else
{
deltaValue.Colour = Colour4.White;
deltaValue.ValuePrefix = string.Empty;
}
mainValue.SetCountWithoutRolling(before);
deltaValue.SetCountWithoutRolling(delta);
titleText.Alpha = 1;
deltaValue.Alpha = 0;
using (BeginDelayedSequence(1200))
{
titleText.FadeOut(250, Easing.OutQuad);
deltaValue.FadeIn(250, Easing.OutQuad);
using (BeginDelayedSequence(1250))
{
valueUpdateSchedule = Schedule(() =>
{
mainValue.Current.Value = after;
deltaValue.Current.SetDefault();
});
}
}
}
}
private partial class Counter<T> : RollingCounter<T>
where T : struct, IEquatable<T>, IFormattable
{
public FontUsage Font { get; init; } = OsuFont.Default.With(fixedWidth: true);
public string ValuePrefix
{
get => valuePrefix;
set
{
valuePrefix = value;
UpdateDisplay();
}
}
private string valuePrefix = string.Empty;
protected override LocalisableString FormatCount(T count) => LocalisableString.Format(@"{0}{1:N0}", ValuePrefix, count);
protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(t =>
{
t.Font = Font;
t.Spacing = new Vector2(-1.5f, 0);
});
protected override double RollingDuration => 1500;
}
}
}

View File

@ -17,6 +17,7 @@ using osu.Game.Database;
using osu.Game.Online.API;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Online.Solo;
using osu.Game.Online.Spectator;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
@ -42,6 +43,10 @@ namespace osu.Game.Screens.Play
[Resolved]
private SessionStatics statics { get; set; }
[Resolved(canBeNull: true)]
[CanBeNull]
private SoloStatisticsWatcher soloStatisticsWatcher { get; set; }
private readonly object scoreSubmissionLock = new object();
private TaskCompletionSource<bool> scoreSubmissionSource;
@ -175,6 +180,7 @@ namespace osu.Game.Screens.Play
await submitScore(score).ConfigureAwait(false);
spectatorClient.EndPlaying(GameplayState);
soloStatisticsWatcher?.RegisterForStatisticsUpdateAfter(score.ScoreInfo);
}
[Resolved]

View File

@ -41,9 +41,6 @@ namespace osu.Game.Screens.Ranking
public override bool? AllowGlobalTrackControl => true;
// Temporary for now to stop dual transitions. Should respect the current toolbar mode, but there's no way to do so currently.
public override bool HideOverlaysOnEnter => true;
public readonly Bindable<ScoreInfo> SelectedScore = new Bindable<ScoreInfo>();
[CanBeNull]

View File

@ -31,10 +31,7 @@ namespace osu.Game.Screens.Ranking
[Resolved]
private RulesetStore rulesets { get; set; } = null!;
[Resolved]
private SoloStatisticsWatcher soloStatisticsWatcher { get; set; } = null!;
private IDisposable? statisticsSubscription;
private IBindable<SoloStatisticsUpdate?> latestUpdate = null!;
private readonly Bindable<SoloStatisticsUpdate?> statisticsUpdate = new Bindable<SoloStatisticsUpdate?>();
public SoloResultsScreen(ScoreInfo score, bool allowRetry)
@ -42,14 +39,20 @@ namespace osu.Game.Screens.Ranking
{
}
protected override void LoadComplete()
[BackgroundDependencyLoader]
private void load(SoloStatisticsWatcher? soloStatisticsWatcher)
{
base.LoadComplete();
if (ShowUserStatistics && soloStatisticsWatcher != null)
{
Debug.Assert(Score != null);
Debug.Assert(Score != null);
if (ShowUserStatistics)
statisticsSubscription = soloStatisticsWatcher.RegisterForStatisticsUpdateAfter(Score, update => statisticsUpdate.Value = update);
latestUpdate = soloStatisticsWatcher.LatestUpdate.GetBoundCopy();
latestUpdate.BindValueChanged(update =>
{
if (update.NewValue?.Score.MatchesOnlineID(Score) == true)
statisticsUpdate.Value = update.NewValue;
});
}
}
protected override StatisticsPanel CreateStatisticsPanel()
@ -84,7 +87,6 @@ namespace osu.Game.Screens.Ranking
base.Dispose(isDisposing);
getScoreRequest?.Cancel();
statisticsSubscription?.Dispose();
}
}
}