2021-03-23 14:00:02 +08:00
// 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.
2022-06-17 15:37:17 +08:00
#nullable disable
2021-03-23 15:41:36 +08:00
using System ;
2021-05-11 10:24:55 +08:00
using System.Linq ;
2021-03-23 14:00:02 +08:00
using System.Threading.Tasks ;
2021-03-24 12:17:13 +08:00
using JetBrains.Annotations ;
2021-03-23 14:00:02 +08:00
using osu.Framework.Allocation ;
using osu.Framework.Logging ;
using osu.Framework.Screens ;
2022-07-13 15:36:55 +08:00
using osu.Game.Beatmaps ;
using osu.Game.Database ;
2021-03-23 14:00:02 +08:00
using osu.Game.Online.API ;
Ensure score submission attempt completion before notifying spectator server when exiting play early
When a `SubmittingPlayer` gameplay session ends with the successful
completion of a beatmap, `PrepareScoreForResultsAsync()` ensures that
the score submission request is sent to and responded to by osu-web
before calling `ISpectatorClient.EndPlaying()`.
While previously this was mostly an implementation detail, this becomes
important when considering that more and more server-side flows (replay
upload, notifying about score processing completion) hook into
`EndPlaying()`, and assume that by the point that message arrives at
osu-spectator-server, the score has already been submitted and has been
assigned a score ID that corresponds to the score submission token.
As it turns out, in the early-exit path (when the user exits the play
midway through, retries, or just fails), the same ordering guarantees
were not provided. The score's submission ran concurrently to the
spectator client `EndPlaying()` call, therefore creating a network
race. osu-server-spectator components that implciitly relied on the
ordering provided by the happy path, could therefore fail to unmap the
score submission token to a score ID.
Note that as written, the osu-server-spectator replay upload flow is
not really affected by this, as it self-corrects by essentially polling
the database and trying to unmap the score submission token to a score
ID for up to 30 seconds. However, this change would have the benefit of
reducing the polls required in such cases to just one DB retrieval.
2022-12-22 04:59:46 +08:00
using osu.Game.Online.Multiplayer ;
2021-03-23 14:00:02 +08:00
using osu.Game.Online.Rooms ;
2022-07-28 19:44:02 +08:00
using osu.Game.Online.Spectator ;
2021-07-04 14:14:25 +08:00
using osu.Game.Rulesets.Scoring ;
2021-03-23 14:00:02 +08:00
using osu.Game.Scoring ;
namespace osu.Game.Screens.Play
{
2021-03-23 14:33:31 +08:00
/// <summary>
/// A player instance which supports submitting scores to an online store.
/// </summary>
2021-03-23 14:00:02 +08:00
public abstract partial class SubmittingPlayer : Player
{
2021-03-24 12:02:17 +08:00
/// <summary>
2021-03-24 12:02:37 +08:00
/// The token to be used for the current submission. This is fetched via a request created by <see cref="CreateTokenRequest"/>.
2021-03-24 12:02:17 +08:00
/// </summary>
2021-03-24 12:20:44 +08:00
private long? token ;
2021-03-23 14:00:02 +08:00
[Resolved]
private IAPIProvider api { get ; set ; }
2022-07-28 19:44:02 +08:00
[Resolved]
private SpectatorClient spectatorClient { get ; set ; }
2021-07-01 16:02:33 +08:00
private TaskCompletionSource < bool > scoreSubmissionSource ;
2021-03-23 14:35:06 +08:00
protected SubmittingPlayer ( PlayerConfiguration configuration = null )
2021-03-23 14:00:02 +08:00
: base ( configuration )
{
}
2023-11-03 05:26:52 +08:00
[BackgroundDependencyLoader]
private void load ( )
{
2023-11-03 06:44:40 +08:00
if ( DrawableRuleset = = null )
{
// base load must have failed (e.g. due to an unknown mod); bail.
return ;
}
2023-11-06 17:08:19 +08:00
AddInternal ( new PlayerTouchInputDetector ( ) ) ;
2023-11-03 05:26:52 +08:00
}
2021-03-23 15:05:40 +08:00
protected override void LoadAsyncComplete ( )
2021-03-25 12:48:41 +08:00
{
base . LoadAsyncComplete ( ) ;
2021-05-11 10:24:35 +08:00
handleTokenRetrieval ( ) ;
2021-03-25 12:48:41 +08:00
}
private bool handleTokenRetrieval ( )
2021-03-23 14:00:02 +08:00
{
2021-03-23 15:05:40 +08:00
// Token request construction should happen post-load to allow derived classes to potentially prepare DI backings that are used to create the request.
2021-03-23 15:41:36 +08:00
var tcs = new TaskCompletionSource < bool > ( ) ;
2021-03-23 14:00:02 +08:00
2021-06-09 13:32:48 +08:00
if ( Mods . Value . Any ( m = > ! m . UserPlayable ) )
2021-05-11 10:24:55 +08:00
{
2021-06-09 13:32:48 +08:00
handleTokenFailure ( new InvalidOperationException ( "Non-user playable mod selected." ) ) ;
2021-05-11 10:24:55 +08:00
return false ;
}
2021-03-23 15:41:36 +08:00
if ( ! api . IsLoggedIn )
2021-03-23 14:00:02 +08:00
{
2021-03-24 12:20:44 +08:00
handleTokenFailure ( new InvalidOperationException ( "API is not online." ) ) ;
2021-03-25 12:48:41 +08:00
return false ;
2021-03-23 15:41:36 +08:00
}
2021-03-23 14:00:02 +08:00
2021-03-24 12:02:37 +08:00
var req = CreateTokenRequest ( ) ;
2021-03-23 14:00:02 +08:00
2021-03-23 15:41:36 +08:00
if ( req = = null )
{
2021-03-24 12:20:44 +08:00
handleTokenFailure ( new InvalidOperationException ( "Request could not be constructed." ) ) ;
2021-03-25 12:48:41 +08:00
return false ;
2021-03-23 15:41:36 +08:00
}
req . Success + = r = >
{
2022-10-03 16:22:30 +08:00
Logger . Log ( $"Score submission token retrieved ({r.ID})" ) ;
2021-03-24 12:20:44 +08:00
token = r . ID ;
2021-03-23 15:41:36 +08:00
tcs . SetResult ( true ) ;
2021-03-23 14:00:02 +08:00
} ;
2021-03-24 12:20:44 +08:00
req . Failure + = handleTokenFailure ;
2021-03-23 14:00:02 +08:00
api . Queue ( req ) ;
2022-08-20 16:19:17 +08:00
// Generally a timeout would not happen here as APIAccess will timeout first.
2022-11-18 13:07:40 +08:00
if ( ! tcs . Task . Wait ( 30000 ) )
2022-10-17 08:26:28 +08:00
req . TriggerFailure ( new InvalidOperationException ( "Token retrieval timed out (request never run)" ) ) ;
2022-08-20 16:19:17 +08:00
2021-03-25 12:48:41 +08:00
return true ;
2021-03-24 12:20:44 +08:00
void handleTokenFailure ( Exception exception )
2021-03-23 15:41:36 +08:00
{
2022-10-17 08:26:28 +08:00
tcs . SetResult ( false ) ;
2022-10-03 16:29:46 +08:00
2021-03-23 15:41:36 +08:00
if ( HandleTokenRetrievalFailure ( exception ) )
{
if ( string . IsNullOrEmpty ( exception . Message ) )
Logger . Error ( exception , "Failed to retrieve a score submission token." ) ;
else
Logger . Log ( $"You are not able to submit a score: {exception.Message}" , level : LogLevel . Important ) ;
Schedule ( ( ) = >
{
ValidForResume = false ;
this . Exit ( ) ;
} ) ;
}
2022-10-03 16:22:30 +08:00
else
{
// Gameplay is allowed to continue, but we still should keep track of the error.
// In the future, this should be visible to the user in some way.
Logger . Log ( $"Score submission token retrieval failed ({exception.Message})" ) ;
}
2021-03-23 15:41:36 +08:00
}
2021-03-23 14:00:02 +08:00
}
2021-03-23 15:41:36 +08:00
/// <summary>
/// Called when a token could not be retrieved for submission.
/// </summary>
/// <param name="exception">The error causing the failure.</param>
/// <returns>Whether gameplay should be immediately exited as a result. Returning false allows the gameplay session to continue. Defaults to true.</returns>
protected virtual bool HandleTokenRetrievalFailure ( Exception exception ) = > true ;
2021-07-01 16:02:33 +08:00
protected override async Task PrepareScoreForResultsAsync ( Score score )
{
await base . PrepareScoreForResultsAsync ( score ) . ConfigureAwait ( false ) ;
2021-07-19 18:28:35 +08:00
score . ScoreInfo . Date = DateTimeOffset . Now ;
2021-07-01 16:02:33 +08:00
await submitScore ( score ) . ConfigureAwait ( false ) ;
2022-12-18 04:08:11 +08:00
spectatorClient . EndPlaying ( GameplayState ) ;
2021-07-01 16:02:33 +08:00
}
2022-07-13 15:36:55 +08:00
[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
2022-07-13 21:37:20 +08:00
// from multiplayer / playlists / solo.
2022-07-13 15:36:55 +08:00
realm . WriteAsync ( r = >
{
var realmBeatmap = r . Find < BeatmapInfo > ( Beatmap . Value . BeatmapInfo . ID ) ;
if ( realmBeatmap ! = null )
realmBeatmap . LastPlayed = DateTimeOffset . Now ;
} ) ;
2022-07-28 19:44:02 +08:00
2022-12-12 12:59:27 +08:00
spectatorClient . BeginPlaying ( token , GameplayState , Score ) ;
2022-07-13 15:36:55 +08:00
}
2023-11-23 09:28:15 +08:00
protected override void OnFail ( )
2023-11-22 17:20:22 +08:00
{
2023-11-23 09:28:15 +08:00
base . OnFail ( ) ;
2023-11-22 17:20:22 +08:00
submitFromFailOrQuit ( ) ;
}
2022-04-21 23:52:44 +08:00
public override bool OnExiting ( ScreenExitEvent e )
2021-06-30 19:23:48 +08:00
{
2022-04-21 23:52:44 +08:00
bool exiting = base . OnExiting ( e ) ;
2023-11-22 17:20:22 +08:00
submitFromFailOrQuit ( ) ;
return exiting ;
}
2021-07-01 16:38:28 +08:00
2023-11-22 17:20:22 +08:00
private void submitFromFailOrQuit ( )
{
2022-03-09 16:50:05 +08:00
if ( LoadedBeatmapSuccessfully )
2022-07-28 19:44:02 +08:00
{
Ensure score submission attempt completion before notifying spectator server when exiting play early
When a `SubmittingPlayer` gameplay session ends with the successful
completion of a beatmap, `PrepareScoreForResultsAsync()` ensures that
the score submission request is sent to and responded to by osu-web
before calling `ISpectatorClient.EndPlaying()`.
While previously this was mostly an implementation detail, this becomes
important when considering that more and more server-side flows (replay
upload, notifying about score processing completion) hook into
`EndPlaying()`, and assume that by the point that message arrives at
osu-spectator-server, the score has already been submitted and has been
assigned a score ID that corresponds to the score submission token.
As it turns out, in the early-exit path (when the user exits the play
midway through, retries, or just fails), the same ordering guarantees
were not provided. The score's submission ran concurrently to the
spectator client `EndPlaying()` call, therefore creating a network
race. osu-server-spectator components that implciitly relied on the
ordering provided by the happy path, could therefore fail to unmap the
score submission token to a score ID.
Note that as written, the osu-server-spectator replay upload flow is
not really affected by this, as it self-corrects by essentially polling
the database and trying to unmap the score submission token to a score
ID for up to 30 seconds. However, this change would have the benefit of
reducing the polls required in such cases to just one DB retrieval.
2022-12-22 04:59:46 +08:00
Task . Run ( async ( ) = >
{
await submitScore ( Score . DeepClone ( ) ) . ConfigureAwait ( false ) ;
spectatorClient . EndPlaying ( GameplayState ) ;
} ) . FireAndForget ( ) ;
2022-07-28 19:44:02 +08:00
}
2021-06-30 19:23:48 +08:00
}
2021-07-01 16:02:33 +08:00
/// <summary>
/// Construct a request to be used for retrieval of the score token.
/// Can return null, at which point <see cref="HandleTokenRetrievalFailure"/> will be fired.
/// </summary>
[CanBeNull]
protected abstract APIRequest < APIScoreToken > CreateTokenRequest ( ) ;
2021-03-23 14:00:02 +08:00
2021-07-01 16:02:33 +08:00
/// <summary>
/// Construct a request to submit the score.
/// Will only be invoked if the request constructed via <see cref="CreateTokenRequest"/> was successful.
/// </summary>
/// <param name="score">The score to be submitted.</param>
/// <param name="token">The submission token.</param>
protected abstract APIRequest < MultiplayerScore > CreateSubmissionRequest ( Score score , long token ) ;
private Task submitScore ( Score score )
{
2021-03-23 15:41:36 +08:00
// token may be null if the request failed but gameplay was still allowed (see HandleTokenRetrievalFailure).
2021-03-24 12:20:44 +08:00
if ( token = = null )
2023-10-30 14:07:26 +08:00
{
Logger . Log ( "No token, skipping score submission" ) ;
2021-07-01 16:02:33 +08:00
return Task . CompletedTask ;
2023-10-30 14:07:26 +08:00
}
2021-03-23 14:00:02 +08:00
2021-07-01 16:02:33 +08:00
if ( scoreSubmissionSource ! = null )
return scoreSubmissionSource . Task ;
2021-07-04 14:14:25 +08:00
// if the user never hit anything, this score should not be counted in any way.
2021-07-04 15:41:09 +08:00
if ( ! score . ScoreInfo . Statistics . Any ( s = > s . Key . IsHit ( ) & & s . Value > 0 ) )
2021-07-04 14:14:25 +08:00
return Task . CompletedTask ;
2023-10-30 14:07:26 +08:00
Logger . Log ( $"Beginning score submission (token:{token.Value})..." ) ;
2021-07-01 16:02:33 +08:00
scoreSubmissionSource = new TaskCompletionSource < bool > ( ) ;
2021-03-24 12:20:44 +08:00
var request = CreateSubmissionRequest ( score , token . Value ) ;
2021-03-23 14:00:02 +08:00
request . Success + = s = >
{
2021-12-10 14:37:12 +08:00
score . ScoreInfo . OnlineID = s . ID ;
2021-12-08 14:22:02 +08:00
score . ScoreInfo . Position = s . Position ;
2021-07-01 16:02:33 +08:00
scoreSubmissionSource . SetResult ( true ) ;
2023-10-30 14:07:26 +08:00
Logger . Log ( $"Score submission completed! (token:{token.Value} id:{s.ID})" ) ;
2021-03-23 14:00:02 +08:00
} ;
request . Failure + = e = >
{
2023-10-30 14:07:26 +08:00
Logger . Error ( e , $"Failed to submit score (token:{token.Value}): {e.Message}" ) ;
2021-07-01 16:02:33 +08:00
scoreSubmissionSource . SetResult ( false ) ;
2021-03-23 14:00:02 +08:00
} ;
api . Queue ( request ) ;
2021-07-01 16:02:33 +08:00
return scoreSubmissionSource . Task ;
2021-03-23 14:00:02 +08:00
}
}
}