// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. #nullable disable using System; using System.Linq; using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Online.Spectator; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; namespace osu.Game.Screens.Play { /// /// A player instance which supports submitting scores to an online store. /// public abstract partial class SubmittingPlayer : Player { /// /// The token to be used for the current submission. This is fetched via a request created by . /// private long? token; [Resolved] private IAPIProvider api { get; set; } [Resolved] private SpectatorClient spectatorClient { get; set; } [Resolved] private SessionStatics statics { get; set; } [Resolved(canBeNull: true)] [CanBeNull] private UserStatisticsWatcher userStatisticsWatcher { get; set; } private readonly object scoreSubmissionLock = new object(); private TaskCompletionSource scoreSubmissionSource; protected SubmittingPlayer(PlayerConfiguration configuration = null) : base(configuration) { } [BackgroundDependencyLoader] private void load() { if (DrawableRuleset == null) { // base load must have failed (e.g. due to an unknown mod); bail. return; } AddInternal(new PlayerTouchInputDetector()); // We probably want to move this display to something more global. // Probably using the OSD somehow. AddInternal(new GameplayOffsetControl { Margin = new MarginPadding(20), Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, }); } protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart) { ShouldValidatePlaybackRate = true, }; protected override void LoadAsyncComplete() { base.LoadAsyncComplete(); handleTokenRetrieval(); } private bool handleTokenRetrieval() { // Token request construction should happen post-load to allow derived classes to potentially prepare DI backings that are used to create the request. var tcs = new TaskCompletionSource(); if (Mods.Value.Any(m => !m.UserPlayable)) { handleTokenFailure(new InvalidOperationException("Non-user playable mod selected.")); return false; } if (!api.IsLoggedIn) { handleTokenFailure(new InvalidOperationException("API is not online.")); return false; } var req = CreateTokenRequest(); if (req == null) { handleTokenFailure(new InvalidOperationException("Request could not be constructed.")); return false; } req.Success += r => { Logger.Log($"Score submission token retrieved ({r.ID})"); token = r.ID; tcs.SetResult(true); }; req.Failure += ex => handleTokenFailure(ex, displayNotification: true); api.Queue(req); // Generally a timeout would not happen here as APIAccess will timeout first. if (!tcs.Task.Wait(30000)) req.TriggerFailure(new InvalidOperationException("Token retrieval timed out (request never run)")); return true; void handleTokenFailure(Exception exception, bool displayNotification = false) { tcs.SetResult(false); bool shouldExit = ShouldExitOnTokenRetrievalFailure(exception); if (displayNotification || shouldExit) { string whatWillHappen = shouldExit ? "Play in this state is not permitted." : "Your score will not be submitted."; if (string.IsNullOrEmpty(exception.Message)) Logger.Error(exception, $"Failed to retrieve a score submission token.\n\n{whatWillHappen}"); else { switch (exception.Message) { case @"missing token header": case @"invalid client hash": case @"invalid verification hash": Logger.Log($"Please ensure that you are using the latest version of the official game releases.\n\n{whatWillHappen}", level: LogLevel.Important); break; case @"invalid beatmap hash": Logger.Log($"This beatmap does not match the online version. Please update or redownload it.\n\n{whatWillHappen}", level: LogLevel.Important); break; case @"expired token": Logger.Log($"Your system clock is set incorrectly. Please check your system time, date and timezone.\n\n{whatWillHappen}", level: LogLevel.Important); break; default: Logger.Log($"{whatWillHappen} {exception.Message}", level: LogLevel.Important); break; } } } if (shouldExit) { Schedule(() => { ValidForResume = false; this.Exit(); }); } } } /// /// Called when a token could not be retrieved for submission. /// /// The error causing the failure. /// Whether gameplay should be immediately exited as a result. Returning false allows the gameplay session to continue. Defaults to true. protected virtual bool ShouldExitOnTokenRetrievalFailure(Exception exception) => true; protected override async Task PrepareScoreForResultsAsync(Score score) { await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false); score.ScoreInfo.Date = DateTimeOffset.Now; await submitScore(score).ConfigureAwait(false); spectatorClient.EndPlaying(GameplayState); userStatisticsWatcher?.RegisterForStatisticsUpdateAfter(score.ScoreInfo); } [Resolved] private RealmAccess realm { get; set; } protected override void StartGameplay() { base.StartGameplay(); // User expectation is that last played should be updated when entering the gameplay loop // from multiplayer / playlists / solo. realm.WriteAsync(r => { var realmBeatmap = r.Find(Beatmap.Value.BeatmapInfo.ID); if (realmBeatmap != null) realmBeatmap.LastPlayed = DateTimeOffset.Now; }); spectatorClient.BeginPlaying(token, GameplayState, Score); } protected override void OnFail() { base.OnFail(); submitFromFailOrQuit(); } public override bool OnExiting(ScreenExitEvent e) { bool exiting = base.OnExiting(e); submitFromFailOrQuit(); statics.SetValue(Static.LastLocalUserScore, Score?.ScoreInfo.DeepClone()); return exiting; } private void submitFromFailOrQuit() { if (LoadedBeatmapSuccessfully) { Task.Run(async () => { await submitScore(Score.DeepClone()).ConfigureAwait(false); spectatorClient.EndPlaying(GameplayState); }).FireAndForget(); } } /// /// Construct a request to be used for retrieval of the score token. /// Can return null, at which point will be fired. /// [CanBeNull] protected abstract APIRequest CreateTokenRequest(); /// /// Construct a request to submit the score. /// Will only be invoked if the request constructed via was successful. /// /// The score to be submitted. /// The submission token. protected abstract APIRequest CreateSubmissionRequest(Score score, long token); private Task submitScore(Score score) { var masterClock = GameplayClockContainer as MasterGameplayClockContainer; if (masterClock?.PlaybackRateValid.Value != true) { Logger.Log("Score submission cancelled due to audio playback rate discrepancy."); return Task.CompletedTask; } // token may be null if the request failed but gameplay was still allowed (see HandleTokenRetrievalFailure). if (token == null) { Logger.Log("No token, skipping score submission"); return Task.CompletedTask; } lock (scoreSubmissionLock) { if (scoreSubmissionSource != null) return scoreSubmissionSource.Task; scoreSubmissionSource = new TaskCompletionSource(); } // if the user never hit anything, this score should not be counted in any way. if (!score.ScoreInfo.Statistics.Any(s => s.Key.IsHit() && s.Value > 0)) return Task.CompletedTask; Logger.Log($"Beginning score submission (token:{token.Value})..."); var request = CreateSubmissionRequest(score, token.Value); request.Success += s => { score.ScoreInfo.OnlineID = s.ID; score.ScoreInfo.Position = s.Position; scoreSubmissionSource.SetResult(true); Logger.Log($"Score submission completed! (token:{token.Value} id:{s.ID})"); }; request.Failure += e => { Logger.Error(e, $"Failed to submit score (token:{token.Value}): {e.Message}"); scoreSubmissionSource.SetResult(false); }; api.Queue(request); return scoreSubmissionSource.Task; } } }