Skip to content

Extract leaderboard fetch logic from song select beatmap leaderboard drawable #31881

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from

Conversation

bdach
Copy link
Collaborator

@bdach bdach commented Feb 14, 2025

This is an RFC. It appears to be fully functional and pass tests, but I'm not super sure on the direction here, so opening as draft.

The key points:

  • The core logic of online and local leaderboard fetches has been moved out to LeaderboardProvider - a component at OsuGameBase level. The reason for pulling it up there is to avoid many stupid issues related to presence, schedules not running, etc., that would result from putting that component in one piece in SongSelect.

  • SongSelect has a second component - StateTrackingLeaderboardProvider - that is responsible for tracking the user filter preferences and calling appropriate LeaderboardProvider methods. This component is passed around to Player implementations which fixes Leaderboards do not show when a user goes into gameplay immediately #29861, and that is later intended to daisy-chain further into ResultsScreen implementations, which is prospective towards fixing Results screen should change based on the leaderboard you have selected in song select #26331. Watching a replay does not show leaderboard #27609 is another related issue.

    In Remove fetching logic from leaderboard #31355 I knocked the idea of doing state tracking, but I concede that having a thing that does it is easier than not, because you can pass this stuff forward without needing to pass forward the entirety of song select's leaderboard scope state.

    However, contrary to that pull, there are no weird generics here and no overfitted structures. Everything is written straight, for the specific use case of leaderboards in song select which I consider disparate and not 100% compatible with leaderboards in gameplay (more on that later down the chain of PRs, if this one passes review scrutiny).

  • I admit out of the gate that the hookup of StateTrackingLeaderboardProvider to BeatmapLeaderboard is shoddy at best - the StateTrackingLeaderboardProvider is reconstructed more than it needs to. That is because BeatmapLeaderboard and its base Leaderboard abomination are bad and should be nuked from orbit. I am hoping this can happen with the song select leaderboard, which is why I'm tagging @frenzibyte in here to examine the structure. The hope is that the new implementation can focus on display logic and rely on the provider to provide scores, rather than having all of that Refetch() stuff.

  • StateTrackingLeaderboardProvider keeps it head out of all of the special error logic (showing the messages about the beatmap not having online leaderboards, not having supporter, etc.) I was in two minds about this, and in the end I think this is better, but it's not a strong opinion and I am open to reconsideration.

Copy link
Contributor

@smoogipoo smoogipoo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I'm not really convinced by the abstraction model here. As I mention in the comments below it feels like this is more of a "manager" class functionality-wise, but that means it's doing quite a lot of logic...

  • Exposes mutable beatmap/ruleset/mods. Why should player/results ever be concerned about any other mods?
  • Exposes mutable scope. On its own this is fine, but I don't see why we wouldn't instead pass this bindable on its own through player/results.
  • Exposes ModFilterActive. At surface level this may be fine, but is this how player should behave as well? For example, I would argue the more common scenario is a player enables this filter while in song select but still wants to see all scores during gameplay.

Additionally, this means that any change to the filtering here is going to take effect in song select as well. I'm not sure this is bad, but let me propose a hypothetical: I have the global leaderboard selected, play through a map and go to results, and just click through tabs because I want to see how I compare to others, but I don't want to remain on the "friends" tab - I want to be put back on the global tab when I'm at song select. One-directional is fine but bi-directionality is what I'm not sure about.

Finally, I'll propose another hypothetical. What if the skinning component could let you choose which leaderboard to display, so that you could display two leaderboards (e.g. country + global) during play? It feels like that would be made impossible with this setup.

It feels like this component is just coupling everything together more and its purpose is not fully fleshed out. Are we trying to cache scores? Are we trying to tie absolutely everything together like this? I'm not sure what the primary goal is.

RefetchScores();
};
}
public StateTrackingLeaderboardProvider? LeaderboardProvider { get; private set; }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We cannot have this:

  • An inner component creating another component, which is extracted out by a parenting component to be passed into a separate component (i.e. the changes in PlaySongSelect).
  • Bindables bound to from within one component ferried through like this (SongSelect.Beatmap -> DetailArea.BeatmapInfo -> BeatmapLeaderboard.BeatmapInfo -> back into song select and passed into another component).
  • Said inner component disposing what has now become an external component.

Is there a reason to not pass this top-down from SongSelect instead? If this relates to the "Leaderboard abomination", then that will need to be fixed first, as this is a blocker for me.

