From 207567ed1598d21172d68775add76a4b4ef6ac71 Mon Sep 17 00:00:00 2001 From: KermitNuggies Date: Sat, 14 Feb 2026 19:30:08 +1300 Subject: [PATCH 1/4] Swap extended score from using SoloScoreInfo to IScoreInfo (and update all uses) --- .../Components/ExtendedProfileScore.cs | 86 ++++++++++++------- .../Screens/Collections/ScoreContainer.cs | 6 +- .../Screens/CollectionsScreen.cs | 8 +- .../Screens/ProfileScreen.cs | 10 +-- 4 files changed, 66 insertions(+), 44 deletions(-) diff --git a/PerformanceCalculatorGUI/Components/ExtendedProfileScore.cs b/PerformanceCalculatorGUI/Components/ExtendedProfileScore.cs index 38e553fbb3..e5d08ce551 100644 --- a/PerformanceCalculatorGUI/Components/ExtendedProfileScore.cs +++ b/PerformanceCalculatorGUI/Components/ExtendedProfileScore.cs @@ -18,14 +18,17 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Leaderboards; using osu.Game.Overlays; using osu.Game.Overlays.Profile.Sections; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; +using osu.Game.Scoring; using osu.Game.Users.Drawables; using osu.Game.Utils; using osuTK; @@ -36,7 +39,7 @@ namespace PerformanceCalculatorGUI.Components { public class ExtendedScore { - public SoloScoreInfo SoloScore { get; } + public IScoreInfo Score { get; } public double? LivePP { get; } public Bindable Position { get; } = new Bindable(); @@ -45,13 +48,32 @@ public class ExtendedScore public PerformanceAttributes? PerformanceAttributes { get; } public DifficultyAttributes DifficultyAttributes { get; } - public ExtendedScore(SoloScoreInfo score, DifficultyAttributes difficultyAttributes, PerformanceAttributes? performanceAttributes) + public ExtendedScore(IScoreInfo score, DifficultyAttributes difficultyAttributes, PerformanceAttributes? performanceAttributes) { - SoloScore = score; + Score = score; PerformanceAttributes = performanceAttributes; DifficultyAttributes = difficultyAttributes; LivePP = score.PP; } + + public Dictionary? Statistics() + { + if (Score is SoloScoreInfo soloScore) + return soloScore.Statistics; + if (Score is ScoreInfo scoreInfo) + return scoreInfo.Statistics; + return null; + } + public APIMod[]? Mods() + { + if (Score is ScoreInfo scoreInfo) + return scoreInfo.Mods.Select(m => new APIMod(m)).ToArray(); + if (Score is SoloScoreInfo soloScoreInfo) + { + return soloScoreInfo.Mods; + } + return null; + } } public partial class ExtendedProfileItemContainer : ProfileItemContainer @@ -88,7 +110,7 @@ public partial class ExtendedProfileScore : CompositeDrawable private const float performance_background_shear = 0.45f; - public readonly ExtendedScore Score; + public readonly ExtendedScore ExtScore; public readonly bool ShowAvatar; @@ -102,7 +124,7 @@ public partial class ExtendedProfileScore : CompositeDrawable public ExtendedProfileScore(ExtendedScore score, bool showAvatar = false) { - Score = score; + ExtScore = score; ShowAvatar = showAvatar; RelativeSizeAxes = Axes.X; @@ -114,27 +136,27 @@ private void load(GameHost host, RulesetStore rulesets) { int avatarPadding = ShowAvatar ? avatar_size : 0; int rankDifferenceWidth = ShowAvatar ? 8 : rank_difference_width; - var scoreRuleset = rulesets.GetRuleset(Score.SoloScore.RulesetID)?.CreateInstance() ?? throw new InvalidOperationException(); + var scoreRuleset = rulesets.GetRuleset(ExtScore.Score.Ruleset.OnlineID)?.CreateInstance() ?? throw new InvalidOperationException(); AddInternal(new ExtendedProfileItemContainer { OnHoverAction = () => { - positionChangeText.Text = $"#{Score.Position.Value}"; + positionChangeText.Text = $"#{ExtScore.Position.Value}"; }, OnUnhoverAction = () => { - positionChangeText.Text = $"{Score.PositionChange.Value:+0;-0;-}"; + positionChangeText.Text = $"{ExtScore.PositionChange.Value:+0;-0;-}"; }, Children = new[] { ShowAvatar - ? new ClickableAvatar(Score.SoloScore.User, true) + ? new ClickableAvatar((APIUser)ExtScore.Score.User, true) { Masking = true, CornerRadius = ExtendedLabelledTextBox.CORNER_RADIUS, Size = new Vector2(avatar_size), - Action = () => { host.OpenUrlExternally($"https://osu.ppy.sh/users/{Score.SoloScore.User?.Id}"); } + Action = () => { host.OpenUrlExternally($"https://osu.ppy.sh/users/{ExtScore.Score.User?.OnlineID}"); } } : Empty(), new Container @@ -151,7 +173,7 @@ private void load(GameHost host, RulesetStore rulesets) Anchor = Anchor.Centre, Origin = Anchor.Centre, Colour = colourProvider.Light1, - Text = $"{Score.PositionChange.Value:+0;-0;-}" + Text = $"{ExtScore.PositionChange.Value:+0;-0;-}" } }, new Container @@ -180,13 +202,13 @@ private void load(GameHost host, RulesetStore rulesets) Padding = new MarginPadding { Top = 2 }, Children = new Drawable[] { - new UpdateableRank(Score.SoloScore.Rank) + new UpdateableRank(ExtScore.Score.Rank) { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Size = new Vector2(40, 12), }, - new TinyStarRatingDisplay(Score.DifficultyAttributes) + new TinyStarRatingDisplay(ExtScore.DifficultyAttributes) { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, @@ -202,7 +224,7 @@ private void load(GameHost host, RulesetStore rulesets) Spacing = new Vector2(0, 0.5f), Children = new Drawable[] { - new ScoreBeatmapMetadataContainer(Score.SoloScore.Beatmap), + new ScoreBeatmapMetadataContainer(ExtScore.Score.Beatmap), new FillFlowContainer { AutoSizeAxes = Axes.Both, @@ -212,11 +234,11 @@ private void load(GameHost host, RulesetStore rulesets) { new OsuSpriteText { - Text = $"{Score.SoloScore.Beatmap?.DifficultyName}", + Text = $"{ExtScore.Score.Beatmap?.DifficultyName}", Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular), Colour = colours.Yellow }, - new DrawableDate(Score.SoloScore.EndedAt, 12) + new DrawableDate(ExtScore.Score.Date, 12) { Colour = colourProvider.Foreground1 } @@ -268,7 +290,7 @@ private void load(GameHost host, RulesetStore rulesets) { new OsuSpriteText { - Text = Score.SoloScore.Accuracy.FormatAccuracy(), + Text = ExtScore.Score.Accuracy.FormatAccuracy(), Font = OsuFont.GetFont(weight: FontWeight.Bold, italics: true), Colour = colours.Yellow, Anchor = Anchor.TopCentre, @@ -285,7 +307,7 @@ private void load(GameHost host, RulesetStore rulesets) formatCombo(), new OsuSpriteText { - Text = $"{{ {formatStatistics(Score.SoloScore.Statistics, scoreRuleset)} }}", + Text = $"{{ {formatStatistics(ExtScore.Statistics(), scoreRuleset)} }}", Font = OsuFont.GetFont(size: small_text_font_size, weight: FontWeight.Regular), Colour = colourProvider.Light2, Anchor = Anchor.TopCentre, @@ -310,7 +332,7 @@ private void load(GameHost host, RulesetStore rulesets) Child = new OsuSpriteText { Font = OsuFont.GetFont(weight: FontWeight.Bold), - Text = Score.LivePP != null ? $"{Score.LivePP:0}pp" : "- pp" + Text = ExtScore.LivePP != null ? $"{ExtScore.LivePP:0}pp" : "- pp" }, }, new OsuSpriteText @@ -332,7 +354,7 @@ private void load(GameHost host, RulesetStore rulesets) Origin = Anchor.CentreRight, Direction = FillDirection.Horizontal, Spacing = new Vector2(2), - Children = Score.SoloScore.Mods.Select(mod => new ModIcon(mod.ToMod(scoreRuleset)) + Children = ExtScore.Mods().Select(mod => new ModIcon(mod.ToMod(scoreRuleset)) { Scale = new Vector2(0.35f) }).ToList(), @@ -372,27 +394,27 @@ private void load(GameHost host, RulesetStore rulesets) Shear = new Vector2(performance_background_shear, 0), EdgeSmoothness = new Vector2(2, 0), }, - new ScorePerformanceContainer(Score) + new ScorePerformanceContainer(ExtScore) } } } }); - Score.PositionChange.BindValueChanged(v => { positionChangeText.Text = $"{v.NewValue:+0;-0;-}"; }); + ExtScore.PositionChange.BindValueChanged(v => { positionChangeText.Text = $"{v.NewValue:+0;-0;-}"; }); } private OsuSpriteText formatCombo() { - bool isFullCombo = Score.SoloScore.MaxCombo == Score.DifficultyAttributes.MaxCombo; + bool isFullCombo = ExtScore.Score.MaxCombo == ExtScore.DifficultyAttributes.MaxCombo; return new ExtendedOsuSpriteText { - Text = $"{Score.SoloScore.MaxCombo}x", + Text = $"{ExtScore.Score.MaxCombo}x", Font = OsuFont.GetFont(size: small_text_font_size, weight: FontWeight.Regular), Colour = isFullCombo ? colours.GreenLight : colourProvider.Light2, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - TooltipContent = $"{Score.SoloScore.MaxCombo} / {Score.DifficultyAttributes.MaxCombo}x" + TooltipContent = $"{ExtScore.Score.MaxCombo} / {ExtScore.DifficultyAttributes.MaxCombo}x" }; } @@ -471,14 +493,14 @@ private void load(GameHost host) private partial class ScorePerformanceContainer : OsuHoverContainer { - private readonly ExtendedScore score; + private readonly ExtendedScore extScore; [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; public ScorePerformanceContainer(ExtendedScore score) { - this.score = score; + this.extScore = score; RelativeSizeAxes = Axes.Both; Padding = new MarginPadding { @@ -493,7 +515,7 @@ private void load(PerformanceCalculatorSceneManager sceneManager) { Action = () => { - sceneManager.SwitchToSimulate(score.SoloScore.BeatmapID, score.SoloScore.ID); + sceneManager.SwitchToSimulate(extScore.Score.Beatmap.OnlineID, (ulong?)extScore.Score.OnlineID); }; Child = new FillFlowContainer @@ -507,16 +529,16 @@ private void load(PerformanceCalculatorSceneManager sceneManager) new ExtendedOsuSpriteText { Font = OsuFont.GetFont(weight: FontWeight.Bold), - Text = $"{score.PerformanceAttributes?.Total:0}pp", + Text = $"{extScore.PerformanceAttributes?.Total:0}pp", Colour = colourProvider.Highlight1, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - TooltipContent = $"{AttributeConversion.ToReadableString(score.PerformanceAttributes)}" + TooltipContent = $"{AttributeConversion.ToReadableString(extScore.PerformanceAttributes)}" }, new OsuSpriteText { Font = OsuFont.GetFont(size: small_text_font_size), - Text = $"{score.PerformanceAttributes?.Total - score.LivePP:+0.0;-0.0;-}", + Text = $"{extScore.PerformanceAttributes?.Total - extScore.LivePP:+0.0;-0.0;-}", Colour = getPpDifferenceColor(), Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre @@ -527,7 +549,7 @@ private void load(PerformanceCalculatorSceneManager sceneManager) private Color4 getPpDifferenceColor() { - double difference = score.PerformanceAttributes?.Total - score.LivePP ?? 0; + double difference = extScore.PerformanceAttributes?.Total - extScore.LivePP ?? 0; var baseColor = colourProvider.Light1; return difference switch diff --git a/PerformanceCalculatorGUI/Screens/Collections/ScoreContainer.cs b/PerformanceCalculatorGUI/Screens/Collections/ScoreContainer.cs index 5c4f8c4626..832c71657b 100644 --- a/PerformanceCalculatorGUI/Screens/Collections/ScoreContainer.cs +++ b/PerformanceCalculatorGUI/Screens/Collections/ScoreContainer.cs @@ -12,7 +12,7 @@ namespace PerformanceCalculatorGUI.Screens.Collections { public partial class ScoreContainer : Container { - public ExtendedScore Score { get; } + public ExtendedScore ExtScore { get; } private readonly IconButton deleteButton; @@ -25,7 +25,7 @@ public ScoreContainer(ExtendedScore score) RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - Score = score; + ExtScore = score; Child = new GridContainer { RelativeSizeAxes = Axes.X, @@ -43,7 +43,7 @@ public ScoreContainer(ExtendedScore score) Icon = FontAwesome.Regular.TrashAlt, Action = () => { - OnDelete?.Invoke((long)score.SoloScore.ID!); + OnDelete?.Invoke(ExtScore.Score.OnlineID!); } }, new ExtendedProfileScore(score, true) diff --git a/PerformanceCalculatorGUI/Screens/CollectionsScreen.cs b/PerformanceCalculatorGUI/Screens/CollectionsScreen.cs index e59ec8476d..1253094ac1 100644 --- a/PerformanceCalculatorGUI/Screens/CollectionsScreen.cs +++ b/PerformanceCalculatorGUI/Screens/CollectionsScreen.cs @@ -388,7 +388,7 @@ private void updateSorting(CollectionSortCriteria sortCriteria) { for (int i = 0; i < scoresList.Count; i++) { - scoresList.SetLayoutPosition(scoresList[i], Array.IndexOf(currentCollection.Value!.Scores, scoresList[i].Score.SoloScore.ID)); + scoresList.SetLayoutPosition(scoresList[i], Array.IndexOf(currentCollection.Value!.Scores, scoresList[i].ExtScore.Score.OnlineID)); } return; @@ -399,15 +399,15 @@ private void updateSorting(CollectionSortCriteria sortCriteria) switch (sortCriteria) { case CollectionSortCriteria.Live: - sortedScores = scoresList.Children.OrderByDescending(x => x.Score.LivePP).ToArray(); + sortedScores = scoresList.Children.OrderByDescending(x => x.ExtScore.LivePP).ToArray(); break; case CollectionSortCriteria.Local: - sortedScores = scoresList.Children.OrderByDescending(x => x.Score.PerformanceAttributes?.Total).ToArray(); + sortedScores = scoresList.Children.OrderByDescending(x => x.ExtScore.PerformanceAttributes?.Total).ToArray(); break; case CollectionSortCriteria.Difference: - sortedScores = scoresList.Children.OrderByDescending(x => x.Score.PerformanceAttributes?.Total - x.Score.LivePP).ToArray(); + sortedScores = scoresList.Children.OrderByDescending(x => x.ExtScore.PerformanceAttributes?.Total - x.ExtScore.LivePP).ToArray(); break; default: diff --git a/PerformanceCalculatorGUI/Screens/ProfileScreen.cs b/PerformanceCalculatorGUI/Screens/ProfileScreen.cs index 3fda177589..e48f220d81 100644 --- a/PerformanceCalculatorGUI/Screens/ProfileScreen.cs +++ b/PerformanceCalculatorGUI/Screens/ProfileScreen.cs @@ -371,11 +371,11 @@ private void calculateProfiles(string[] usernames) var filteredPlays = new List(); // List of all beatmap IDs in plays without duplicates - var beatmapIDs = plays.Select(x => x.SoloScore.BeatmapID).Distinct().ToList(); + var beatmapIDs = plays.Select(x => x.Score.Beatmap.OnlineID).Distinct().ToList(); foreach (int id in beatmapIDs) { - var bestPlayOnBeatmap = plays.Where(x => x.SoloScore.BeatmapID == id).OrderByDescending(x => x.PerformanceAttributes?.Total).First(); + var bestPlayOnBeatmap = plays.Where(x => x.Score.Beatmap.OnlineID == id).OrderByDescending(x => x.PerformanceAttributes?.Total).First(); filteredPlays.Add(bestPlayOnBeatmap); } @@ -472,15 +472,15 @@ private void updateSorting(ProfileSortCriteria sortCriteria) switch (sortCriteria) { case ProfileSortCriteria.Live: - sortedScores = scores.Children.OrderByDescending(x => x.Score.LivePP).ToArray(); + sortedScores = scores.Children.OrderByDescending(x => x.ExtScore.LivePP).ToArray(); break; case ProfileSortCriteria.Local: - sortedScores = scores.Children.OrderByDescending(x => x.Score.PerformanceAttributes?.Total).ToArray(); + sortedScores = scores.Children.OrderByDescending(x => x.ExtScore.PerformanceAttributes?.Total).ToArray(); break; case ProfileSortCriteria.Difference: - sortedScores = scores.Children.OrderByDescending(x => x.Score.PerformanceAttributes?.Total - x.Score.LivePP).ToArray(); + sortedScores = scores.Children.OrderByDescending(x => x.ExtScore.PerformanceAttributes?.Total - x.ExtScore.LivePP).ToArray(); break; default: From 82e27e114b2895a297cfd8c155c692167b6e3c50 Mon Sep 17 00:00:00 2001 From: KermitNuggies Date: Sat, 14 Feb 2026 19:35:52 +1300 Subject: [PATCH 2/4] add some comments justifying manual conversions --- .../Components/ExtendedProfileScore.cs | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/PerformanceCalculatorGUI/Components/ExtendedProfileScore.cs b/PerformanceCalculatorGUI/Components/ExtendedProfileScore.cs index e5d08ce551..86ad358fff 100644 --- a/PerformanceCalculatorGUI/Components/ExtendedProfileScore.cs +++ b/PerformanceCalculatorGUI/Components/ExtendedProfileScore.cs @@ -56,23 +56,31 @@ public ExtendedScore(IScoreInfo score, DifficultyAttributes difficultyAttributes LivePP = score.PP; } - public Dictionary? Statistics() + // IScoreInfo is missing statistics right now, but both SoloScoreInfo and ScoreInfo have it (and I believe it's planned for future) + // I think handling conversion at this level is the most elegant, although it means supporting any additional types that inherit from IScoreInfo will need to be manually added here + public Dictionary? Statistics { - if (Score is SoloScoreInfo soloScore) - return soloScore.Statistics; - if (Score is ScoreInfo scoreInfo) - return scoreInfo.Statistics; - return null; + get + { + if (Score is SoloScoreInfo soloScore) + return soloScore.Statistics; + if (Score is ScoreInfo scoreInfo) + return scoreInfo.Statistics; + return null; + } } - public APIMod[]? Mods() + // Exists for largely the same reasoning as Statistics, except rather than a missing field it's because APIMod doesn't inherit from IMod at all + // This returns APIMod[]? instead of Mod[]? because it means ruleset handling isn't necessary + public APIMod[]? Mods { - if (Score is ScoreInfo scoreInfo) - return scoreInfo.Mods.Select(m => new APIMod(m)).ToArray(); - if (Score is SoloScoreInfo soloScoreInfo) + get { - return soloScoreInfo.Mods; + if (Score is ScoreInfo scoreInfo) + return scoreInfo.Mods.Select(m => new APIMod(m)).ToArray(); + if (Score is SoloScoreInfo soloScoreInfo) + return soloScoreInfo.Mods; + return null; } - return null; } } @@ -307,7 +315,7 @@ private void load(GameHost host, RulesetStore rulesets) formatCombo(), new OsuSpriteText { - Text = $"{{ {formatStatistics(ExtScore.Statistics(), scoreRuleset)} }}", + Text = $"{{ {formatStatistics(ExtScore.Statistics, scoreRuleset)} }}", Font = OsuFont.GetFont(size: small_text_font_size, weight: FontWeight.Regular), Colour = colourProvider.Light2, Anchor = Anchor.TopCentre, @@ -354,7 +362,7 @@ private void load(GameHost host, RulesetStore rulesets) Origin = Anchor.CentreRight, Direction = FillDirection.Horizontal, Spacing = new Vector2(2), - Children = ExtScore.Mods().Select(mod => new ModIcon(mod.ToMod(scoreRuleset)) + Children = ExtScore.Mods.Select(mod => new ModIcon(mod.ToMod(scoreRuleset)) { Scale = new Vector2(0.35f) }).ToList(), From 1eb307b28c939b383a414f729493ea42a17c95a6 Mon Sep 17 00:00:00 2001 From: KermitNuggies Date: Sat, 14 Feb 2026 20:25:23 +1300 Subject: [PATCH 3/4] allow calculating from client.realm --- .../Components/SettingsPopover.cs | 8 + .../Configuration/SettingsManager.cs | 4 +- .../PerformanceCalculatorSceneManager.cs | 4 + .../Screens/RealmScreen.cs | 477 ++++++++++++++++++ .../bin/Debug/net8.0/realm/client.realm | 0 5 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 PerformanceCalculatorGUI/Screens/RealmScreen.cs create mode 100644 PerformanceCalculatorGUI/bin/Debug/net8.0/realm/client.realm diff --git a/PerformanceCalculatorGUI/Components/SettingsPopover.cs b/PerformanceCalculatorGUI/Components/SettingsPopover.cs index 34cc679308..0618597d81 100644 --- a/PerformanceCalculatorGUI/Components/SettingsPopover.cs +++ b/PerformanceCalculatorGUI/Components/SettingsPopover.cs @@ -28,6 +28,7 @@ public partial class SettingsPopover : OsuPopover private Bindable clientSecretBindable = null!; private Bindable pathBindable = null!; private Bindable cacheBindable = null!; + private Bindable realmBindable = null!; private Bindable scaleBindable = null!; private const string api_key_link = "https://osu.ppy.sh/home/account/edit#new-oauth-application"; @@ -40,6 +41,7 @@ private void load(SettingsManager configManager, OsuConfigManager osuConfig) clientSecretBindable = configManager.GetBindable(Settings.ClientSecret); pathBindable = configManager.GetBindable(Settings.DefaultPath); cacheBindable = configManager.GetBindable(Settings.CachePath); + realmBindable = configManager.GetBindable(Settings.RealmPath); scaleBindable = osuConfig.GetBindable(OsuSetting.UIScale); Add(new Container @@ -95,6 +97,12 @@ private void load(SettingsManager configManager, OsuConfigManager osuConfig) Label = "Beatmap cache path", Current = { BindTarget = cacheBindable } }, + new LabelledTextBox + { + RelativeSizeAxes = Axes.X, + Label = "Realm path", + Current = { BindTarget = realmBindable} + }, new Box { RelativeSizeAxes = Axes.X, diff --git a/PerformanceCalculatorGUI/Configuration/SettingsManager.cs b/PerformanceCalculatorGUI/Configuration/SettingsManager.cs index 19e0447c05..45b61190f7 100644 --- a/PerformanceCalculatorGUI/Configuration/SettingsManager.cs +++ b/PerformanceCalculatorGUI/Configuration/SettingsManager.cs @@ -13,7 +13,8 @@ public enum Settings ClientId, ClientSecret, DefaultPath, - CachePath + CachePath, + RealmPath } public class SettingsManager : IniConfigManager @@ -31,6 +32,7 @@ protected override void InitialiseDefaults() SetDefault(Settings.ClientSecret, string.Empty); SetDefault(Settings.DefaultPath, Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)); SetDefault(Settings.CachePath, Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "cache")); + SetDefault(Settings.RealmPath, Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "realm")); } } } diff --git a/PerformanceCalculatorGUI/PerformanceCalculatorSceneManager.cs b/PerformanceCalculatorGUI/PerformanceCalculatorSceneManager.cs index 73ed1d9c51..8eacde4095 100644 --- a/PerformanceCalculatorGUI/PerformanceCalculatorSceneManager.cs +++ b/PerformanceCalculatorGUI/PerformanceCalculatorSceneManager.cs @@ -115,6 +115,10 @@ private void load(OsuColour colours) { Action = () => setScreen(new CollectionsScreen()) }, + new ScreenSelectionButton("Realm", FontAwesome.Solid.List) + { + Action = () => setScreen(new RealmScreen()) + }, } }, new FillFlowContainer diff --git a/PerformanceCalculatorGUI/Screens/RealmScreen.cs b/PerformanceCalculatorGUI/Screens/RealmScreen.cs new file mode 100644 index 0000000000..d17c700908 --- /dev/null +++ b/PerformanceCalculatorGUI/Screens/RealmScreen.cs @@ -0,0 +1,477 @@ +// Copyright (c) ppy Pty Ltd . 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 System.Threading; +using System.Threading.Tasks; +using osu.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; +using PerformanceCalculatorGUI.Components; +using PerformanceCalculatorGUI.Components.TextBoxes; +using PerformanceCalculatorGUI.Configuration; +using PerformanceCalculatorGUI.Screens.Profile; +using ButtonState = PerformanceCalculatorGUI.Components.ButtonState; + +namespace PerformanceCalculatorGUI.Screens +{ + public partial class RealmScreen : PerformanceCalculatorScreen + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); + + private StatefulButton calculationButton = null!; + private SwitchButton includeUnrankedMaps = null!; + private SwitchButton includeUnrankedMods = null!; + private SwitchButton onlyDisplayBestCheckbox = null!; + private VerboseLoadingLayer loadingLayer = null!; + + private GridContainer layout = null!; + + private FillFlowContainer scores = null!; + + private LabelledTextBox usernameTextBox = null!; + private Container userPanelContainer = null!; + private UserCard? userPanel; + + private string username = ""; + + private CancellationTokenSource? calculationCancellatonToken; + + [Resolved] + private NotificationDisplay notificationDisplay { get; set; } = null!; + + [Resolved] + private APIManager apiManager { get; set; } = null!; + + [Resolved] + private Bindable ruleset { get; set; } = null!; + + [Resolved] + private SettingsManager configManager { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private GameHost gameHost { get; set; } = null!; + + public override bool ShouldShowConfirmationDialogOnSwitch => false; + + private const float username_container_height = 40; + + public RealmScreen() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6 + }, + layout = new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] { new Dimension() }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, username_container_height), + new Dimension(GridSizeMode.Absolute), + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + new GridContainer + { + Name = "Settings", + Height = username_container_height, + RelativeSizeAxes = Axes.X, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + usernameTextBox = new ExtendedLabelledTextBox + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.TopLeft, + Label = "Username", + PlaceholderText = "peppy", + CommitOnFocusLoss = false + }, + calculationButton = new StatefulButton("Start calculation") + { + Width = 150, + Height = username_container_height, + Action = () => { calculateProfile(usernameTextBox.Current.Value); } + } + } + } + }, + }, + new Drawable[] + { + userPanelContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + }, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Vertical = 2, Left = 10 }, + Spacing = new Vector2(5), + Children = new Drawable[] + { + includeUnrankedMaps = new SwitchButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Current = { Value = true }, + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.Torus.With(weight: FontWeight.SemiBold, size: 14), + UseFullGlyphHeight = false, + Text = "Include unranked maps" + }, + includeUnrankedMods = new SwitchButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Current = { Value = true }, + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.Torus.With(weight: FontWeight.SemiBold, size: 14), + UseFullGlyphHeight = false, + Text = "Include unranked mods" + }, + onlyDisplayBestCheckbox = new SwitchButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Current = { Value = true }, + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.Torus.With(weight: FontWeight.SemiBold, size: 14), + UseFullGlyphHeight = false, + Text = "Only display best score on each beatmap" + } + } + }, + } + } + }, + new Drawable[] + { + new OsuScrollContainer(Direction.Vertical) + { + RelativeSizeAxes = Axes.Both, + Child = scores = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical + } + } + }, + } + }, + loadingLayer = new VerboseLoadingLayer(true) + { + RelativeSizeAxes = Axes.Both + } + }; + + usernameTextBox.OnCommit += (_, _) => { calculateProfile(usernameTextBox.Current.Value); }; + includeUnrankedMaps.Current.ValueChanged += e => { calculateProfile(username); }; + includeUnrankedMods.Current.ValueChanged += e => { calculateProfile(username); }; + onlyDisplayBestCheckbox.Current.ValueChanged += e => { calculateProfile(username); }; + + if (RuntimeInfo.IsDesktop) + HotReloadCallbackReceiver.CompilationFinished += _ => Schedule(() => { calculateProfile(username); }); + } + + private void calculateProfile(string username) + { + if (username == "") + { + usernameTextBox.FlashColour(Color4.Red, 1); + return; + } + + var storage = gameHost.GetStorage(configManager.GetBindable(Settings.RealmPath).Value); + var realmAccess = new RealmAccess(storage, @"client.realm"); + + calculationCancellatonToken?.Cancel(); + calculationCancellatonToken?.Dispose(); + + loadingLayer.Show(); + calculationButton.State.Value = ButtonState.Loading; + + scores.Clear(); + + calculationCancellatonToken = new CancellationTokenSource(); + var token = calculationCancellatonToken.Token; + + Task.Run(async () => + { + Schedule(() => + { + if (userPanel != null) + userPanelContainer.Remove(userPanel, true); + + layout.RowDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, username_container_height), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }; + }); + + if (token.IsCancellationRequested) + return; + + var plays = new List(); + APIUser player = null; + var rulesetInstance = ruleset.Value.CreateInstance(); + string[] playerUsernames; + + try + { + Schedule(() => loadingLayer.Text.Value = $"Getting {username} user data..."); + + player = await apiManager.GetJsonFromApi($"users/{username}/{ruleset.Value.ShortName}").ConfigureAwait(false); + playerUsernames = [player.Username, .. player.PreviousUsernames, player.Id.ToString()]; // double check player id + + Schedule(() => loadingLayer.Text.Value = $"Calculating {player.Username} top scores..."); + + var realmScores = realmAccess.Run(r => r.All().Detach()) // ideally work out how to use Live<> + .Where(x => playerUsernames.Any(name => name.Equals(x.User.Username, StringComparison.OrdinalIgnoreCase)) && + x.BeatmapInfo != null && + x.Passed == true && x.Rank != ScoreRank.F && + x.Ruleset.OnlineID == ruleset.Value.OnlineID && + x.BeatmapInfo.OnlineID != -1) + .ToList(); + if (!includeUnrankedMaps.Current.Value) + realmScores.RemoveAll(x => x.BeatmapInfo.Status != BeatmapOnlineStatus.Ranked); + if (!includeUnrankedMods.Current.Value) + realmScores.RemoveAll(x => x.Mods.Any(mod => !mod.Ranked)); + + foreach (var score in realmScores) + { + if (token.IsCancellationRequested) + return; + + var working = ProcessorWorkingBeatmap.FromFileOrId(score.BeatmapInfo.OnlineID.ToString(), cachePath: configManager.GetBindable(Settings.CachePath).Value); + + Schedule(() => loadingLayer.Text.Value = $"Calculating {working.Metadata}"); + + var difficultyCalculator = rulesetInstance.CreateDifficultyCalculator(working); + var difficultyAttributes = difficultyCalculator.Calculate(score.Mods); + var performanceCalculator = rulesetInstance.CreatePerformanceCalculator(); + if (performanceCalculator == null) + continue; + + var perfAttributes = await performanceCalculator.CalculateAsync(score, difficultyAttributes, token).ConfigureAwait(false); + var extendedScore = new ExtendedScore(score, difficultyAttributes, perfAttributes); + plays.Add(extendedScore); + } + } + catch (Exception ex) + { + Logger.Log(ex.ToString(), level: LogLevel.Error); + notificationDisplay.Display(new Notification($"Failed to calculate {username}: {ex.Message}")); + } + + + if (token.IsCancellationRequested) + return; + + Schedule(() => + { + userPanelContainer.Add(userPanel = new UserCard(player) + { + RelativeSizeAxes = Axes.X + }); + }); + + // Filter plays if only displaying best score on each beatmap + if (onlyDisplayBestCheckbox.Current.Value) + { + Schedule(() => loadingLayer.Text.Value = "Filtering plays"); + + var filteredPlays = new List(); + + // List of all beatmap IDs in plays without duplicates + var beatmapIDs = plays.Select(x => x.Score.Beatmap.OnlineID).Distinct().ToList(); + + foreach (int id in beatmapIDs) + { + var bestPlayOnBeatmap = plays.Where(x => x.Score.Beatmap.OnlineID == id).OrderByDescending(x => x.PerformanceAttributes?.Total).First(); + filteredPlays.Add(bestPlayOnBeatmap); + } + + plays = filteredPlays; + } + + plays = plays.OrderByDescending(x => x.PerformanceAttributes?.Total).ToList(); + + Schedule(() => + { + foreach (var play in plays) + { + scores.Add(new ExtendedProfileScore(play, false)); + + play.Position.Value = plays.IndexOf(play) + 1; + } + }); + + decimal totalLocalPP = 0; + + for (int i = 0; i < plays.Count; i++) + totalLocalPP += (decimal)(Math.Pow(0.95, i) * plays[i].PerformanceAttributes?.Total ?? 0); + + decimal totalLivePP = player.Statistics.PP ?? (decimal)0.0; + + // https://github.com/ppy/osu-queue-score-statistics/blob/842653412d66eef527f7b7067b7cf50e886de954/osu.Server.Queues.ScoreStatisticsProcessor/Helpers/UserTotalPerformanceAggregateHelper.cs#L36-L38 + // this might be slightly incorrect for some profiles due to the deduplication happening on the osu-queue-score-statistics side which we can't account for here + decimal playcountBonusPP = (decimal)((417.0 - 1.0 / 3.0) * (1.0 - Math.Pow(0.995, Math.Min(player.BeatmapPlayCountsCount, 1000)))); + totalLocalPP += playcountBonusPP; + + Schedule(() => + { + if (userPanel != null) + { + userPanel.Data.Value = new UserCardData + { + LivePP = totalLivePP, + LocalPP = totalLocalPP, + PlaycountPP = playcountBonusPP + }; + } + }); + }, token).ContinueWith(t => + { + Logger.Log(t.Exception?.ToString(), level: LogLevel.Error); + notificationDisplay.Display(new Notification(t.Exception?.Flatten().Message)); + }, TaskContinuationOptions.OnlyOnFaulted).ContinueWith(t => + { + Schedule(() => + { + loadingLayer.Hide(); + calculationButton.State.Value = ButtonState.Done; + updateSorting(ProfileSortCriteria.Local); + }); + }, TaskContinuationOptions.None); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + calculationCancellatonToken?.Cancel(); + calculationCancellatonToken?.Dispose(); + calculationCancellatonToken = null; + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Key == Key.Escape && calculationCancellatonToken?.IsCancellationRequested == false) + { + calculationCancellatonToken?.Cancel(); + } + + return base.OnKeyDown(e); + } + + private void updateSorting(ProfileSortCriteria sortCriteria) + { + if (!scores.Children.Any()) + return; + + ExtendedProfileScore[] sortedScores; + + switch (sortCriteria) + { + case ProfileSortCriteria.Live: + sortedScores = scores.Children.OrderByDescending(x => x.ExtScore.LivePP).ToArray(); + break; + + case ProfileSortCriteria.Local: + sortedScores = scores.Children.OrderByDescending(x => x.ExtScore.PerformanceAttributes?.Total).ToArray(); + break; + + case ProfileSortCriteria.Difference: + sortedScores = scores.Children.OrderByDescending(x => x.ExtScore.PerformanceAttributes?.Total - x.ExtScore.LivePP).ToArray(); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(sortCriteria), sortCriteria, null); + } + + for (int i = 0; i < sortedScores.Length; i++) + { + scores.SetLayoutPosition(sortedScores[i], i); + } + } + } +} diff --git a/PerformanceCalculatorGUI/bin/Debug/net8.0/realm/client.realm b/PerformanceCalculatorGUI/bin/Debug/net8.0/realm/client.realm new file mode 100644 index 0000000000..e69de29bb2 From a15be7ab242653171aa610398d4e3e68fc34a7c7 Mon Sep 17 00:00:00 2001 From: KermitNuggies Date: Sat, 14 Feb 2026 20:35:45 +1300 Subject: [PATCH 4/4] add some comments --- PerformanceCalculatorGUI/Screens/RealmScreen.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/PerformanceCalculatorGUI/Screens/RealmScreen.cs b/PerformanceCalculatorGUI/Screens/RealmScreen.cs index d17c700908..7cb93f8c0a 100644 --- a/PerformanceCalculatorGUI/Screens/RealmScreen.cs +++ b/PerformanceCalculatorGUI/Screens/RealmScreen.cs @@ -300,17 +300,18 @@ private void calculateProfile(string username) Schedule(() => loadingLayer.Text.Value = $"Getting {username} user data..."); player = await apiManager.GetJsonFromApi($"users/{username}/{ruleset.Value.ShortName}").ConfigureAwait(false); - playerUsernames = [player.Username, .. player.PreviousUsernames, player.Id.ToString()]; // double check player id + playerUsernames = [player.Username, .. player.PreviousUsernames, player.Id.ToString()]; Schedule(() => loadingLayer.Text.Value = $"Calculating {player.Username} top scores..."); - var realmScores = realmAccess.Run(r => r.All().Detach()) // ideally work out how to use Live<> - .Where(x => playerUsernames.Any(name => name.Equals(x.User.Username, StringComparison.OrdinalIgnoreCase)) && - x.BeatmapInfo != null && - x.Passed == true && x.Rank != ScoreRank.F && - x.Ruleset.OnlineID == ruleset.Value.OnlineID && - x.BeatmapInfo.OnlineID != -1) + var realmScores = realmAccess.Run(r => r.All().Detach()) + .Where(x => playerUsernames.Any(name => name.Equals(x.User.Username, StringComparison.OrdinalIgnoreCase)) && // scores from the correct user + x.BeatmapInfo != null && // map exists + x.Passed == true && x.Rank != ScoreRank.F && // exclude failed scores + x.Ruleset.OnlineID == ruleset.Value.OnlineID && // exclude other rulesets + x.BeatmapInfo.OnlineID != -1) // exclude unsubmitted maps .ToList(); + // remove unranked maps and mods if toggled off if (!includeUnrankedMaps.Current.Value) realmScores.RemoveAll(x => x.BeatmapInfo.Status != BeatmapOnlineStatus.Ranked); if (!includeUnrankedMods.Current.Value)