Merge pull request #12152 from peppy/solo-score-submission

Add solo score submission flow
This commit is contained in:
Dan Balasescu 2021-03-25 14:24:43 +09:00 committed by GitHub
commit cfc65d5226
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 318 additions and 95 deletions

View File

@ -58,7 +58,7 @@ namespace osu.Game.Tests.Visual.Navigation
public void TestPerformAtSongSelectFromPlayerLoader()
{
PushAndConfirm(() => new PlaySongSelect());
PushAndConfirm(() => new PlayerLoader(() => new Player()));
PushAndConfirm(() => new PlayerLoader(() => new SoloPlayer()));
AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(PlaySongSelect) }));
AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect);
@ -69,7 +69,7 @@ namespace osu.Game.Tests.Visual.Navigation
public void TestPerformAtMenuFromPlayerLoader()
{
PushAndConfirm(() => new PlaySongSelect());
PushAndConfirm(() => new PlayerLoader(() => new Player()));
PushAndConfirm(() => new PlayerLoader(() => new SoloPlayer()));
AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true));
AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is MainMenu);

View File

@ -0,0 +1,32 @@
// 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.Net.Http;
using osu.Framework.IO.Network;
using osu.Game.Online.API;
using osu.Game.Online.Rooms;
namespace osu.Game.Online.Solo
{
public class CreateSoloScoreRequest : APIRequest<APIScoreToken>
{
private readonly int beatmapId;
private readonly string versionHash;
public CreateSoloScoreRequest(int beatmapId, string versionHash)
{
this.beatmapId = beatmapId;
this.versionHash = versionHash;
}
protected override WebRequest CreateWebRequest()
{
var req = base.CreateWebRequest();
req.Method = HttpMethod.Post;
req.AddParameter("version_hash", versionHash);
return req;
}
protected override string Target => $@"solo/{beatmapId}/scores";
}
}

View File

@ -0,0 +1,45 @@
// 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.Net.Http;
using Newtonsoft.Json;
using osu.Framework.IO.Network;
using osu.Game.Online.API;
using osu.Game.Online.Rooms;
using osu.Game.Scoring;
namespace osu.Game.Online.Solo
{
public class SubmitSoloScoreRequest : APIRequest<MultiplayerScore>
{
private readonly long scoreId;
private readonly int beatmapId;
private readonly ScoreInfo scoreInfo;
public SubmitSoloScoreRequest(int beatmapId, long scoreId, ScoreInfo scoreInfo)
{
this.beatmapId = beatmapId;
this.scoreId = scoreId;
this.scoreInfo = scoreInfo;
}
protected override WebRequest CreateWebRequest()
{
var req = base.CreateWebRequest();
req.ContentType = "application/json";
req.Method = HttpMethod.Put;
req.AddRaw(JsonConvert.SerializeObject(scoreInfo, new JsonSerializerSettings
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore
}));
return req;
}
protected override string Target => $@"solo/{beatmapId}/scores/{scoreId}";
}
}

View File

