diff --git a/Refresh.Database/GameDatabaseContext.Leaderboard.cs b/Refresh.Database/GameDatabaseContext.Leaderboard.cs index 6f1fd53e1..eba5d3272 100644 --- a/Refresh.Database/GameDatabaseContext.Leaderboard.cs +++ b/Refresh.Database/GameDatabaseContext.Leaderboard.cs @@ -110,6 +110,28 @@ public DatabaseScoreList GetTopScoresForLevel(GameLevel level, int count, int sk return new(scores.ToArray().Select(s => new ScoreWithRank(s, s.Rank)), skip, count, user); } + + // TODO all these score-specific extra params should probably be passed in by a dedicated struct, like with level params. + public DatabaseList GetTopScoresByUser(GameUser user, int count, int skip, byte scoreType, uint minRank, uint maxRank, bool showDuplicates = false) + { + IEnumerable scores = this.GameScoresIncluded + .Where(s => s.PublisherId == user.UserId) + .OrderByDescending(s => s.Score); + + if (scoreType != 0) + scores = scores.Where(s => s.ScoreType == scoreType); + + if (!showDuplicates) + scores = scores.Where(s => s.Rank != 0); + + if (minRank > 0) + scores = scores.Where(s => s.Rank >= minRank); + + if (maxRank > 0) + scores = scores.Where(s => s.Rank <= maxRank); + + return new(scores.ToArray().Select(s => new ScoreWithRank(s, s.Rank)), skip, count); + } public DatabaseScoreList GetRankedScoresAroundScore(GameScore score, int count, GameUser? user = null) { diff --git a/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiValidationError.cs b/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiValidationError.cs index 97ae34efe..7d28543b0 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiValidationError.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiValidationError.cs @@ -104,6 +104,12 @@ public class ApiValidationError : ApiError public const string NoPlaylistDeletePermissionErrorWhen = "You do not have permission to delete this playlist"; public static readonly ApiValidationError NoPlaylistDeletePermissionError = new(NoPlaylistDeletePermissionErrorWhen); + public const string ScoreModeInvalidErrorWhen = "The score mode was not a number or higher than 4."; + public static readonly ApiValidationError ScoreModeInvalidError = new(ScoreModeInvalidErrorWhen); + + public const string ScoreRanksInvalidErrorWhen = "The minRank or maxRank parameters were not numbers."; + public static readonly ApiValidationError ScoreRanksInvalidError = new(ScoreRanksInvalidErrorWhen); + // TODO: Split off error messages which are actually 401 or anything else that isn't 400 public ApiValidationError(string message) : base(message) {} } diff --git a/Refresh.Interfaces.APIv3/Endpoints/LeaderboardApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/LeaderboardApiEndpoints.cs index 27a9a8e5f..74ba61bc9 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/LeaderboardApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/LeaderboardApiEndpoints.cs @@ -7,7 +7,9 @@ using Refresh.Database; using Refresh.Database.Models.Levels; using Refresh.Database.Models.Levels.Scores; +using Refresh.Database.Models.Users; using Refresh.Interfaces.APIv3.Documentation.Attributes; +using Refresh.Interfaces.APIv3.Documentation.Descriptions; using Refresh.Interfaces.APIv3.Endpoints.ApiTypes; using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors; using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels; @@ -45,6 +47,52 @@ public ApiListResponse GetTopScoresForLevel(RequestContext DatabaseList ret = DatabaseListExtensions.FromOldList(scores, dataContext); return ret; } + + [ApiV3Endpoint("users/{idType}/{userId}/scores"), Authentication(false)] + [DocUsesPageData, DocSummary("Gets a list of the top scores on a level.")] + // TODO i really can't wait to move all these strings to their own place, this looks really messy (like many other API endpoints) + [DocQueryParam("showAll", "Whether or not to show all scores. If false, only the user's best scores per level/mode will be shown." + + "If true, all scores will be shown no matter what. False by default.")] + [DocQueryParam("mode", "The leaderboard mode (aka the number of players, e.g. 2 for 2-player mode)." + + "If 0 or missing, scores won't be filtered by mode.")] + [DocQueryParam("minRank", "The minimum rank all scores have to be at (among their levels' main leaderboard)." + + "For example, minRank 1 and maxRank 3 will only keep all scores which are currently rank 1, 2 or 3." + + "If 0 or missing, scores won't be filtered by minimum rank.")] + [DocQueryParam("maxRank", "The maximum rank all scores have to be at (among their levels' main leaderboard)." + + "For example, minRank 1 and maxRank 3 will only keep all scores which are currently rank 1, 2 or 3." + + "If 0 or missing, scores won't be filtered by minimum rank.")] + [DocError(typeof(ApiNotFoundError), ApiNotFoundError.UserMissingErrorWhen)] + [DocError(typeof(ApiValidationError), "The boolean 'showAll' could not be parsed by the server.")] + [DocError(typeof(ApiNotFoundError), ApiValidationError.ScoreModeInvalidErrorWhen)] + [DocError(typeof(ApiNotFoundError), ApiValidationError.ScoreRanksInvalidErrorWhen)] + public ApiListResponse GetTopScoresByUser(RequestContext context, + [DocSummary(SharedParamDescriptions.UserIdParam)] string userId, + [DocSummary(SharedParamDescriptions.UserIdTypeParam)] string idType, + DataContext dataContext) + { + GameUser? user = dataContext.Database.GetUserByIdAndType(idType, userId); + if(user == null) return ApiNotFoundError.UserMissingError; + + (int skip, int count) = context.GetPageData(); + + // TODO "showAll" is not very clear, rename it for both endpoints in APIv4 + bool result = bool.TryParse(context.QueryString.Get("showAll") ?? "false", out bool showAll); + if (!result) return ApiValidationError.BooleanParseError; + + bool modeParsed = byte.TryParse(context.QueryString.Get("mode") ?? "0", out byte mode); + if (!modeParsed || mode > 4) return ApiValidationError.ScoreModeInvalidError; + + bool minRankParsed = uint.TryParse(context.QueryString.Get("minRank") ?? "0", out uint minRank); + bool maxRankParsed = uint.TryParse(context.QueryString.Get("maxRank") ?? "0", out uint maxRank); + // Don't think it's necessary to check whether min is higher than max. + // Could be friendly and warn the client instead of just returning an empty list, but that's just unnecessary + // extra work for us for nearly no reason, so whatever. + if (!minRankParsed || !maxRankParsed) return ApiValidationError.ScoreRanksInvalidError; + + DatabaseList scores = dataContext.Database.GetTopScoresByUser(user, count, skip, mode, minRank, maxRank, showAll); + DatabaseList ret = DatabaseListExtensions.FromOldList(scores, dataContext); + return ret; + } [ApiV3Endpoint("scores/{uuid}"), Authentication(false)] [DocSummary("Gets an individual score by a UUID")] diff --git a/RefreshTests.GameServer/Tests/ApiV3/LeaderboardApiTests.cs b/RefreshTests.GameServer/Tests/ApiV3/LeaderboardApiTests.cs index a2d8aa05a..d0cc45220 100644 --- a/RefreshTests.GameServer/Tests/ApiV3/LeaderboardApiTests.cs +++ b/RefreshTests.GameServer/Tests/ApiV3/LeaderboardApiTests.cs @@ -1,6 +1,10 @@ using Refresh.Database.Models.Authentication; using Refresh.Database.Models.Levels; using Refresh.Database.Models.Users; +using Refresh.Interfaces.APIv3.Endpoints.ApiTypes; +using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels; +using Refresh.Interfaces.Game.Types.UserData.Leaderboard; +using RefreshTests.GameServer.Extensions; namespace RefreshTests.GameServer.Tests.ApiV3; @@ -29,4 +33,220 @@ public async Task DeletesScoresByPublisherUuidAndName() Assert.That(resetResponse.IsSuccessStatusCode, Is.True); Assert.That(context.Database.GetTopScoresForLevel(level, 100, 0, 1).TotalItems, Is.Zero); } + + private void AssertResponseListCount(ApiListResponse? response, int count) + { + Assert.That(response?.Data, Is.Not.Null); + Assert.That(response.ListInfo, Is.Not.Null); + Assert.That(response.Data.Count, Is.EqualTo(count)); + Assert.That(response.ListInfo.TotalItems, Is.EqualTo(count)); + } + + [Test] + public void GetsScoresByUserOnVariousLevels() + { + using TestContext context = this.GetServer(); + GameUser uploader = context.CreateUser(); + + // Post 1 score per mode per level, 3 levels in total, each score is slightly higher than the previous one. + for (int i = 0; i < 3; i++) + { + GameLevel level = context.CreateLevel(uploader); + for (byte mode = 1; mode <= 4; mode++) + { + SerializedScore score = new() + { + Host = true, + ScoreType = mode, + Score = 434834, + PlayerUsernames = [uploader.Username], + }; + context.Database.SubmitScore(score, uploader, level, TokenGame.LittleBigPlanet1, TokenPlatform.RPCS3, [uploader]); + } + } + + for (byte mode = 1; mode <= 4; mode++) + { + // UUID + ApiListResponse? response = context.Http.GetList($"/api/v3/users/uuid/{uploader.UserId.ToString()}/scores?mode={mode}"); + this.AssertResponseListCount(response, 3); + + // name + response = context.Http.GetList($"/api/v3/users/name/{uploader.Username}/scores?mode={mode}"); + this.AssertResponseListCount(response, 3); + } + + // Ensure that no mode and mode 0 will both return all scores. + ApiListResponse? bigResponse = context.Http.GetList($"/api/v3/users/name/{uploader.Username}/scores"); + this.AssertResponseListCount(bigResponse, 12); + + bigResponse = context.Http.GetList($"/api/v3/users/name/{uploader.Username}/scores?mode=0"); + this.AssertResponseListCount(bigResponse, 12); + } + + [Test] + [TestCase(false)] + [TestCase(true)] + public void GetsScoresByUserOnSameLevel(bool showOvertaken) + { + using TestContext context = this.GetServer(); + GameUser uploader = context.CreateUser(); + GameLevel level = context.CreateLevel(uploader); + + // Post 3 scores per mode to the same level, each score is slightly higher than the previous one, + // so the user will end up overtaking their own score. + for (int i = 0; i < 3; i++) + { + for (byte mode = 1; mode <= 4; mode++) + { + SerializedScore score = new() + { + Host = true, + ScoreType = mode, + Score = 434834 + i, + PlayerUsernames = [uploader.Username], + }; + context.Database.SubmitScore(score, uploader, level, TokenGame.LittleBigPlanet1, TokenPlatform.RPCS3, [uploader]); + } + } + + for (byte mode = 1; mode <= 4; mode++) + { + // Show either just the one best score on this level, or also include the 2 overtaken ones. + // UUID + ApiListResponse? response = context.Http.GetList($"/api/v3/users/uuid/{uploader.UserId.ToString()}/scores?mode={mode}&showAll={showOvertaken}"); + this.AssertResponseListCount(response, showOvertaken ? 3 : 1); + + // name + response = context.Http.GetList($"/api/v3/users/name/{uploader.Username}/scores?mode={mode}&showAll={showOvertaken}"); + this.AssertResponseListCount(response, showOvertaken ? 3 : 1); + } + + // Ensure that no mode and mode 0 will both return all scores. + // Will either return just the best score per mode, or all of them. + ApiListResponse? bigResponse = context.Http.GetList($"/api/v3/users/name/{uploader.Username}/scores?showAll={showOvertaken}"); + this.AssertResponseListCount(bigResponse, showOvertaken ? 12 : 4); + + bigResponse = context.Http.GetList($"/api/v3/users/name/{uploader.Username}/scores?mode=0&showAll={showOvertaken}"); + this.AssertResponseListCount(bigResponse, showOvertaken ? 12 : 4); + } + + [Test] + [TestCase(false)] + [TestCase(true)] + public void DontFilterOutScoresNotOvertakenByOwnPublisher(bool showOvertaken) + { + using TestContext context = this.GetServer(); + GameUser uploader = context.CreateUser(); + GameLevel level = context.CreateLevel(uploader); + + // Upload the scores of the user we want, one per type + for (byte mode = 1; mode <= 4; mode++) + { + SerializedScore ogScore = new() + { + Host = true, + ScoreType = mode, + Score = 2344, + PlayerUsernames = [uploader.Username], + }; + context.Database.SubmitScore(ogScore, uploader, level, TokenGame.LittleBigPlanet1, TokenPlatform.RPCS3, [uploader]); + } + + // Post 1 score per extra user per mode to the same level, each score is slightly higher than the previous one, + // so we will have 4 scores by a different user each per mode. + for (int i = 0; i < 2; i++) + { + GameUser anotherUploader = context.CreateUser(); + for (byte mode = 1; mode <= 4; mode++) + { + SerializedScore score = new() + { + Host = true, + ScoreType = mode, + Score = 434834 + i, + PlayerUsernames = [anotherUploader.Username], + }; + context.Database.SubmitScore(score, anotherUploader, level, TokenGame.LittleBigPlanet1, TokenPlatform.RPCS3, [anotherUploader]); + } + } + + for (byte mode = 1; mode <= 4; mode++) + { + // Show either just the one best score on this level, or also include the 2 overtaken ones. + // UUID + ApiListResponse? response = context.Http.GetList($"/api/v3/users/uuid/{uploader.UserId.ToString()}/scores?mode={mode}&showAll={showOvertaken}"); + this.AssertResponseListCount(response, 1); // user still has just one of this type, regardless of whether we're showing all scores by them + + response = context.Http.GetList($"/api/v3/scores/{level.LevelId}/{mode}?showAll={showOvertaken}"); + this.AssertResponseListCount(response, 3); // the user's score + the ones by the 2 extra users + } + + ApiListResponse? bigResponse = context.Http.GetList($"/api/v3/users/uuid/{uploader.UserId.ToString()}/scores?showAll={showOvertaken}"); + this.AssertResponseListCount(bigResponse, 4); // user still has just one per type, and they're all not overtaken by themselves + + bigResponse = context.Http.GetList($"/api/v3/scores/{level.LevelId}/0?showAll={showOvertaken}"); + this.AssertResponseListCount(bigResponse, 12); // all scores by all users regardless of type + } + + [Test] + public void CanFilterUserScoresByRank() + { + using TestContext context = this.GetServer(); + GameUser uploader = context.CreateUser(); + + // Upload scores by the same user on various levels + for (int i = 0; i < 10; i++) + { + GameLevel anotherLevel = context.CreateLevel(uploader); + SerializedScore score = new() + { + Host = true, + ScoreType = 1, + Score = i, + PlayerUsernames = [uploader.Username], + }; + context.Database.SubmitScore(score, uploader, anotherLevel, TokenGame.LittleBigPlanet1, TokenPlatform.RPCS3, [uploader]); + + // Also put scores by others there, so the uploader has a score per level, and these scores all have different ranks + for (int u = 0; u < 10; u++) + { + GameUser anotherUploader = context.CreateUser(); + SerializedScore anotherScore = new() + { + Host = true, + ScoreType = 1, + Score = u, + PlayerUsernames = [anotherUploader.Username], + }; + context.Database.SubmitScore(anotherScore, anotherUploader, anotherLevel, TokenGame.LittleBigPlanet1, TokenPlatform.RPCS3, [anotherUploader]); + } + } + + context.Database.Refresh(); + + // No filters + ApiListResponse? bigResponse = context.Http.GetList($"/api/v3/users/uuid/{uploader.UserId.ToString()}/scores"); + this.AssertResponseListCount(bigResponse, 10); + + // Settings filters to 0 is the same as no filter + bigResponse = context.Http.GetList($"/api/v3/users/uuid/{uploader.UserId.ToString()}/scores?minRank=0&maxRank=0"); + this.AssertResponseListCount(bigResponse, 10); + + // Can use just minRank + bigResponse = context.Http.GetList($"/api/v3/users/uuid/{uploader.UserId.ToString()}/scores?minRank=5"); + this.AssertResponseListCount(bigResponse, 6); + + // Can use just maxRank + bigResponse = context.Http.GetList($"/api/v3/users/uuid/{uploader.UserId.ToString()}/scores?maxRank=3"); + this.AssertResponseListCount(bigResponse, 3); + + // Can use both + bigResponse = context.Http.GetList($"/api/v3/users/uuid/{uploader.UserId.ToString()}/scores?minRank=4&maxRank=7"); + this.AssertResponseListCount(bigResponse, 4); + + // Can use both to return all scores of just one rank + bigResponse = context.Http.GetList($"/api/v3/users/uuid/{uploader.UserId.ToString()}/scores?minRank=1&maxRank=1"); + this.AssertResponseListCount(bigResponse, 1); + } } \ No newline at end of file