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
10 changed files with 318 additions and 95 deletions

View File

@ -58,7 +58,7 @@ namespace osu.Game.Tests.Visual.Navigation
public void TestPerformAtSongSelectFromPlayerLoader() public void TestPerformAtSongSelectFromPlayerLoader()
{ {
PushAndConfirm(() => new PlaySongSelect()); PushAndConfirm(() => new PlaySongSelect());
PushAndConfirm(() => new PlayerLoader(() => new Player())); PushAndConfirm(() => new PlayerLoader(() => new SoloPlayer()));
AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(PlaySongSelect) })); AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(PlaySongSelect) }));
AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect);
@ -69,7 +69,7 @@ namespace osu.Game.Tests.Visual.Navigation
public void TestPerformAtMenuFromPlayerLoader() public void TestPerformAtMenuFromPlayerLoader()
{ {
PushAndConfirm(() => new PlaySongSelect()); PushAndConfirm(() => new PlaySongSelect());
PushAndConfirm(() => new PlayerLoader(() => new Player())); PushAndConfirm(() => new PlayerLoader(() => new SoloPlayer()));
AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true)); AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true));
AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is MainMenu); 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.Multiplayer;
using osu.Game.Online.Rooms; using osu.Game.Online.Rooms;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Screens.OnlinePlay.Playlists;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Ranking; using osu.Game.Screens.Ranking;
@ -19,8 +18,7 @@ using osuTK;
namespace osu.Game.Screens.OnlinePlay.Multiplayer 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 : RoomSubmittingPlayer
public class MultiplayerPlayer : PlaylistsPlayer
{ {
protected override bool PauseOnFocusLost => false; protected override bool PauseOnFocusLost => false;
@ -63,9 +61,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(ScoreProcessor, userIds), HUDOverlay.Add); LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(ScoreProcessor, userIds), HUDOverlay.Add);
HUDOverlay.Add(loadingDisplay = new LoadingLayer(true) { Depth = float.MaxValue }); HUDOverlay.Add(loadingDisplay = new LoadingLayer(true) { Depth = float.MaxValue });
}
if (Token == null) protected override void LoadAsyncComplete()
return; // Todo: Somehow handle token retrieval failure. {
base.LoadAsyncComplete();
if (!ValidForResume)
return; // token retrieval may have failed.
client.MatchStarted += onMatchStarted; client.MatchStarted += onMatchStarted;
client.ResultsReady += onResultsReady; client.ResultsReady += onResultsReady;
@ -135,9 +138,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
private void onResultsReady() => resultsReady.SetResult(true); 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); await client.ChangeState(MultiplayerUserState.FinishedPlay).ConfigureAwait(false);

View File

@ -4,13 +4,9 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Logging;
using osu.Framework.Screens; using osu.Framework.Screens;
using osu.Game.Online.API;
using osu.Game.Online.Rooms; using osu.Game.Online.Rooms;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Scoring; using osu.Game.Scoring;
@ -19,36 +15,18 @@ using osu.Game.Screens.Ranking;
namespace osu.Game.Screens.OnlinePlay.Playlists namespace osu.Game.Screens.OnlinePlay.Playlists
{ {
public class PlaylistsPlayer : Player public class PlaylistsPlayer : RoomSubmittingPlayer
{ {
public Action Exited; 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) public PlaylistsPlayer(PlaylistItem playlistItem, PlayerConfiguration configuration = null)
: base(configuration) : base(playlistItem, configuration)
{ {
PlaylistItem = playlistItem;
} }
[BackgroundDependencyLoader] [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 // Sanity checks to ensure that PlaylistsPlayer matches the settings for the current PlaylistItem
if (Beatmap.Value.BeatmapInfo.OnlineBeatmapID != PlaylistItem.Beatmap.Value.OnlineBeatmapID) if (Beatmap.Value.BeatmapInfo.OnlineBeatmapID != PlaylistItem.Beatmap.Value.OnlineBeatmapID)
throw new InvalidOperationException("Current Beatmap does not match PlaylistItem's Beatmap"); 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))) if (!PlaylistItem.RequiredMods.All(m => Mods.Value.Any(m.Equals)))
throw new InvalidOperationException("Current Mods do not match PlaylistItem's RequiredMods"); 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) public override bool OnExiting(IScreen next)
@ -106,31 +61,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
return score; 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) protected override void Dispose(bool isDisposing)
{ {
base.Dispose(isDisposing); base.Dispose(isDisposing);

View File

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