Copy link
Collaborator Author

@bdach bdach Feb 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not exactly sure having this thing at SongSelect level is in our best interests (because that class is too huge) but sure I'll try.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also just to be clear, of all the commentary I provided this one is the only blocker for me. The rest is only at the level of "i feel like we're going to have to reconsider this component's place again in the (near) future".

Would very much like this to be top-down unless worlds have to be moved apart to do so.

/// <see langword="null"/> if fetch is in progress.
/// Updates to this bindable may not be delivered on the update thread. Consumers are expected to schedule locally as required.
/// </summary>
public Bindable<(IEnumerable<ScoreInfo> best, ScoreInfo? userScore)?> Scores => scores;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be an IBindable<>.

/// Raised when fetching scores fails.
/// This event may not be invoked on the update thread. Consumers are expected to schedule locally as required.
/// </summary>
public event Action<Exception>? RetrievalFailed;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have a better suggestion right now, but I'm not sure about this in the context of being around a BIndable. It works around web requests or Tasks in my mind, but this provider appears to be wanting to be a transparent class that just does everything correctly - that is it's trying to be more akin to a "manager".

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure how to resolve this

Comment on lines +41 to +43
public IEnumerable<ScoreInfo> GetLocalScoresFor(BeatmapInfo beatmap, RulesetInfo ruleset) => realm.Run(r => getLocalScoresFor(r, beatmap, ruleset)).AsEnumerable();

public IDisposable SubscribeToLocalScores(BeatmapInfo beatmap, RulesetInfo ruleset, NotificationCallbackDelegate<ScoreInfo> onChange)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there intent behind exposing these?

Copy link
Collaborator Author

@bdach bdach Feb 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SubscribeToLocalScore() is used. GetLocalScoresFor() isn't and can probably removed, but again, I'm not sure what you're asking for here.

Part of the reason why there's a two-level split of this out from the tracking leaderboard provider thing is mentioned in the OP (e.g. not wanting realm subscriptions to be tied to particular components that may or may not be alive/updating, which breaks scheduling flows, and thus breaks most of anything async).

@bdach
Copy link
Collaborator Author

bdach commented Feb 17, 2025

Exposes mutable beatmap/ruleset/mods. Why should player/results ever be concerned about any other mods?

I don't understand this concern.

Exposes mutable scope. On its own this is fine, but I don't see why we wouldn't instead pass this bindable on its own through player/results.

My assumption is that the gameplay leaderboard's state should match the song select leaderboard 1:1. To do this, there are more pieces of state to be passed around. Thus, having one component collect all pieces of state and passing that seemed like a better idea than passing five disparate pieces of state.

Exposes ModFilterActive. At surface level this may be fine, but is this how player should behave as well? For example, I would argue the more common scenario is a player enables this filter while in song select but still wants to see all scores during gameplay.

I have not heard anyone say this, or seen this mentioned in any discussion around this area. This PR was not designed with this in mind.

I have the global leaderboard selected, play through a map and go to results, and just click through tabs because I want to see how I compare to others, but I don't want to remain on the "friends" tab - I want to be put back on the global tab when I'm at song select. One-directional is fine but bi-directionality is what I'm not sure about.

I have not seen a single results screen design where there are "tabs", so this PR was not designed with this in mind.

Finally, I'll propose another hypothetical. What if the skinning component could let you choose which leaderboard to display, so that you could display two leaderboards (e.g. country + global) during play? It feels like that would be made impossible with this setup.

I don't want to discuss the in-gameplay part in this PR because it is a disparate thing that will have a disparate solution. This PR is almost orthogonal to it in a sense.

To not be 100% evasive, the sketch is that there will be an IGameplayLeaderboardProvider interface, which will provide the collection of scores to display. Solo, multiplayer, and multiplayer spectator will cache different implementations of this which will provide correct behaviour. That would make "choosing which leaderboard to display" potentially feasible if we want it (which I have never seen expressed before, either, by the way).

@smoogipoo
Copy link
Contributor

ModFilterActive...
I have not heard anyone say this, or seen this mentioned in any discussion around this area.

I brought it up because I assumed that's the way it worked in stable. But actually trying it out it's retaining the selected mods, so I suppose there's no issue here.

I have not seen a single results screen design where there are "tabs", so this PR was not designed with this in mind.