@ -11,7 +11,6 @@ using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Scoring;
using osu.Game.Screens.OnlinePlay.Playlists;
using osu.Game.Screens.Play;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Ranking;
@ -19,8 +18,7 @@ using osuTK;
namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
// Todo: The "room" part of PlaylistsPlayer should be split out into an abstract player class to be inherited instead.
public class MultiplayerPlayer : PlaylistsPlayer
public class MultiplayerPlayer : RoomSubmittingPlayer
{
protected override bool PauseOnFocusLost => false;
@ -63,9 +61,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(ScoreProcessor, userIds), HUDOverlay.Add);
HUDOverlay.Add(loadingDisplay = new LoadingLayer(true) { Depth = float.MaxValue });
}
if (Token == null)
return; // Todo: Somehow handle token retrieval failure.
protected override void LoadAsyncComplete()
{
base.LoadAsyncComplete();
if (!ValidForResume)
return; // token retrieval may have failed.
client.MatchStarted += onMatchStarted;
client.ResultsReady += onResultsReady;
@ -135,9 +138,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
private void onResultsReady() => resultsReady.SetResult(true);
protected override async Task SubmitScore(Score score)
protected override async Task PrepareScoreForResultsAsync(Score score)
{
await base.SubmitScore(score).ConfigureAwait(false);
await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false);
await client.ChangeState(MultiplayerUserState.FinishedPlay).ConfigureAwait(false);

View File

@ -4,13 +4,9 @@
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Logging;
using osu.Framework.Screens;
using osu.Game.Online.API;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets;
using osu.Game.Scoring;
@ -19,36 +15,18 @@ using osu.Game.Screens.Ranking;
namespace osu.Game.Screens.OnlinePlay.Playlists
{
public class PlaylistsPlayer : Player
public class PlaylistsPlayer : RoomSubmittingPlayer
{
public Action Exited;
[Resolved(typeof(Room), nameof(Room.RoomID))]
protected Bindable<long?> RoomId { get; private set; }
protected readonly PlaylistItem PlaylistItem;
protected long? Token { get; private set; }
[Resolved]
private IAPIProvider api { get; set; }
[Resolved]
private IBindable<RulesetInfo> ruleset { get; set; }
public PlaylistsPlayer(PlaylistItem playlistItem, PlayerConfiguration configuration = null)
: base(configuration)
: base(playlistItem, configuration)
{
PlaylistItem = playlistItem;
}
[BackgroundDependencyLoader]
private void load()
private void load(IBindable<RulesetInfo> ruleset)
{
Token = null;
bool failed = false;
// Sanity checks to ensure that PlaylistsPlayer matches the settings for the current PlaylistItem
if (Beatmap.Value.BeatmapInfo.OnlineBeatmapID != PlaylistItem.Beatmap.Value.OnlineBeatmapID)
throw new InvalidOperationException("Current Beatmap does not match PlaylistItem's Beatmap");
@ -58,29 +36,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
if (!PlaylistItem.RequiredMods.All(m => Mods.Value.Any(m.Equals)))
throw new InvalidOperationException("Current Mods do not match PlaylistItem's RequiredMods");
var req = new CreateRoomScoreRequest(RoomId.Value ?? 0, PlaylistItem.ID, Game.VersionHash);
req.Success += r => Token = r.ID;
req.Failure += e =>
{
failed = true;
if (string.IsNullOrEmpty(e.Message))
Logger.Error(e, "Failed to retrieve a score submission token.");
else
Logger.Log($"You are not able to submit a score: {e.Message}", level: LogLevel.Important);
Schedule(() =>
{
ValidForResume = false;
this.Exit();
});
};
api.Queue(req);
while (!failed && !Token.HasValue)
Thread.Sleep(1000);
}
public override bool OnExiting(IScreen next)
@ -106,31 +61,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
return score;
}
protected override async Task SubmitScore(Score score)
{
await base.SubmitScore(score).ConfigureAwait(false);
Debug.Assert(Token != null);
var tcs = new TaskCompletionSource<bool>();
var request = new SubmitRoomScoreRequest(Token.Value, RoomId.Value ?? 0, PlaylistItem.ID, score.ScoreInfo);
request.Success += s =>
{
score.ScoreInfo.OnlineScoreID = s.ID;
tcs.SetResult(true);
};
request.Failure += e =>
{
Logger.Error(e, "Failed to submit score");
tcs.SetResult(false);
};
api.Queue(request);
await tcs.Task.ConfigureAwait(false);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);

View File

@ -39,7 +39,7 @@ namespace osu.Game.Screens.Play
{
[Cached]
[Cached(typeof(ISamplePlaybackDisabler))]
public class Player : ScreenWithBeatmapBackground, ISamplePlaybackDisabler
public abstract class Player : ScreenWithBeatmapBackground, ISamplePlaybackDisabler
{
/// <summary>
/// The delay upon completion of the beatmap before displaying the results screen.
@ -135,7 +135,7 @@ namespace osu.Game.Screens.Play
/// <summary>
/// Create a new player instance.
/// </summary>
public Player(PlayerConfiguration configuration = null)
protected Player(PlayerConfiguration configuration = null)
{
Configuration = configuration ?? new PlayerConfiguration();
}
@ -559,7 +559,7 @@ namespace osu.Game.Screens.Play
}
private ScheduledDelegate completionProgressDelegate;
private Task<ScoreInfo> scoreSubmissionTask;
private Task<ScoreInfo> prepareScoreForDisplayTask;
private void updateCompletionState(ValueChangedEvent<bool> completionState)
{
@ -586,17 +586,17 @@ namespace osu.Game.Screens.Play
if (!Configuration.ShowResults) return;
scoreSubmissionTask ??= Task.Run(async () =>
prepareScoreForDisplayTask ??= Task.Run(async () =>
{
var score = CreateScore();
try
{
await SubmitScore(score).ConfigureAwait(false);
await PrepareScoreForResultsAsync(score).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Error(ex, "Score submission failed!");
Logger.Error(ex, "Score preparation failed!");
}
try
@ -617,7 +617,7 @@ namespace osu.Game.Screens.Play
private void scheduleCompletion() => completionProgressDelegate = Schedule(() =>
{
if (!scoreSubmissionTask.IsCompleted)
if (!prepareScoreForDisplayTask.IsCompleted)
{
scheduleCompletion();
return;
@ -625,7 +625,7 @@ namespace osu.Game.Screens.Play
// screen may be in the exiting transition phase.
if (this.IsCurrentScreen())
this.Push(CreateResults(scoreSubmissionTask.Result));
this.Push(CreateResults(prepareScoreForDisplayTask.Result));
});
protected override bool OnScroll(ScrollEvent e) => mouseWheelDisabled.Value && !GameplayClockContainer.IsPaused.Value;
@ -895,11 +895,11 @@ namespace osu.Game.Screens.Play
}
/// <summary>
/// Submits the player's <see cref="Score"/>.
/// Prepare the <see cref="Score"/> for display at results.
/// </summary>
/// <param name="score">The <see cref="Score"/> to submit.</param>
/// <returns>The submitted score.</returns>
protected virtual Task SubmitScore(Score score) => Task.CompletedTask;
/// <param name="score">The <see cref="Score"/> to prepare.</param>
/// <returns>A task that prepares the provided score. On completion, the score is assumed to be ready for display.</returns>
protected virtual Task PrepareScoreForResultsAsync(Score score) => Task.CompletedTask;
/// <summary>
/// Creates the <see cref="ResultsScreen"/> for a <see cref="ScoreInfo"/>.

View File

@ -0,0 +1,38 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Game.Online.API;
using osu.Game.Online.Rooms;
using osu.Game.Scoring;
namespace osu.Game.Screens.Play
{
/// <summary>
/// A player instance which submits to a room backing. This is generally used by playlists and multiplayer.
/// </summary>
public abstract class RoomSubmittingPlayer : SubmittingPlayer
{
[Resolved(typeof(Room), nameof(Room.RoomID))]
protected Bindable<long?> RoomId { get; private set; }
protected readonly PlaylistItem PlaylistItem;
protected RoomSubmittingPlayer(PlaylistItem playlistItem, PlayerConfiguration configuration = null)
: base(configuration)
{
PlaylistItem = playlistItem;
}
protected override APIRequest<APIScoreToken> CreateTokenRequest()
{
if (!(RoomId.Value is long roomId))
return null;
return new CreateRoomScoreRequest(roomId, PlaylistItem.ID, Game.VersionHash);
}
protected override APIRequest<MultiplayerScore> CreateSubmissionRequest(Score score, long token) => new SubmitRoomScoreRequest(token, RoomId.Value ?? 0, PlaylistItem.ID, score.ScoreInfo);
}
}

View File

@ -0,0 +1,34 @@
// 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.Diagnostics;
using osu.Game.Online.API;
using osu.Game.Online.Rooms;
using osu.Game.Online.Solo;
using osu.Game.Scoring;
namespace osu.Game.Screens.Play
{
public class SoloPlayer : SubmittingPlayer
{
protected override APIRequest<APIScoreToken> CreateTokenRequest()
{
if (!(Beatmap.Value.BeatmapInfo.OnlineBeatmapID is int beatmapId))
return null;
return new CreateSoloScoreRequest(beatmapId, Game.VersionHash);
}
protected override bool HandleTokenRetrievalFailure(Exception exception) => false;
protected override APIRequest<MultiplayerScore> CreateSubmissionRequest(Score score, long token)
{
Debug.Assert(Beatmap.Value.BeatmapInfo.OnlineBeatmapID != null);
int beatmapId = Beatmap.Value.BeatmapInfo.OnlineBeatmapID.Value;
return new SubmitSoloScoreRequest(beatmapId, token, score.ScoreInfo);
}
}
}

View File

@ -0,0 +1,141 @@
// 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.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Logging;
using osu.Framework.Screens;
using osu.Game.Online.API;
using osu.Game.Online.Rooms;
using osu.Game.Scoring;
namespace osu.Game.Screens.Play
{
/// <summary>
/// A player instance which supports submitting scores to an online store.
/// </summary>
public abstract class SubmittingPlayer : Player
{
/// <summary>
/// The token to be used for the current submission. This is fetched via a request created by <see cref="CreateTokenRequest"/>.
/// </summary>
private long? token;
[Resolved]
private IAPIProvider api { get; set; }
protected SubmittingPlayer(PlayerConfiguration configuration = null)
: base(configuration)
{
}
protected override void LoadAsyncComplete()
{
if (!handleTokenRetrieval()) return;
base.LoadAsyncComplete();
}
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<bool>();
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 =>
{
token = r.ID;
tcs.SetResult(true);
};
req.Failure += handleTokenFailure;
api.Queue(req);
tcs.Task.Wait();
return true;
void handleTokenFailure(Exception exception)
{
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();
});
}
tcs.SetResult(false);
}
}
/// <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;
protected override async Task PrepareScoreForResultsAsync(Score score)
{
await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false);
// token may be null if the request failed but gameplay was still allowed (see HandleTokenRetrievalFailure).
if (token == null)
return;
var tcs = new TaskCompletionSource<bool>();
var request = CreateSubmissionRequest(score, token.Value);
request.Success += s =>
{
score.ScoreInfo.OnlineScoreID = s.ID;
tcs.SetResult(true);
};
request.Failure += e =>
{
Logger.Error(e, "Failed to submit score");
tcs.SetResult(false);
};
api.Queue(request);
await tcs.Task.ConfigureAwait(false);
}
/// <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();
/// <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);
}
}

View File

@ -106,7 +106,7 @@ namespace osu.Game.Screens.Select
SampleConfirm?.Play();
this.Push(player = new PlayerLoader(() => new Player()));
this.Push(player = new PlayerLoader(() => new SoloPlayer()));
return true;
}