Migrate scores to use proper relationships (#830)

* Initial work for score migration

* Finish score migration

* Implement suggested changes from code review

* Make Score Timestamp default the current time

* Chunk insertions to reduce packet size and give all scores the same Timestamp

* Fix serialization of GameScore

* Break score ties by time then scoreId

* Make lighthouse score migration not dependent on current score implementation
This commit is contained in:
Josh 2023-08-19 02:32:38 -05:00 committed by GitHub
parent a7d5095c67
commit 70a66e6034
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 549 additions and 210 deletions

View file

@ -1,5 +1,4 @@
#nullable enable
using System.Diagnostics.CodeAnalysis;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Helpers;
@ -30,26 +29,14 @@ public class ScoreController : ControllerBase
this.database = database;
}
private string[] getFriendUsernames(int userId, string username)
private static int[] GetFriendIds(int userId)
{
UserFriendData? store = UserFriendStore.GetUserFriendData(userId);
if (store == null) return new[] { username, };
List<int>? friendIds = store?.FriendIds;
friendIds ??= new List<int>();
friendIds.Add(userId);
List<string> friendNames = new()
{
username,
};
// ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator
foreach (int friendId in store.FriendIds)
{
string? friendUsername = this.database.Users.Where(u => u.UserId == friendId)
.Select(u => u.Username)
.FirstOrDefault();
if (friendUsername != null) friendNames.Add(friendUsername);
}
return friendNames.ToArray();
return friendIds.Distinct().ToArray();
}
[HttpPost("scoreboard/{slotType}/{id:int}")]
@ -144,33 +131,33 @@ public class ScoreController : ControllerBase
await this.database.SaveChangesAsync();
string playerIdCollection = string.Join(',', score.PlayerIds);
ScoreEntity? existingScore = await this.database.Scores.Where(s => s.SlotId == slot.SlotId)
.Where(s => s.ChildSlotId == 0 || s.ChildSlotId == childId)
.Where(s => s.PlayerIdCollection == playerIdCollection)
.Where(s => s.UserId == token.UserId)
.Where(s => s.Type == score.Type)
.FirstOrDefaultAsync();
if (existingScore != null)
{
existingScore.Points = Math.Max(existingScore.Points, score.Points);
existingScore.Timestamp = TimeHelper.TimestampMillis;
}
else
{
ScoreEntity playerScore = new()
{
PlayerIdCollection = playerIdCollection,
UserId = token.UserId,
Type = score.Type,
Points = score.Points,
SlotId = slotId,
ChildSlotId = childId,
Timestamp = TimeHelper.TimestampMillis,
};
this.database.Scores.Add(playerScore);
}
await this.database.SaveChangesAsync();
return this.Ok(this.getScores(new LeaderboardOptions
return this.Ok(await this.GetScores(new LeaderboardOptions
{
RootName = "scoreboardSegment",
PageSize = 5,
@ -178,7 +165,7 @@ public class ScoreController : ControllerBase
SlotId = slotId,
ChildSlotId = childId,
ScoreType = score.Type,
TargetUsername = username,
TargetUser = token.UserId,
TargetPlayerIds = null,
}));
}
@ -189,8 +176,6 @@ public class ScoreController : ControllerBase
{
GameTokenEntity token = this.GetToken();
string username = await this.database.UsernameFromGameToken(token);
if (slotType == "developer") id = await SlotHelper.GetPlaceholderSlotId(this.database, id, SlotType.Developer);
LeaderboardOptions options = new()
@ -199,7 +184,7 @@ public class ScoreController : ControllerBase
PageStart = 1,
ScoreType = -1,
SlotId = id,
TargetUsername = username,
TargetUser = token.UserId,
RootName = "scoreboardSegment",
};
if (!HttpMethods.IsPost(this.Request.Method))
@ -208,7 +193,7 @@ public class ScoreController : ControllerBase
for (int i = 1; i <= 4; i++)
{
options.ScoreType = i;
ScoreboardResponse response = this.getScores(options);
ScoreboardResponse response = await this.GetScores(options);
scoreboardResponses.Add(new PlayerScoreboardResponse(response.Scores, i));
}
return this.Ok(new MultiScoreboardResponse(scoreboardResponses));
@ -217,9 +202,9 @@ public class ScoreController : ControllerBase
GameScore? score = await this.DeserializeBody<GameScore>();
if (score == null) return this.BadRequest();
options.ScoreType = score.Type;
options.TargetPlayerIds = this.getFriendUsernames(token.UserId, username);
options.TargetPlayerIds = GetFriendIds(token.UserId);
return this.Ok(this.getScores(options));
return this.Ok(await this.GetScores(options));
}
[HttpGet("friendscores/{slotType}/{slotId:int}/{type:int}")]
@ -230,15 +215,13 @@ public class ScoreController : ControllerBase
if (pageSize <= 0) return this.BadRequest();
string username = await this.database.UsernameFromGameToken(token);
if (SlotHelper.IsTypeInvalid(slotType)) return this.BadRequest();
if (slotType == "developer") slotId = await SlotHelper.GetPlaceholderSlotId(this.database, slotId, SlotType.Developer);
string[] friendIds = this.getFriendUsernames(token.UserId, username);
int[] friendIds = GetFriendIds(token.UserId);
return this.Ok(this.getScores(new LeaderboardOptions
return this.Ok(await this.GetScores(new LeaderboardOptions
{
RootName = "scores",
PageSize = pageSize,
@ -246,27 +229,24 @@ public class ScoreController : ControllerBase
SlotId = slotId,
ChildSlotId = childId,
ScoreType = type,
TargetUsername = username,
TargetUser = token.UserId,
TargetPlayerIds = friendIds,
}));
}
[HttpGet("topscores/{slotType}/{slotId:int}/{type:int}")]
[HttpGet("topscores/{slotType}/{slotId:int}/{childId:int}/{type:int}")]
[SuppressMessage("ReSharper", "PossibleMultipleEnumeration")]
public async Task<IActionResult> TopScores(string slotType, int slotId, int? childId, int type, [FromQuery] int pageStart = -1, [FromQuery] int pageSize = 5)
{
GameTokenEntity token = this.GetToken();
if (pageSize <= 0) return this.BadRequest();
string username = await this.database.UsernameFromGameToken(token);
if (SlotHelper.IsTypeInvalid(slotType)) return this.BadRequest();
if (slotType == "developer") slotId = await SlotHelper.GetPlaceholderSlotId(this.database, slotId, SlotType.Developer);
return this.Ok(this.getScores(new LeaderboardOptions
return this.Ok(await this.GetScores(new LeaderboardOptions
{
RootName = "scores",
PageSize = pageSize,
@ -274,52 +254,56 @@ public class ScoreController : ControllerBase
SlotId = slotId,
ChildSlotId = childId,
ScoreType = type,
TargetUsername = username,
TargetUser = token.UserId,
TargetPlayerIds = null,
}));
}
private class LeaderboardOptions
{
public int SlotId { get; set; }
public int SlotId { get; init; }
public int ScoreType { get; set; }
public string TargetUsername { get; set; } = "";
public int PageStart { get; set; } = -1;
public int PageSize { get; set; } = 5;
public string RootName { get; set; } = "scores";
public string[]? TargetPlayerIds;
public int TargetUser { get; init; }
public int PageStart { get; init; } = -1;
public int PageSize { get; init; } = 5;
public string RootName { get; init; } = "scores";
public int[]? TargetPlayerIds;
public int? ChildSlotId;
}
private ScoreboardResponse getScores(LeaderboardOptions options)
private async Task<ScoreboardResponse> GetScores(LeaderboardOptions options)
{
// This is hella ugly but it technically assigns the proper rank to a score
// var needed for Anonymous type returned from SELECT
var rankedScores = this.database.Scores.Where(s => s.SlotId == options.SlotId)
IQueryable<ScoreEntity> scoreQuery = this.database.Scores.Where(s => s.SlotId == options.SlotId)
.Where(s => options.ScoreType == -1 || s.Type == options.ScoreType)
.Where(s => s.ChildSlotId == 0 || s.ChildSlotId == options.ChildSlotId)
.AsEnumerable()
.Where(s => options.TargetPlayerIds == null ||
options.TargetPlayerIds.Any(id => s.PlayerIdCollection.Split(",").Contains(id)))
.OrderByDescending(s => s.Points)
.ThenBy(s => s.ScoreId)
.ToList()
.Select((s, rank) => new
.Where(s => options.TargetPlayerIds == null || options.TargetPlayerIds.Contains(s.UserId));
// First find if you have a score on a level to find scores around it
var myScore = await scoreQuery.Where(s => s.UserId == options.TargetUser)
.Select(s => new
{
Score = s,
Rank = rank + 1,
Rank = scoreQuery.Count(s2 => s2.Points > s.Points) + 1,
}).FirstOrDefaultAsync();
int skipAmt = options.PageStart != -1 || myScore == null ? options.PageStart - 1 : myScore.Rank - 3;
var rankedScores = scoreQuery.OrderByDescending(s => s.Points)
.ThenBy(s => s.Timestamp)
.ThenBy(s => s.ScoreId)
.Skip(Math.Max(0, skipAmt))
.Take(Math.Min(options.PageSize, 30))
.Select(s => new
{
Score = s,
Rank = scoreQuery.Count(s2 => s2.Points > s.Points) + 1,
})
.ToList();
int totalScores = scoreQuery.Count();
// Find your score, since even if you aren't in the top list your score is pinned
var myScore = rankedScores.Where(rs => rs.Score.PlayerIdCollection.Split(",").Contains(options.TargetUsername)).MaxBy(rs => rs.Score.Points);
List<GameScore> gameScores = rankedScores.ToSerializableList(ps => GameScore.CreateFromEntity(ps.Score, ps.Rank));
// Paginated viewing: if not requesting pageStart, get results around user
var pagedScores = rankedScores.Skip(options.PageStart != -1 || myScore == null ? options.PageStart - 1 : myScore.Rank - 3).Take(Math.Min(options.PageSize, 30));
List<GameScore> gameScores = pagedScores.ToSerializableList(ps => GameScore.CreateFromEntity(ps.Score, ps.Rank));
return new ScoreboardResponse(options.RootName, gameScores, rankedScores.Count, myScore?.Score.Points ?? 0, myScore?.Rank ?? 0);
return new ScoreboardResponse(options.RootName, gameScores, totalScores, myScore?.Score.Points ?? 0, myScore?.Rank ?? 0);
}
}