diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/ScoreController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/ScoreController.cs index cfb986b1..544e5799 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/ScoreController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/ScoreController.cs @@ -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? friendIds = store?.FriendIds; + friendIds ??= new List(); + friendIds.Add(userId); - List 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(); 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 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 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 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 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 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); } } \ No newline at end of file diff --git a/ProjectLighthouse.Servers.Website/Pages/Partials/LeaderboardPartial.cshtml b/ProjectLighthouse.Servers.Website/Pages/Partials/LeaderboardPartial.cshtml index 797fac6d..d2c86598 100644 --- a/ProjectLighthouse.Servers.Website/Pages/Partials/LeaderboardPartial.cshtml +++ b/ProjectLighthouse.Servers.Website/Pages/Partials/LeaderboardPartial.cshtml @@ -24,7 +24,6 @@ @for(int i = 0; i < Model.Scores.Count; i++) { ScoreEntity score = Model.Scores[i]; - string[] playerIds = score.PlayerIds; DatabaseContext database = Model.Database;
@@ -39,9 +38,9 @@
- @for (int j = 0; j < playerIds.Length; j++) - { - UserEntity? user = await database.Users.FirstOrDefaultAsync(u => u.Username == playerIds[j]); + @{ + UserEntity? user = await database.Users.FindAsync(score.UserId); + }
@@ -49,13 +48,8 @@ { @await user.ToLink(Html, ViewData, language, timeZone) } - else - { -

@playerIds[j]

- }
- }
diff --git a/ProjectLighthouse/Administration/Maintenance/MaintenanceHelper.cs b/ProjectLighthouse/Administration/Maintenance/MaintenanceHelper.cs index dd17d8e3..eac0f38e 100644 --- a/ProjectLighthouse/Administration/Maintenance/MaintenanceHelper.cs +++ b/ProjectLighthouse/Administration/Maintenance/MaintenanceHelper.cs @@ -19,15 +19,15 @@ public static class MaintenanceHelper { static MaintenanceHelper() { - Commands = getListOfInterfaceObjects(); - MaintenanceJobs = getListOfInterfaceObjects(); - MigrationTasks = getListOfInterfaceObjects(); - RepeatingTasks = getListOfInterfaceObjects(); + Commands = GetListOfInterfaceObjects(); + MaintenanceJobs = GetListOfInterfaceObjects(); + MigrationTasks = GetListOfInterfaceObjects(); + RepeatingTasks = GetListOfInterfaceObjects(); } public static List Commands { get; } public static List MaintenanceJobs { get; } - public static List MigrationTasks { get; } + public static List MigrationTasks { get; } public static List RepeatingTasks { get; } public static async Task> RunCommand(IServiceProvider provider, string[] args) @@ -80,16 +80,18 @@ public static class MaintenanceHelper await job.Run(); } - public static async Task RunMigration(DatabaseContext database, IMigrationTask migrationTask) + public static async Task RunMigration(DatabaseContext database, MigrationTask migrationTask) { - // Migrations should never be run twice. - Debug.Assert(!await database.CompletedMigrations.Has(m => m.MigrationName == migrationTask.GetType().Name)); + Debug.Assert(!await database.CompletedMigrations.Has(m => m.MigrationName == migrationTask.GetType().Name), + $"Tried to run migration {migrationTask.GetType().Name} twice"); - Logger.Info($"Running migration task {migrationTask.Name()}", LogArea.Database); + Logger.Info($"Running LH migration task {migrationTask.Name()}", LogArea.Database); bool success; Exception? exception = null; + + Stopwatch stopwatch = Stopwatch.StartNew(); try { @@ -103,13 +105,14 @@ public static class MaintenanceHelper if (!success) { - Logger.Error($"Could not run migration {migrationTask.Name()}", LogArea.Database); + Logger.Error($"Could not run LH migration {migrationTask.Name()}", LogArea.Database); if (exception != null) Logger.Error(exception.ToDetailedException(), LogArea.Database); - return; + return false; } - - Logger.Success($"Successfully completed migration {migrationTask.Name()}", LogArea.Database); + stopwatch.Stop(); + + Logger.Success($"Successfully completed LH migration {migrationTask.Name()} in {stopwatch.ElapsedMilliseconds}ms", LogArea.Database); CompletedMigrationEntity completedMigration = new() { @@ -119,13 +122,14 @@ public static class MaintenanceHelper database.CompletedMigrations.Add(completedMigration); await database.SaveChangesAsync(); + return true; } - private static List getListOfInterfaceObjects() where T : class + private static List GetListOfInterfaceObjects() where T : class { return Assembly.GetExecutingAssembly() .GetTypes() - .Where(t => t.GetInterfaces().Contains(typeof(T)) && t.GetConstructor(Type.EmptyTypes) != null) + .Where(t => (t.IsSubclassOf(typeof(T)) || t.GetInterfaces().Contains(typeof(T))) && t.GetConstructor(Type.EmptyTypes) != null) .Select(t => Activator.CreateInstance(t) as T) .ToList()!; } diff --git a/ProjectLighthouse/Administration/Maintenance/MigrationTasks/CleanupBrokenVersusScoresMigration.cs b/ProjectLighthouse/Administration/Maintenance/MigrationTasks/CleanupBrokenVersusScoresMigration.cs deleted file mode 100644 index 67a9ad14..00000000 --- a/ProjectLighthouse/Administration/Maintenance/MigrationTasks/CleanupBrokenVersusScoresMigration.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Threading.Tasks; -using LBPUnion.ProjectLighthouse.Database; -using LBPUnion.ProjectLighthouse.Types.Entities.Level; -using LBPUnion.ProjectLighthouse.Types.Maintenance; - -namespace LBPUnion.ProjectLighthouse.Administration.Maintenance.MigrationTasks; - -public class FixBrokenVersusScores : IMigrationTask -{ - public string Name() => "Cleanup versus scores"; - - async Task IMigrationTask.Run(DatabaseContext database) - { - foreach (ScoreEntity score in database.Scores) - { - if (!score.PlayerIdCollection.Contains(':')) continue; - - score.PlayerIdCollection = score.PlayerIdCollection.Replace(':', ','); - } - - await database.SaveChangesAsync(); - return true; - } -} \ No newline at end of file diff --git a/ProjectLighthouse/Administration/Maintenance/MigrationTasks/CleanupDuplicateScoresMigration.cs b/ProjectLighthouse/Administration/Maintenance/MigrationTasks/CleanupDuplicateScoresMigration.cs deleted file mode 100644 index 1a7e25e9..00000000 --- a/ProjectLighthouse/Administration/Maintenance/MigrationTasks/CleanupDuplicateScoresMigration.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using LBPUnion.ProjectLighthouse.Database; -using LBPUnion.ProjectLighthouse.Logging; -using LBPUnion.ProjectLighthouse.Types.Entities.Level; -using LBPUnion.ProjectLighthouse.Types.Logging; -using LBPUnion.ProjectLighthouse.Types.Maintenance; - -namespace LBPUnion.ProjectLighthouse.Administration.Maintenance.MigrationTasks; - -public class CleanupDuplicateScoresMigration : IMigrationTask -{ - public string Name() => "Cleanup duplicate scores"; - - public async Task Run(DatabaseContext database) - { - List duplicateScoreIds = new(); - // The original score should have the lowest score id - foreach (ScoreEntity score in database.Scores.OrderBy(s => s.ScoreId) - .ToList() - .Where(score => !duplicateScoreIds.Contains(score.ScoreId))) - { - foreach (ScoreEntity other in database.Scores.Where(s => - s.Points == score.Points && - s.Type == score.Type && - s.SlotId == score.SlotId && - s.ScoreId != score.ScoreId && - s.ChildSlotId == score.ChildSlotId && - s.ScoreId > score.ScoreId)) - { - if (score.PlayerIds.Length != other.PlayerIds.Length) - continue; - - HashSet hashSet = new(score.PlayerIds); - - if (!other.PlayerIds.All(hashSet.Contains)) continue; - - Logger.Info($"Removing score with id {other.ScoreId}, slotId={other.SlotId} main='{score.PlayerIdCollection}', duplicate={other.PlayerIdCollection}", LogArea.Score); - database.Scores.Remove(other); - duplicateScoreIds.Add(other.ScoreId); - } - } - - Logger.Info($"Removed a total of {duplicateScoreIds.Count} duplicate scores", LogArea.Score); - await database.SaveChangesAsync(); - - return true; - } - -} \ No newline at end of file diff --git a/ProjectLighthouse/Administration/Maintenance/MigrationTasks/CleanupSanitizedStrings.cs b/ProjectLighthouse/Administration/Maintenance/MigrationTasks/CleanupSanitizedStrings.cs index c039b05d..47a38a60 100644 --- a/ProjectLighthouse/Administration/Maintenance/MigrationTasks/CleanupSanitizedStrings.cs +++ b/ProjectLighthouse/Administration/Maintenance/MigrationTasks/CleanupSanitizedStrings.cs @@ -8,11 +8,11 @@ using LBPUnion.ProjectLighthouse.Types.Maintenance; namespace LBPUnion.ProjectLighthouse.Administration.Maintenance.MigrationTasks; -public class CleanupSanitizedStrings : IMigrationTask +public class CleanupSanitizedStrings : MigrationTask { - public string Name() => "Cleanup Sanitized strings"; + public override string Name() => "Cleanup Sanitized strings"; - async Task IMigrationTask.Run(DatabaseContext database) + public override async Task Run(DatabaseContext database) { List objsToBeSanitized = new(); diff --git a/ProjectLighthouse/Administration/Maintenance/MigrationTasks/CleanupSlotVersionMismatchMigration.cs b/ProjectLighthouse/Administration/Maintenance/MigrationTasks/CleanupSlotVersionMismatchMigration.cs index df07e7e5..136bd070 100644 --- a/ProjectLighthouse/Administration/Maintenance/MigrationTasks/CleanupSlotVersionMismatchMigration.cs +++ b/ProjectLighthouse/Administration/Maintenance/MigrationTasks/CleanupSlotVersionMismatchMigration.cs @@ -8,11 +8,11 @@ using LBPUnion.ProjectLighthouse.Types.Users; namespace LBPUnion.ProjectLighthouse.Administration.Maintenance.MigrationTasks; -public class CleanupSlotVersionMismatchMigration : IMigrationTask +public class CleanupSlotVersionMismatchMigration : MigrationTask { - public string Name() => "Cleanup slot versions"; + public override string Name() => "Cleanup slot versions"; - async Task IMigrationTask.Run(DatabaseContext database) + public override async Task Run(DatabaseContext database) { foreach (SlotEntity slot in database.Slots) { diff --git a/ProjectLighthouse/Administration/Maintenance/MigrationTasks/SwitchScoreToUserIdMigration.cs b/ProjectLighthouse/Administration/Maintenance/MigrationTasks/SwitchScoreToUserIdMigration.cs new file mode 100644 index 00000000..03fba51a --- /dev/null +++ b/ProjectLighthouse/Administration/Maintenance/MigrationTasks/SwitchScoreToUserIdMigration.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Data; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using LBPUnion.ProjectLighthouse.Database; +using LBPUnion.ProjectLighthouse.Helpers; +using LBPUnion.ProjectLighthouse.Types.Maintenance; +using Microsoft.EntityFrameworkCore; + +namespace LBPUnion.ProjectLighthouse.Administration.Maintenance.MigrationTasks; + +public class SwitchScoreToUserIdMigration : MigrationTask +{ + #region DB entity replication stuff + + private class PostMigrationScore + { + public int ScoreId { get; set; } + + public int SlotId { get; set; } + + public int ChildSlotId { get; set; } + + public int Type { get; set; } + + public int UserId { get; set; } + + public int Points { get; set; } + + public long Timestamp { get; set; } + } + + private class PreMigrationScore + { + public int ScoreId { get; set; } + + public int SlotId { get; set; } + + public int ChildSlotId { get; set; } + + public int Type { get; set; } + + public string PlayerIdCollection { get; set; } + + [NotMapped] + public IEnumerable PlayerIds => this.PlayerIdCollection.Split(","); + + public int Points { get; set; } + } + + private class MigrationUser + { + public int UserId { get; set; } + + public string Username { get; set; } + } + + #endregion + + public override string Name() => "20230706020914_DropPlayerIdCollectionAndAddUserForeignKey"; + + public override MigrationHook HookType() => MigrationHook.Before; + + private static async Task> GetAllScores(DbContext database) + { + return await MigrationHelper.GetAllObjects(database, + "select * from Scores", + reader => new PreMigrationScore + { + ScoreId = reader.GetInt32("ScoreId"), + SlotId = reader.GetInt32("SlotId"), + ChildSlotId = reader.GetInt32("ChildSlotId"), + Type = reader.GetInt32("Type"), + PlayerIdCollection = reader.GetString("PlayerIdCollection"), + Points = reader.GetInt32("Points"), + }); + } + + private static async Task> GetAllUsers(DbContext database) + { + return await MigrationHelper.GetAllObjects(database, + "select UserId, Username from Users", + reader => new MigrationUser + { + UserId = reader.GetInt32("UserId"), + Username = reader.GetString("Username"), + }); + } + + private static async Task> GetAllSlots(DbContext database) + { + return await MigrationHelper.GetAllObjects(database, + "select SlotId from Slots", + reader => reader.GetInt32("SlotId")); + } + + /// + /// This function deletes all existing scores and inserts the new generated scores + /// All scores must be deleted because MySQL doesn't allow you to change primary keys + /// + private static async Task ApplyFixedScores(DatabaseContext database, IReadOnlyList newScores) + { + // Re-order scores (The order doesn't make any difference but since we're already deleting everything we may as well) + newScores = newScores.OrderByDescending(s => s.SlotId) + .ThenByDescending(s => s.ChildSlotId) + .ThenByDescending(s => s.Type) + .ToList(); + + // Set IDs for new scores + for (int i = 1; i < newScores.Count; i++) + { + newScores[i].ScoreId = i; + } + // Delete all existing scores + await database.Scores.ExecuteDeleteAsync(); + + long timestamp = TimeHelper.TimestampMillis; + + // This is significantly faster than using standard EntityFramework Add and Save albeit a little wacky + foreach (PostMigrationScore[] scoreChunk in newScores.Chunk(50_000)) + { + StringBuilder insertionScript = new(); + foreach (PostMigrationScore score in scoreChunk) + { + insertionScript.AppendLine($""" + insert into Scores (ScoreId, SlotId, Type, Points, ChildSlotId, Timestamp, UserId) + values('{score.ScoreId}', '{score.SlotId}', '{score.Type}', '{score.Points}', '{score.ChildSlotId}', '{timestamp}', '{score.UserId}'); + """); + } + + await database.Database.ExecuteSqlRawAsync(insertionScript.ToString()); + } + } + + public override async Task Run(DatabaseContext database) + { + int[] scoreTypes = + { + 1, 2, 3, 4, 7, + }; + ConcurrentBag newScores = new(); + + List slotIds = await GetAllSlots(database); + List scores = await GetAllScores(database); + + // Don't run migration if there are no scores + if (scores == null || scores.Count == 0) return true; + + List users = await GetAllUsers(database); + + ConcurrentQueue<(int slotId, int type)> collection = new(); + foreach (int slotId in slotIds.Where(id => scores.Any(score => id == score.SlotId))) + { + foreach (int type in scoreTypes) + { + collection.Enqueue((slotId, type)); + } + } + + ConcurrentBag taskList = new(); + for (int i = 0; i < Environment.ProcessorCount; i++) + { + Task task = Task.Run(() => + { + while (collection.TryDequeue(out (int slotId, int type) item)) + { + List fixedScores = FixScores(users, + item.slotId, + scores.Where(s => s.SlotId == item.slotId).Where(s => s.Type == item.type).ToList(), + item.type) + .ToList(); + fixedScores.AsParallel().ForAll(score => newScores.Add(score)); + } + }); + taskList.Add(task); + } + + await Task.WhenAll(taskList); + + await ApplyFixedScores(database, newScores.ToList()); + + return true; + } + + /// + /// This function takes in a list of scores and creates a map of players and their highest score + /// + private static Dictionary CreateHighestScores(List scores, IReadOnlyCollection userCache) + { + Dictionary maxPointsByPlayer = new(StringComparer.InvariantCultureIgnoreCase); + foreach (PreMigrationScore score in scores) + { + IEnumerable players = score.PlayerIds; + foreach (string player in players) + { + // Remove non existent users to ensure foreign key constraint + if (userCache.All(u => u.Username != player)) continue; + + _ = maxPointsByPlayer.TryGetValue(player, out int highestScore); + highestScore = Math.Max(highestScore, score.Points); + maxPointsByPlayer[player] = highestScore; + } + } + + return maxPointsByPlayer; + } + + /// + /// This function groups slots by ChildSlotId to account for adventure scores and then for each user + /// finds their highest score on that level and adds a new Score + /// + private static IEnumerable FixScores(IReadOnlyCollection userCache, int slotId, IEnumerable scores, int scoreType) + { + return ( + from slotGroup in scores.GroupBy(s => s.ChildSlotId) + let highestScores = CreateHighestScores(slotGroup.ToList(), userCache) + from kvp in highestScores + let userId = userCache.Where(u => u.Username == kvp.Key).Select(u => u.UserId).First() + select new PostMigrationScore + { + UserId = userId, + SlotId = slotId, + ChildSlotId = slotGroup.Key, + Points = kvp.Value, + // This gets set before insertion + Timestamp = 0L, + Type = scoreType, + }).ToList(); + } +} \ No newline at end of file diff --git a/ProjectLighthouse/Helpers/MigrationHelper.cs b/ProjectLighthouse/Helpers/MigrationHelper.cs new file mode 100644 index 00000000..e1346d13 --- /dev/null +++ b/ProjectLighthouse/Helpers/MigrationHelper.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; + +namespace LBPUnion.ProjectLighthouse.Helpers; + +public static class MigrationHelper +{ + public static async Task> GetAllObjects(DbContext database, string commandText, Func returnFunc) + { + DbConnection dbConnection = database.Database.GetDbConnection(); + + await using DbCommand cmd = dbConnection.CreateCommand(); + cmd.CommandText = commandText; + cmd.Transaction = GetDbTransaction(database.Database.CurrentTransaction); + + await using DbDataReader reader = await cmd.ExecuteReaderAsync(); + List items = new(); + + if (!reader.HasRows) return default; + + while (await reader.ReadAsync()) + { + items.Add(returnFunc(reader)); + } + + return items; + } + + private static DbTransaction GetDbTransaction(IDbContextTransaction dbContextTransaction) + { + if (dbContextTransaction is not IInfrastructure accessor) + { + throw new InvalidOperationException(RelationalStrings.RelationalNotInUse); + } + + return accessor.GetInfrastructure(); + } +} \ No newline at end of file diff --git a/ProjectLighthouse/Migrations/20230628043618_AddUserIdAndTimestampToScore.cs b/ProjectLighthouse/Migrations/20230628043618_AddUserIdAndTimestampToScore.cs new file mode 100644 index 00000000..51c82f64 --- /dev/null +++ b/ProjectLighthouse/Migrations/20230628043618_AddUserIdAndTimestampToScore.cs @@ -0,0 +1,42 @@ +using LBPUnion.ProjectLighthouse.Database; +using LBPUnion.ProjectLighthouse.Helpers; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProjectLighthouse.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20230628043618_AddUserIdAndTimestampToScore")] + public partial class AddUserIdAndTimestampToScore : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Timestamp", + table: "Scores", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "UserId", + table: "Scores", + type: "int", + nullable: false, + defaultValue: 0); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Timestamp", + table: "Scores"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "Scores"); + } + } +} diff --git a/ProjectLighthouse/Migrations/20230706020914_DropPlayerIdCollectionAndAddUserForeignKey.cs b/ProjectLighthouse/Migrations/20230706020914_DropPlayerIdCollectionAndAddUserForeignKey.cs new file mode 100644 index 00000000..609741f9 --- /dev/null +++ b/ProjectLighthouse/Migrations/20230706020914_DropPlayerIdCollectionAndAddUserForeignKey.cs @@ -0,0 +1,51 @@ +using LBPUnion.ProjectLighthouse.Database; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProjectLighthouse.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20230706020914_DropPlayerIdCollectionAndAddUserForeignKey")] + public partial class DropPlayerIdCollectionAndAddUserForeignKey : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PlayerIdCollection", + table: "Scores"); + + migrationBuilder.CreateIndex( + name: "IX_Scores_UserId", + table: "Scores", + column: "UserId"); + + migrationBuilder.AddForeignKey( + name: "FK_Scores_Users_UserId", + table: "Scores", + column: "UserId", + principalTable: "Users", + principalColumn: "UserId", + onDelete: ReferentialAction.Cascade); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Scores_Users_UserId", + table: "Scores"); + + migrationBuilder.DropIndex( + name: "IX_Scores_UserId", + table: "Scores"); + + migrationBuilder.AddColumn( + name: "PlayerIdCollection", + table: "Scores", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + } + } +} diff --git a/ProjectLighthouse/ProjectLighthouse/Migrations/DatabaseModelSnapshot.cs b/ProjectLighthouse/ProjectLighthouse/Migrations/DatabaseModelSnapshot.cs index aebdea4f..7a419db4 100644 --- a/ProjectLighthouse/ProjectLighthouse/Migrations/DatabaseModelSnapshot.cs +++ b/ProjectLighthouse/ProjectLighthouse/Migrations/DatabaseModelSnapshot.cs @@ -321,22 +321,27 @@ namespace ProjectLighthouse.Migrations b.Property("ChildSlotId") .HasColumnType("int"); - b.Property("PlayerIdCollection") - .HasColumnType("longtext"); - b.Property("Points") .HasColumnType("int"); b.Property("SlotId") .HasColumnType("int"); + b.Property("Timestamp") + .HasColumnType("bigint"); + b.Property("Type") .HasColumnType("int"); + b.Property("UserId") + .HasColumnType("int"); + b.HasKey("ScoreId"); b.HasIndex("SlotId"); + b.HasIndex("UserId"); + b.ToTable("Scores"); }); @@ -1243,7 +1248,15 @@ namespace ProjectLighthouse.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + b.Navigation("Slot"); + + b.Navigation("User"); }); modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", b => diff --git a/ProjectLighthouse/StartupTasks.cs b/ProjectLighthouse/StartupTasks.cs index 2fdfc76a..32a92941 100644 --- a/ProjectLighthouse/StartupTasks.cs +++ b/ProjectLighthouse/StartupTasks.cs @@ -14,14 +14,17 @@ using LBPUnion.ProjectLighthouse.Helpers; using LBPUnion.ProjectLighthouse.Logging; using LBPUnion.ProjectLighthouse.Logging.Loggers; using LBPUnion.ProjectLighthouse.StorableLists; -using LBPUnion.ProjectLighthouse.Types.Entities.Maintenance; using LBPUnion.ProjectLighthouse.Types.Entities.Profile; using LBPUnion.ProjectLighthouse.Types.Logging; using LBPUnion.ProjectLighthouse.Types.Maintenance; -using LBPUnion.ProjectLighthouse.Types.Misc; using LBPUnion.ProjectLighthouse.Types.Users; using Medallion.Threading.MySql; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; +using ServerType = LBPUnion.ProjectLighthouse.Types.Misc.ServerType; namespace LBPUnion.ProjectLighthouse; @@ -161,23 +164,53 @@ public static class StartupTasks Logger.Success($"Acquiring migration lock took {stopwatch.ElapsedMilliseconds}ms", LogArea.Database); stopwatch.Restart(); - await database.Database.MigrateAsync(); - stopwatch.Stop(); - Logger.Success($"Structure migration took {stopwatch.ElapsedMilliseconds}ms.", LogArea.Database); + List pendingMigrations = (await database.Database.GetPendingMigrationsAsync()).ToList(); + IMigrator migrator = database.GetInfrastructure().GetRequiredService(); + + async Task RunLighthouseMigrations(Func predicate) + { + List tasks = MaintenanceHelper.MigrationTasks + .Where(predicate) + .ToList(); + foreach (MigrationTask task in tasks) + { + if (!await MaintenanceHelper.RunMigration(database, task)) return false; + } + return true; + } + + Logger.Info($"There are {pendingMigrations.Count} pending migrations", LogArea.Database); + + foreach (string migration in pendingMigrations) + { + try + { + await using IDbContextTransaction transaction = await database.Database.BeginTransactionAsync(); + Logger.Debug($"Running migration '{migration}", LogArea.Database); + stopwatch.Restart(); + if (!await RunLighthouseMigrations(m => m.Name() == migration && m.HookType() == MigrationHook.Before)) + throw new Exception($"Failed to run pre migration hook for {migration}"); + + await migrator.MigrateAsync(migration); + + stopwatch.Stop(); + Logger.Success($"Running migration '{migration}' took {stopwatch.ElapsedMilliseconds}ms.", LogArea.Database); + } + catch (Exception e) + { + Logger.Error($"Failed to run migration '{migration}'", LogArea.Database); + Logger.Error(e.ToDetailedException(), LogArea.Database); + if (database.Database.CurrentTransaction != null) + await database.Database.RollbackTransactionAsync(); + Environment.Exit(-1); + } + } stopwatch.Restart(); - List completedMigrations = database.CompletedMigrations.ToList(); - List migrationsToRun = MaintenanceHelper.MigrationTasks - .Where(migrationTask => !completedMigrations - .Select(m => m.MigrationName) - .Contains(migrationTask.GetType().Name) - ).ToList(); + List completedMigrations = database.CompletedMigrations.Select(m => m.MigrationName).ToList(); - foreach (IMigrationTask migrationTask in migrationsToRun) - { - MaintenanceHelper.RunMigration(database, migrationTask).Wait(); - } + await RunLighthouseMigrations(m => !completedMigrations.Contains(m.GetType().Name) && m.HookType() == MigrationHook.None); stopwatch.Stop(); totalStopwatch.Stop(); diff --git a/ProjectLighthouse/Types/Entities/Level/ScoreEntity.cs b/ProjectLighthouse/Types/Entities/Level/ScoreEntity.cs index ffc3fbb7..93aedc9c 100644 --- a/ProjectLighthouse/Types/Entities/Level/ScoreEntity.cs +++ b/ProjectLighthouse/Types/Entities/Level/ScoreEntity.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -using System.Xml.Serialization; +using LBPUnion.ProjectLighthouse.Types.Entities.Profile; namespace LBPUnion.ProjectLighthouse.Types.Entities.Level; @@ -11,22 +11,19 @@ public class ScoreEntity public int SlotId { get; set; } - [XmlIgnore] [ForeignKey(nameof(SlotId))] public SlotEntity Slot { get; set; } - [XmlIgnore] public int ChildSlotId { get; set; } public int Type { get; set; } + + public int UserId { get; set; } - public string PlayerIdCollection { get; set; } - - [NotMapped] - public string[] PlayerIds { - get => this.PlayerIdCollection.Split(","); - set => this.PlayerIdCollection = string.Join(',', value); - } + [ForeignKey(nameof(UserId))] + public UserEntity User { get; set; } public int Points { get; set; } + + public long Timestamp { get; set; } } \ No newline at end of file diff --git a/ProjectLighthouse/Types/Entities/Maintenance/CompletedMigrationEntity.cs b/ProjectLighthouse/Types/Entities/Maintenance/CompletedMigrationEntity.cs index 55cda685..245ab17e 100644 --- a/ProjectLighthouse/Types/Entities/Maintenance/CompletedMigrationEntity.cs +++ b/ProjectLighthouse/Types/Entities/Maintenance/CompletedMigrationEntity.cs @@ -5,7 +5,7 @@ using LBPUnion.ProjectLighthouse.Types.Maintenance; namespace LBPUnion.ProjectLighthouse.Types.Entities.Maintenance; /// -/// A record of the completion of a . +/// A record of the completion of a . /// public class CompletedMigrationEntity { diff --git a/ProjectLighthouse/Types/Maintenance/IMigrationTask.cs b/ProjectLighthouse/Types/Maintenance/MigrationTask.cs similarity index 61% rename from ProjectLighthouse/Types/Maintenance/IMigrationTask.cs rename to ProjectLighthouse/Types/Maintenance/MigrationTask.cs index 774a6cff..a1c6899a 100644 --- a/ProjectLighthouse/Types/Maintenance/IMigrationTask.cs +++ b/ProjectLighthouse/Types/Maintenance/MigrationTask.cs @@ -1,21 +1,27 @@ using System.Threading.Tasks; -using JetBrains.Annotations; using LBPUnion.ProjectLighthouse.Database; namespace LBPUnion.ProjectLighthouse.Types.Maintenance; -[UsedImplicitly(ImplicitUseTargetFlags.WithInheritors)] -public interface IMigrationTask +public enum MigrationHook +{ + Before, + None, +} + +public abstract class MigrationTask { /// /// The user-friendly name of a migration. /// - public string Name(); - + public abstract string Name(); + + public virtual MigrationHook HookType() => MigrationHook.None; + /// /// Performs the migration. /// /// The Lighthouse database. /// True if successful, false if not. - internal Task Run(DatabaseContext database); + public abstract Task Run(DatabaseContext database); } \ No newline at end of file diff --git a/ProjectLighthouse/Types/Serialization/GameScore.cs b/ProjectLighthouse/Types/Serialization/GameScore.cs index 7addcf22..1195ce96 100644 --- a/ProjectLighthouse/Types/Serialization/GameScore.cs +++ b/ProjectLighthouse/Types/Serialization/GameScore.cs @@ -1,17 +1,24 @@ using System.ComponentModel; using System.Linq; +using System.Threading.Tasks; using System.Xml.Serialization; +using LBPUnion.ProjectLighthouse.Database; using LBPUnion.ProjectLighthouse.Types.Entities.Level; +using Microsoft.EntityFrameworkCore; namespace LBPUnion.ProjectLighthouse.Types.Serialization; [XmlRoot("playRecord")] [XmlType("playRecord")] -public class GameScore : ILbpSerializable +public class GameScore : ILbpSerializable, INeedsPreparationForSerialization { + [XmlIgnore] + public int UserId { get; set; } + [XmlElement("type")] public int Type { get; set; } + [DefaultValue(null)] [XmlElement("playerIds")] public string[] PlayerIds; @@ -26,14 +33,19 @@ public class GameScore : ILbpSerializable [XmlElement("score")] public int Points { get; set; } + public async Task PrepareSerialization(DatabaseContext database) + { + this.MainPlayer = await database.Users.Where(u => u.UserId == this.UserId) + .Select(u => u.Username) + .FirstAsync(); + } + public static GameScore CreateFromEntity(ScoreEntity entity, int rank) => new() { - MainPlayer = entity.PlayerIds.ElementAtOrDefault(0) ?? "", - PlayerIds = entity.PlayerIds, + UserId = entity.UserId, Points = entity.Points, Type = entity.Type, Rank = rank, }; - } \ No newline at end of file