This is the latest design afaik https://www.figma.com/design/cbYyzJWrG68hQw3MincySk/Client---Result-Screen, and I took the bottom part to represent tabs given that one of them appears to be in a selected state:

image

But maybe that's just so that it corresponds to the progress bar below it. Dunno.

@bdach
Copy link
Collaborator Author

bdach commented Feb 17, 2025

This is the latest design afaik https://www.figma.com/design/cbYyzJWrG68hQw3MincySk/Client---Result-Screen, and I took the bottom part to represent tabs given that one of them appears to be in a selected state

i don't think i've ever seen this figma. the github project that was supposed to stay up to date and be the source of truth on this links to this figma which i have seen and doesn't appear to feature a leaderboard selector. @arflyte please advise

@smoogipoo
Copy link
Contributor

Coincidentally, that's the first time I've seen that design (was going by the figma library in internal #design) 😅 Well, alright then...

@arflyte
Copy link

arflyte commented Feb 17, 2025

While it's the latest design (https://www.figma.com/design/f7Gj5AHR79C9QctfS0HHkK/Result-Screen-8?node-id=0-1&p=f&t=59oCJzAW0PDKi7zv-0), it's pending for review and update; it's far from complete. It will be the next focus after Song Select.

This (https://www.figma.com/design/cbYyzJWrG68hQw3MincySk/Client---Result-Screen) works for the current implemented design.

@bdach
Copy link
Collaborator Author

bdach commented Feb 17, 2025

it's pending for review and update; it's far from complete. It will be the next focus after Song Select. (...) This works for the current implemented design.

somehow this explanation has made me even more confused so let me try and restate the question: is either figma supposed to be used for anything at all?

@arflyte
Copy link

arflyte commented Feb 17, 2025

I'm not sure what exactly is trying to be implemented here. If you're looking for a way to quickly implement leaderboard tabs, then yes, you may use this for now: https://www.figma.com/design/cbYyzJWrG68hQw3MincySk/Client---Result-Screen This works for the currently implemented Result Screen.

This https://www.figma.com/design/f7Gj5AHR79C9QctfS0HHkK/Result-Screen-8?node-id=0-1&p=f&t=59oCJzAW0PDKi7zv-0, is still being worked on, and shouldn't be used until it's fully reviewed.

@peppy
Copy link
Member

peppy commented Feb 17, 2025

I'm still getting through reviewing this PR, but in the mean time:

This is the latest design afaik https://www.figma.com/design/cbYyzJWrG68hQw3MincySk/Client---Result-Screen, and I took the bottom part to represent tabs given that one of them appears to be in a selected state:

I didn't see these as tabs (the design cues make them look like static elements), but let's take a step back for a moment. Designs and designers shouldn't be part of this conversation. Design is driven by actual behavioural requirements and not vice-versa.

From a functional perspective, giving users the ability to view different leaderboard categories from the results screen may be nice and I'm sure some users will ask for it, but it also means we need full filter controls available as users will ask for every leaderboard style available. To keep scope in check, let's say that this can't be done for now, and results screen just shows the same leaderboard that was selected at song select.

I would still see this selection being game-wide. If structured correctly, this would mean that in a future we could allow the current displayed leaderboard at results to be changed (and it would update the global selection), which may be something to keep in mind when making implementation decisions. Keep it simple, one state object defining the leaderboard shown game wide.

@bdach
Copy link
Collaborator Author

bdach commented Feb 17, 2025

Designs and designers shouldn't be part of this conversation. Design is driven by actual behavioural requirements and not vice-versa.

sure, but i'd also say that because designs should be driven by requirements, looking at designs can be a contextual cue for me to deduce behavioural requirements that may have been laid down in conversations i was not party to.

establishing whether we want leaderboard filter controls on the results screen at all is one key factor in this entire series that i would like to be clarified before i proceed any further, because it may invalidate core assumptions behind this PR.

@peppy
Copy link
Member

peppy commented Feb 17, 2025

The only way I could surmise that affecting this change is whether the state is mutable or not post-results-screen-load, would that be correct?

or put another way, should the results screen be listening to a bindable, or receive a non-mutable state at construction?

@bdach
Copy link
Collaborator Author

bdach commented Feb 17, 2025

or put another way, should the results screen be listening to a bindable, or receive a non-mutable state at construction?

The way I foresee this series proceeding and have it prepared, unless a major shift in direction occurs, is that the collection of scores to display on the leaderboards and then in results is going to be more or less frozen at the state it was in song select at the point of entering player, which would be closer to the latter than the former. If that ceases to be true, further work on results screens might be required.

@peppy
Copy link
Member

peppy commented Feb 17, 2025

As discussed, I think a starting point would be to have a global component which manages the leaderboard scores, something like this:

class LeaderboardCache // i think this most closely matches other cache classes in usage?
{
    readonly Bindable<LeaderboardScope> GlobalScope; // either lives here or in song select or OsuGame, doens't really matter just needs to be somewhere.

    readonly ILeaderboardContext GetScores(LeaderboardScope scope);

    private class Context : ILeaderboardContext
    {
        // scores list updated by cache
        // disposal used to allow leaderboard cache to clean up after all usages are gone.
    }
}

class ILeaderboardContext : IDisposable
{
    IBindableList<ScoreInfo> Scores;
    void Invalidate(); // alternative: Refetch();
}

record LeaderboardScope
{
    BeatmapLeaderboardScope Scope;
    BeatmapInfo Beatmap;
    RulesetInfo Ruleset;
    Mod[]? RequiredMods;
}

I've probably missed things here so please apply salt.

Copy link
Member

@peppy peppy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

api proposal, wrote other stuff but posted elsewhere

@bdach
Copy link
Collaborator Author

bdach commented Mar 5, 2025

@peppy @smoogipoo can I get both of you to take 15 minutes reading through https://github.com/ppy/osu/compare/master...bdach:osu:yet-another-leaderboard-rewrite-attempt?expand=1 and telling me if I'm going in the right direction? I wrote it to mostly follow the API proposal in #31881 (comment) and it sort of appears to function but I'm not convinced it's going to alleviate all of your concerns.

  • @peppy mentions here that he wants this to be a "cache", e.g. a thing that stores leaderboards in full - but what I wrote is not a cache, and I question that a cache is useful here. The way everything is set up in the diff means that every small change in criteria would trigger a full refetch under an entirely new cache key - and I imagine in most cases wherein a hit rate could occur, you don't actually want it because you want to refetch the thing anyway (e.g. say, I switch away from beatmap A to beatmap B, and then back to A - don't I want the leaderboards for A to refetch? how about when I change filters back and forth?)

    Put it this way: I think we would get more use of leaderboard objects as containers of scores at a point in time rather than a "cache" in the way user/beatmap caching works right now since it's data that's a lot more volatile and if it were cached, people would expect to be able to invalidate it almost every time.

  • @smoogipoo mentions not liking having a bindable next to a failure callback. I don't feel like I have many options here. I want consumers to be able to pass the ILeaderboardProvider around, in line with the "containers of scores" idea described above and to access the stored data. This forces me into a design wherein it can't just be a Task, the data has to be retrievable at any point in some way. (Unless I make LeaderboardManager.GetLeaderboardFor() return a Task<ILeaderboardProvider>?)

  • There's also the concern about how to pass the ILeaderboardProvider around - what I did is cache a bindable at SongSelect level and essentially manage it from within BeatmapLeaderboard, which is dodgy, but my other recourse is pulling all of the leaderboard filtering state out to SongSelect level, and I'm really reluctant to do it and inflate such a huge class further.

  • I'm also still not super comfortable with trying to abstract the entirety of Scope. Local retrieval is fundamentally different in mechanics than online retrieval (local retrieval uses realm subscriptions which are data-push, and online retrieval uses API which are data-pull). Forcing them into one abstraction just ends up being a little awkward.

Not sure where to take this further. If it helps we can voice at some point.

@smoogipoo
Copy link
Contributor

smoogipoo commented Mar 6, 2025

My biggest problem with that changeset is the following:

OsuGameBase:
    load():
        dependencies.Cache(leaderboardManager = new LeaderboardManager());

SongSelect:
    [Cached] private Bindable<ILeaderboardProvider?> leaderboardProvider = new Bindable<ILeaderboardProvider?>();
        
BeatmapLeaderboard:
    [Resolved] private LeaderboardManager leaderboardManager { get; set; } = null!;
    [Resolved] private Bindable<ILeaderboardProvider?> leaderboardProvider { get; set; } = null!;
    
    FetchScores():
        leaderboardProvider.Value?.Dispose();
        ...
        var newProvider = leaderboardManager.GetLeaderboardFor()
  • We've been trying to move away from caching Bindables in general.
  • The disposal is disposing an externally resolved ILeaderboardProvider?
  • The new ILeaderboardProvider is not stored to the bindable, so it gets lost with the assumption that hopefully an IDisposable object does not actually require disposal?

Resolving both a manager and a provider sounds extremely flimsy to me. You say "pulling all of the leaderboard filtering state out to SongSelect" but I don't understand why this is required... What logic does SongSelect need for leaderboards? The whole point of the last discussion we had is that there was one global manager that linked things together, no? That is, it would be Player/Results/this leaderboard, all resolving in something that is shared through the global manager.

I still cannot come to accept this structure as is... sorry. Is it a case of me not understanding the complexities involved?

Other than that, it looks a little bit more tenable than this branch.

@bdach
Copy link
Collaborator Author

bdach commented Mar 6, 2025

The whole point of the last discussion we had is that there was one global manager that linked things together, no?

I could make all the leaderboard state game global and pull it out to the leaderboard manager class or whatever, but that's also going to change the API shape enough to no longer match @peppy's proposal (and go even farther away from the "cache" idea)...

@smoogipoo
Copy link
Contributor

I think it might be easiest for me to hold off from reviewing further, because it feels like I'm being a thorn. I'll let @peppy review things for now and see whether he has a better view of the path you're heading in.

@peppy peppy self-requested a review March 10, 2025 08:19
@peppy
Copy link
Member

peppy commented Mar 10, 2025

The way everything is set up in the diff means that every small change in criteria would trigger a full refetch under an entirely new cache key - and I imagine in most cases wherein a hit rate could occur, you don't actually want it because you want to refetch the thing anyway (e.g. say, I switch away from beatmap A to beatmap B, and then back to A - don't I want the leaderboards for A to refetch? how about when I change filters back and forth?)

