Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Refresh.Database/GameDatabaseContext.Leaderboard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ScoreWithRank> GetTopScoresByUser(GameUser user, int count, int skip, byte scoreType, uint minRank, uint maxRank, bool showDuplicates = false)
{
IEnumerable<GameScore> 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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
}
48 changes: 48 additions & 0 deletions Refresh.Interfaces.APIv3/Endpoints/LeaderboardApiEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -45,6 +47,52 @@ public ApiListResponse<ApiGameScoreResponse> GetTopScoresForLevel(RequestContext
DatabaseList<ApiGameScoreResponse> ret = DatabaseListExtensions.FromOldList<ApiGameScoreResponse, ScoreWithRank>(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<ApiGameScoreResponse> 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<ScoreWithRank> scores = dataContext.Database.GetTopScoresByUser(user, count, skip, mode, minRank, maxRank, showAll);
DatabaseList<ApiGameScoreResponse> ret = DatabaseListExtensions.FromOldList<ApiGameScoreResponse, ScoreWithRank>(scores, dataContext);
return ret;
}

[ApiV3Endpoint("scores/{uuid}"), Authentication(false)]
[DocSummary("Gets an individual score by a UUID")]
Expand Down
220 changes: 220 additions & 0 deletions RefreshTests.GameServer/Tests/ApiV3/LeaderboardApiTests.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<ApiGameScoreResponse>? 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<ApiGameScoreResponse>? response = context.Http.GetList<ApiGameScoreResponse>($"/api/v3/users/uuid/{uploader.UserId.ToString()}/scores?mode={mode}");
this.AssertResponseListCount(response, 3);

// name
response = context.Http.GetList<ApiGameScoreResponse>($"/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<ApiGameScoreResponse>? bigResponse = context.Http.GetList<ApiGameScoreResponse>($"/api/v3/users/name/{uploader.Username}/scores");
this.AssertResponseListCount(bigResponse, 12);

bigResponse = context.Http.GetList<ApiGameScoreResponse>($"/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<ApiGameScoreResponse>? response = context.Http.GetList<ApiGameScoreResponse>($"/api/v3/users/uuid/{uploader.UserId.ToString()}/scores?mode={mode}&showAll={showOvertaken}");
this.AssertResponseListCount(response, showOvertaken ? 3 : 1);

// name
response = context.Http.GetList<ApiGameScoreResponse>($"/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<ApiGameScoreResponse>? bigResponse = context.Http.GetList<ApiGameScoreResponse>($"/api/v3/users/name/{uploader.Username}/scores?showAll={showOvertaken}");
this.AssertResponseListCount(bigResponse, showOvertaken ? 12 : 4);

bigResponse = context.Http.GetList<ApiGameScoreResponse>($"/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<ApiGameScoreResponse>? response = context.Http.GetList<ApiGameScoreResponse>($"/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<ApiGameScoreResponse>($"/api/v3/scores/{level.LevelId}/{mode}?showAll={showOvertaken}");
this.AssertResponseListCount(response, 3); // the user's score + the ones by the 2 extra users
}

ApiListResponse<ApiGameScoreResponse>? bigResponse = context.Http.GetList<ApiGameScoreResponse>($"/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<ApiGameScoreResponse>($"/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<ApiGameScoreResponse>? bigResponse = context.Http.GetList<ApiGameScoreResponse>($"/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<ApiGameScoreResponse>($"/api/v3/users/uuid/{uploader.UserId.ToString()}/scores?minRank=0&maxRank=0");
this.AssertResponseListCount(bigResponse, 10);

// Can use just minRank
bigResponse = context.Http.GetList<ApiGameScoreResponse>($"/api/v3/users/uuid/{uploader.UserId.ToString()}/scores?minRank=5");
this.AssertResponseListCount(bigResponse, 6);

// Can use just maxRank
bigResponse = context.Http.GetList<ApiGameScoreResponse>($"/api/v3/users/uuid/{uploader.UserId.ToString()}/scores?maxRank=3");
this.AssertResponseListCount(bigResponse, 3);

// Can use both
bigResponse = context.Http.GetList<ApiGameScoreResponse>($"/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<ApiGameScoreResponse>($"/api/v3/users/uuid/{uploader.UserId.ToString()}/scores?minRank=1&maxRank=1");
this.AssertResponseListCount(bigResponse, 1);
}
}
Loading