The reasoning for making it "cache" to some degree is to cover the scenario of gameplay and results screen use the same content as song select.

With the way you've structured this, I believe the idea is to cache the "resultant" ILeaderboardProvider at song select and use this as the source of truth for gameplay/results, correct?

If so, this sounds fine until we potentially want to switch the source or criteria at the results screen, but I think that can be dealt with separately.

@peppy
Copy link
Member

peppy commented Mar 10, 2025

As for the concerns smoogi raises about the song select bindable, I tend to agree.

In my API, the GetScores(context) would need to be replaced with a get-only Scores{get;} and there would be a separate way to update/bind to the global context.

bdach added a commit to bdach/osu that referenced this pull request Mar 21, 2025
…drawable

RFC. Another attempt at this.

- Supersedes ppy#31881
- Supersedes / closes ppy#31355
- Closes ppy#29861

This is a weird diff because I am feeling rather boxed in by all the
constraints, namely that:

- Leaderboard state should be global state
- But the global state is essentially managed by song select and namely
  `BeatmapLeaderboard` itself. That's because trying to e.g. not have
  `BeatmapLeaderboard` pass the beatmap and the ruleset to the global
  leaderboard manager is worse, as it essentially introduces two
  parallel paths of execution that need to be somehow merged into one
  (as in I'd have to somehow sync `LeaderboardManager` responding to
  beatmap/ruleset changes with `BeatmapLeaderboard` which is inheritance
  hell)
- Also local leaderboard fetching is data-push (as in the scores can
  change under the leaderboard manager), and online leaderboard fetching
  is data-pull (as in the scores do not change unless the leaderboard
  manager does something). Also online leaderboard fetching can fail.
  Which is why I need to still have the weird setup wherein there's a
  `FetchWithCriteriaAsync()` (because I need to be able to respond to
  online requests taking time, or failing), but also the
  `BeatmapLeaderboard` only uses the public `Scores` bindable to
  actually read the scores (because it needs to respond to new local
  scores arriving).
- Another thing to think about here is what happens when a retrieval
  fails because e.g. the user requested friend leaderboards without
  having supporter. With how this diff is written, that special
  condition is handled to `BeatmapLeaderboard`, and
  `LeaderboardManager`'s state will remain as whatever it was before
  that scope change was requested, which may be considered good or it
  may not (I imagine it's better to show scores in gameplay than not in
  this case, but maybe I'm wrong?)
@bdach
Copy link
Collaborator Author

bdach commented Mar 21, 2025

Superseded by #32494

@bdach bdach closed this Mar 21, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Leaderboards do not show when a user goes into gameplay immediately
4 participants