From ff1a5df19c46ab4fc80d14b114e6f9e7034e2cff Mon Sep 17 00:00:00 2001 From: Tyler Ruark Date: Tue, 11 Aug 2026 22:50:53 -0400 Subject: [PATCH 01/15] Added Recently Played support for LittleBigPlanet 3 --- .../Controllers/Matching/MatchController.cs | 91 +- .../Types/Categories/CategoryHelper.cs | 1 + .../Categories/RecentlyPlayedCategory.cs | 72 + .../Database/DatabaseContext.Slots.cs | 64 + ProjectLighthouse/Database/DatabaseContext.cs | 58 +- ...260812004441_AddRecentlyPlayed.Designer.cs | 1602 +++++++++++++++++ .../20260812004441_AddRecentlyPlayed.cs | 52 + .../DatabaseContextModelSnapshot.cs | 40 +- .../Interaction/RecentlyPlayedEntity.cs | 23 + 9 files changed, 1997 insertions(+), 6 deletions(-) create mode 100644 ProjectLighthouse.Servers.GameServer/Types/Categories/RecentlyPlayedCategory.cs create mode 100644 ProjectLighthouse/Migrations/20260812004441_AddRecentlyPlayed.Designer.cs create mode 100644 ProjectLighthouse/Migrations/20260812004441_AddRecentlyPlayed.cs create mode 100644 ProjectLighthouse/Types/Entities/Interaction/RecentlyPlayedEntity.cs diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs index 2b3b295b9..100e5a13b 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs @@ -1,4 +1,5 @@ #nullable enable +using System; using System.Text.Json; using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Database; @@ -14,6 +15,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using LBPUnion.ProjectLighthouse.Types.Users; namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Controllers.Matching; @@ -32,7 +34,94 @@ public MatchController(DatabaseContext database) [HttpPost("gameState")] [Produces("text/plain")] - public IActionResult GameState() => this.Ok("VALID"); + public async Task GameState() + { + GameTokenEntity token = this.GetToken(); + string bodyString = await this.ReadBodyAsync(); + + Logger.Info( + $"Server has received gameState, GameVersion={token.GameVersion}, Platform={token.Platform}, Body={bodyString}", + LogArea.Match); + + if (string.IsNullOrWhiteSpace(bodyString)) + return this.Ok("VALID"); + + try + { + int jsonStart = bodyString.IndexOf('{'); + int jsonEnd = bodyString.LastIndexOf('}'); + + if (jsonStart < 0 || jsonEnd < jsonStart) + return this.Ok("VALID"); + + string json = bodyString[jsonStart..(jsonEnd + 1)]; + + using JsonDocument document = JsonDocument.Parse(json); + JsonElement root = document.RootElement; + + if (!root.TryGetProperty("currentLevel", out JsonElement currentLevel)) + return this.Ok("VALID"); + + if (currentLevel.ValueKind != JsonValueKind.Array || + currentLevel.GetArrayLength() < 2) + return this.Ok("VALID"); + + string? levelType = currentLevel[0].GetString(); + + if (!currentLevel[1].TryGetInt32(out int slotId)) + return this.Ok("VALID"); + + Logger.Info( + $"Parsed gameState: GameVersion={token.GameVersion}, LevelType={levelType}, SlotId={slotId}", + LogArea.Match); + + //Makes it so that this is a LBP3 only feature + if (token.GameVersion != GameVersion.LittleBigPlanet3) + return this.Ok("VALID"); + + //Makes it so that only community/user levels belong in Recently Played. + if (!string.Equals(levelType, "user", StringComparison.OrdinalIgnoreCase)) + return this.Ok("VALID"); + + if (slotId <= 0) + return this.Ok("VALID"); + + //This checks the supplied slotId to see if its valid. + bool slotExists = await this.database.Slots + .AnyAsync(s => s.SlotId == slotId); + + if (!slotExists) + { + Logger.Info( + $"Ignoring recently played SlotId={slotId} since it doesn't exist.", + LogArea.Match); + + return this.Ok("VALID"); + } + + await this.database.RecordRecentlyPlayedLevel( + token.UserId, + slotId); + + Logger.Info( + $"Successfully updated Recently Played for UserId={token.UserId}, SlotId={slotId}", + LogArea.Match); + } + catch (JsonException e) + { + Logger.Error( + $"Failed to parse the gameState JSON: {e.Message}", + LogArea.Match); + } + catch (Exception e) + { + //Makes it so that this recently played implementation doesnt cause /gameState to cry and break. + Logger.Error( + $"Failed to update Recently Played: {e.Message}", + LogArea.Match); + } + return this.Ok("VALID"); +} [HttpPost("match")] [Produces("text/plain")] diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs index c4bfc5fda..4fd3f95f2 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs @@ -20,6 +20,7 @@ static CategoryHelper() Categories.Add(new QueueCategory()); Categories.Add(new HeartedCategory()); Categories.Add(new LuckyDipCategory()); + Categories.Add(new RecentlyPlayedCategory()); Categories.Add(new TextSearchCategory()); using DatabaseContext database = DatabaseContext.CreateNewInstance(); diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/RecentlyPlayedCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/RecentlyPlayedCategory.cs new file mode 100644 index 000000000..67b34ee13 --- /dev/null +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/RecentlyPlayedCategory.cs @@ -0,0 +1,72 @@ +#nullable enable + +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; + +using LBPUnion.ProjectLighthouse.Database; +using LBPUnion.ProjectLighthouse.Filter; +using LBPUnion.ProjectLighthouse.Types.Entities.Interaction; +using LBPUnion.ProjectLighthouse.Types.Entities.Level; +using LBPUnion.ProjectLighthouse.Types.Entities.Token; + +using Microsoft.EntityFrameworkCore; + +namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Types.Categories; + +public class RecentlyPlayedCategory : SlotCategory +{ + public override string Name { get; set; } = "Recently Played"; + public override string Description { get; set; } = "Your recently played content"; + public override string IconHash { get; set; } = "g820616"; + public override string Endpoint { get; set; } = "recently_played"; + public override string Tag => "my_recently_played"; + + public override IQueryable GetItems( + DatabaseContext database, + GameTokenEntity token, + SlotQueryBuilder queryBuilder) + { + RecentlyPlayedEntity? recentlyPlayed = database.RecentlyPlayed + .AsNoTracking() + .FirstOrDefault(r => r.UserId == token.UserId); + + if (recentlyPlayed == null || recentlyPlayed.SlotIds.Count == 0) + return database.Slots.Where(_ => false); + + List slotIds = recentlyPlayed.SlotIds + .Take(20) + .ToList(); + + ParameterExpression slotParameter = + Expression.Parameter(typeof(SlotEntity), "slot"); + + MemberExpression slotIdProperty = + Expression.Property( + slotParameter, + nameof(SlotEntity.SlotId)); + + Expression orderExpression = + Expression.Constant(slotIds.Count); + + for (int i = slotIds.Count - 1; i >= 0; i--) + { + orderExpression = Expression.Condition( + Expression.Equal( + slotIdProperty, + Expression.Constant(slotIds[i])), + Expression.Constant(i), + orderExpression); + } + + Expression> ordering = + Expression.Lambda>( + orderExpression, + slotParameter); + + return database.Slots + .Where(s => slotIds.Contains(s.SlotId)) + .Where(queryBuilder.Build()) + .OrderBy(ordering); + } +} \ No newline at end of file diff --git a/ProjectLighthouse/Database/DatabaseContext.Slots.cs b/ProjectLighthouse/Database/DatabaseContext.Slots.cs index 41d3e92cd..381f0b37d 100644 --- a/ProjectLighthouse/Database/DatabaseContext.Slots.cs +++ b/ProjectLighthouse/Database/DatabaseContext.Slots.cs @@ -1,4 +1,7 @@ #nullable enable +using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using LBPUnion.ProjectLighthouse.Types.Entities.Interaction; using LBPUnion.ProjectLighthouse.Types.Entities.Level; @@ -87,4 +90,65 @@ public async Task UnqueueLevel(int userId, SlotEntity queuedSlot) await this.SaveChangesAsync(); } + public async Task RecordRecentlyPlayedLevel(int userId, int slotId) + { + RecentlyPlayedEntity? recentlyPlayed = + await this.RecentlyPlayed.FirstOrDefaultAsync(r => + r.UserId == userId); + + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + //Top recently played level for the user. + if (recentlyPlayed == null) + { + this.RecentlyPlayed.Add(new RecentlyPlayedEntity + { + UserId = userId, + SlotIds = new List { slotId }, + LastPlayedAt = new List { now }, + }); + + await this.SaveChangesAsync(); + return; + } + + //LBP3 can send multiple gameState packets for one level, and if the player is already connected, it doesnt reqrite the entry with this + if (recentlyPlayed.SlotIds.Count > 0 && + recentlyPlayed.SlotIds[0] == slotId) + { + return; + } + + //If the level already exists in the history it removes the slot id and the timestamp + int existingIndex = recentlyPlayed.SlotIds.IndexOf(slotId); + + if (existingIndex >= 0) + { + recentlyPlayed.SlotIds.RemoveAt(existingIndex); + + if (existingIndex < recentlyPlayed.LastPlayedAt.Count) + recentlyPlayed.LastPlayedAt.RemoveAt(existingIndex); + } + + //The newest added levels go to the start of the list + recentlyPlayed.SlotIds.Insert(0, slotId); + recentlyPlayed.LastPlayedAt.Insert(0, now); + + //Keeps a max of 20 levels + if (recentlyPlayed.SlotIds.Count > 20) + { + recentlyPlayed.SlotIds.RemoveRange( + 20, + recentlyPlayed.SlotIds.Count - 20); + } + + if (recentlyPlayed.LastPlayedAt.Count > 20) + { + recentlyPlayed.LastPlayedAt.RemoveRange( + 20, + recentlyPlayed.LastPlayedAt.Count - 20); + } + + await this.SaveChangesAsync(); + } } \ No newline at end of file diff --git a/ProjectLighthouse/Database/DatabaseContext.cs b/ProjectLighthouse/Database/DatabaseContext.cs index 0a2e09e15..cae796344 100644 --- a/ProjectLighthouse/Database/DatabaseContext.cs +++ b/ProjectLighthouse/Database/DatabaseContext.cs @@ -1,3 +1,7 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Types.Entities.Interaction; using LBPUnion.ProjectLighthouse.Types.Entities.Level; @@ -8,6 +12,8 @@ using LBPUnion.ProjectLighthouse.Types.Entities.Token; using LBPUnion.ProjectLighthouse.Types.Entities.Website; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace LBPUnion.ProjectLighthouse.Database; @@ -53,6 +59,7 @@ public partial class DatabaseContext : DbContext public DbSet RatedLevels { get; set; } public DbSet RatedReviews { get; set; } public DbSet VisitedLevels { get; set; } + public DbSet RecentlyPlayed { get; set; } #endregion #region Moderation @@ -82,10 +89,53 @@ public DatabaseContext(DbContextOptions options) : base(options { } public static DatabaseContext CreateNewInstance() +{ + DbContextOptionsBuilder builder = new(); + builder.UseMySql(ServerConfiguration.Instance.DbConnectionString, + MySqlServerVersion.LatestSupportedServerVersion); + return new DatabaseContext(builder.Options); +} + + protected override void OnModelCreating(ModelBuilder modelBuilder) { - DbContextOptionsBuilder builder = new(); - builder.UseMySql(ServerConfiguration.Instance.DbConnectionString, - MySqlServerVersion.LatestSupportedServerVersion); - return new DatabaseContext(builder.Options); + base.OnModelCreating(modelBuilder); + + ValueConverter, string> slotIdsConverter = new( + value => JsonSerializer.Serialize( + value, + (JsonSerializerOptions)null), + value => JsonSerializer.Deserialize>( + value, + (JsonSerializerOptions)null) ?? new List()); + + ValueComparer> slotIdsComparer = new( + (left, right) => left.SequenceEqual(right), + value => value.Aggregate( + 0, + (hash, item) => HashCode.Combine(hash, item.GetHashCode())), + value => value.ToList()); + + modelBuilder.Entity() + .Property(r => r.SlotIds) + .HasConversion(slotIdsConverter, slotIdsComparer); + + ValueConverter, string> timestampsConverter = new( + value => JsonSerializer.Serialize( + value, + (JsonSerializerOptions)null), + value => JsonSerializer.Deserialize>( + value, + (JsonSerializerOptions)null) ?? new List()); + + ValueComparer> timestampsComparer = new( + (left, right) => left.SequenceEqual(right), + value => value.Aggregate( + 0, + (hash, item) => HashCode.Combine(hash, item.GetHashCode())), + value => value.ToList()); + + modelBuilder.Entity() + .Property(r => r.LastPlayedAt) + .HasConversion(timestampsConverter, timestampsComparer); } } \ No newline at end of file diff --git a/ProjectLighthouse/Migrations/20260812004441_AddRecentlyPlayed.Designer.cs b/ProjectLighthouse/Migrations/20260812004441_AddRecentlyPlayed.Designer.cs new file mode 100644 index 000000000..30c01f46a --- /dev/null +++ b/ProjectLighthouse/Migrations/20260812004441_AddRecentlyPlayed.Designer.cs @@ -0,0 +1,1602 @@ +// +using System; +using LBPUnion.ProjectLighthouse.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LBPUnion.ProjectLighthouse.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260812004441_AddRecentlyPlayed")] + partial class AddRecentlyPlayed + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.18") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.HeartedLevelEntity", b => + { + b.Property("HeartedLevelId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("HeartedLevelId")); + + b.Property("SlotId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("HeartedLevelId"); + + b.HasIndex("SlotId"); + + b.HasIndex("UserId"); + + b.ToTable("HeartedLevels"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.HeartedPlaylistEntity", b => + { + b.Property("HeartedPlaylistId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("HeartedPlaylistId")); + + b.Property("PlaylistId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("HeartedPlaylistId"); + + b.HasIndex("PlaylistId"); + + b.HasIndex("UserId"); + + b.ToTable("HeartedPlaylists"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.HeartedProfileEntity", b => + { + b.Property("HeartedProfileId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("HeartedProfileId")); + + b.Property("HeartedUserId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("HeartedProfileId"); + + b.HasIndex("HeartedUserId"); + + b.HasIndex("UserId"); + + b.ToTable("HeartedProfiles"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.QueuedLevelEntity", b => + { + b.Property("QueuedLevelId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("QueuedLevelId")); + + b.Property("SlotId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("QueuedLevelId"); + + b.HasIndex("SlotId"); + + b.HasIndex("UserId"); + + b.ToTable("QueuedLevels"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.RatedCommentEntity", b => + { + b.Property("RatingId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RatingId")); + + b.Property("CommentId") + .HasColumnType("int"); + + b.Property("Rating") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("RatingId"); + + b.HasIndex("CommentId"); + + b.HasIndex("UserId"); + + b.ToTable("RatedComments"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.RatedLevelEntity", b => + { + b.Property("RatedLevelId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RatedLevelId")); + + b.Property("Rating") + .HasColumnType("int"); + + b.Property("RatingLBP1") + .HasColumnType("double"); + + b.Property("SlotId") + .HasColumnType("int"); + + b.Property("TagLBP1") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("RatedLevelId"); + + b.HasIndex("SlotId"); + + b.HasIndex("UserId"); + + b.ToTable("RatedLevels"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.RatedReviewEntity", b => + { + b.Property("RatedReviewId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RatedReviewId")); + + b.Property("ReviewId") + .HasColumnType("int"); + + b.Property("Thumb") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("RatedReviewId"); + + b.HasIndex("ReviewId"); + + b.HasIndex("UserId"); + + b.ToTable("RatedReviews"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.RecentlyPlayedEntity", b => + { + b.Property("RecentlyPlayedId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RecentlyPlayedId")); + + b.Property("LastPlayedAt") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SlotIds") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("RecentlyPlayedId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("RecentlyPlayed"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.VisitedLevelEntity", b => + { + b.Property("VisitedLevelId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("VisitedLevelId")); + + b.Property("PlaysLBP1") + .HasColumnType("int"); + + b.Property("PlaysLBP2") + .HasColumnType("int"); + + b.Property("PlaysLBP3") + .HasColumnType("int"); + + b.Property("SlotId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("VisitedLevelId"); + + b.HasIndex("SlotId"); + + b.HasIndex("UserId"); + + b.ToTable("VisitedLevels"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Level.DatabaseCategoryEntity", b => + { + b.Property("CategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CategoryId")); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("Endpoint") + .HasColumnType("longtext"); + + b.Property("IconHash") + .HasColumnType("longtext"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("SlotIdsCollection") + .HasColumnType("longtext"); + + b.HasKey("CategoryId"); + + b.ToTable("CustomCategories"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Level.PlaylistEntity", b => + { + b.Property("PlaylistId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PlaylistId")); + + b.Property("CreatorId") + .HasColumnType("int"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SlotCollection") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("PlaylistId"); + + b.HasIndex("CreatorId"); + + b.ToTable("Playlists"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Level.ReviewEntity", b => + { + b.Property("ReviewId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReviewId")); + + b.Property("Deleted") + .HasColumnType("tinyint(1)"); + + b.Property("DeletedBy") + .HasColumnType("int"); + + b.Property("LabelCollection") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ReviewerId") + .HasColumnType("int"); + + b.Property("SlotId") + .HasColumnType("int"); + + b.Property("Text") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Thumb") + .HasColumnType("int"); + + b.Property("ThumbsDown") + .HasColumnType("int"); + + b.Property("ThumbsUp") + .HasColumnType("int"); + + b.Property("Timestamp") + .HasColumnType("bigint"); + + b.HasKey("ReviewId"); + + b.HasIndex("ReviewerId"); + + b.HasIndex("SlotId"); + + b.ToTable("Reviews"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Level.ScoreEntity", b => + { + b.Property("ScoreId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ScoreId")); + + b.Property("ChildSlotId") + .HasColumnType("int"); + + 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"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", b => + { + b.Property("SlotId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SlotId")); + + b.Property("AuthorLabels") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("BackgroundHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CommentsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("CreatorId") + .HasColumnType("int"); + + b.Property("CrossControllerRequired") + .HasColumnType("tinyint(1)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FirstUploaded") + .HasColumnType("bigint"); + + b.Property("GameVersion") + .HasColumnType("int"); + + b.Property("Hidden") + .HasColumnType("tinyint(1)"); + + b.Property("HiddenReason") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IconHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("InitiallyLocked") + .HasColumnType("tinyint(1)"); + + b.Property("InternalSlotId") + .HasColumnType("int"); + + b.Property("IsAdventurePlanet") + .HasColumnType("tinyint(1)"); + + b.Property("LastUpdated") + .HasColumnType("bigint"); + + b.Property("Lbp1Only") + .HasColumnType("tinyint(1)"); + + b.Property("LevelType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("LocationPacked") + .HasColumnType("bigint unsigned"); + + b.Property("LockedByModerator") + .HasColumnType("tinyint(1)"); + + b.Property("LockedReason") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("MaximumPlayers") + .HasColumnType("int"); + + b.Property("MinimumPlayers") + .HasColumnType("int"); + + b.Property("MoveRequired") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PlaysLBP1") + .HasColumnType("int"); + + b.Property("PlaysLBP1Complete") + .HasColumnType("int"); + + b.Property("PlaysLBP1Unique") + .HasColumnType("int"); + + b.Property("PlaysLBP2") + .HasColumnType("int"); + + b.Property("PlaysLBP2Complete") + .HasColumnType("int"); + + b.Property("PlaysLBP2Unique") + .HasColumnType("int"); + + b.Property("PlaysLBP3") + .HasColumnType("int"); + + b.Property("PlaysLBP3Complete") + .HasColumnType("int"); + + b.Property("PlaysLBP3Unique") + .HasColumnType("int"); + + b.Property("ResourceCollection") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RootLevel") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Shareable") + .HasColumnType("int"); + + b.Property("SubLevel") + .HasColumnType("tinyint(1)"); + + b.Property("TeamPickTime") + .HasColumnType("bigint"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("SlotId"); + + b.HasIndex("CreatorId"); + + b.ToTable("Slots"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Maintenance.CompletedMigrationEntity", b => + { + b.Property("MigrationName") + .HasColumnType("varchar(255)"); + + b.Property("RanAt") + .HasColumnType("datetime(6)"); + + b.HasKey("MigrationName"); + + b.ToTable("CompletedMigrations"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Moderation.GriefReportEntity", b => + { + b.Property("ReportId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReportId")); + + b.Property("Bounds") + .HasColumnType("longtext"); + + b.Property("GriefStateHash") + .HasColumnType("longtext"); + + b.Property("InitialStateHash") + .HasColumnType("longtext"); + + b.Property("JpegHash") + .HasColumnType("longtext"); + + b.Property("LevelId") + .HasColumnType("int"); + + b.Property("LevelOwner") + .HasColumnType("longtext"); + + b.Property("LevelType") + .HasColumnType("longtext"); + + b.Property("Players") + .HasColumnType("longtext"); + + b.Property("ReportingPlayerId") + .HasColumnType("int"); + + b.Property("Timestamp") + .HasColumnType("bigint"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("ReportId"); + + b.HasIndex("ReportingPlayerId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Moderation.ModerationCaseEntity", b => + { + b.Property("CaseId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CaseId")); + + b.Property("AffectedId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatorId") + .HasColumnType("int"); + + b.Property("CreatorUsername") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("DismissedAt") + .HasColumnType("datetime(6)"); + + b.Property("DismisserId") + .HasColumnType("int"); + + b.Property("DismisserUsername") + .HasColumnType("longtext"); + + b.Property("ExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("ModeratorNotes") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Processed") + .HasColumnType("tinyint(1)"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("CaseId"); + + b.HasIndex("CreatorId"); + + b.HasIndex("DismisserId"); + + b.ToTable("Cases"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Notifications.NotificationEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("IsDismissed") + .HasColumnType("tinyint(1)"); + + b.Property("Text") + .HasColumnType("longtext"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.BlockedProfileEntity", b => + { + b.Property("BlockedProfileId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BlockedProfileId")); + + b.Property("BlockedUserId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("BlockedProfileId"); + + b.HasIndex("BlockedUserId"); + + b.HasIndex("UserId"); + + b.ToTable("BlockedProfiles"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.CommentEntity", b => + { + b.Property("CommentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CommentId")); + + b.Property("Deleted") + .HasColumnType("tinyint(1)"); + + b.Property("DeletedBy") + .HasColumnType("longtext"); + + b.Property("DeletedType") + .HasColumnType("longtext"); + + b.Property("Message") + .HasColumnType("longtext"); + + b.Property("PosterUserId") + .HasColumnType("int"); + + b.Property("TargetSlotId") + .HasColumnType("int"); + + b.Property("TargetUserId") + .HasColumnType("int"); + + b.Property("ThumbsDown") + .HasColumnType("int"); + + b.Property("ThumbsUp") + .HasColumnType("int"); + + b.Property("Timestamp") + .HasColumnType("bigint"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("CommentId"); + + b.HasIndex("PosterUserId"); + + b.HasIndex("TargetSlotId"); + + b.HasIndex("TargetUserId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.LastContactEntity", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("GameVersion") + .HasColumnType("int"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("Timestamp") + .HasColumnType("bigint"); + + b.HasKey("UserId"); + + b.ToTable("LastContacts"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PhotoEntity", b => + { + b.Property("PhotoId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PhotoId")); + + b.Property("CreatorId") + .HasColumnType("int"); + + b.Property("LargeHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("MediumHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PlanHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SlotId") + .HasColumnType("int"); + + b.Property("SmallHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Timestamp") + .HasColumnType("bigint"); + + b.HasKey("PhotoId"); + + b.HasIndex("CreatorId"); + + b.HasIndex("SlotId"); + + b.ToTable("Photos"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PhotoSubjectEntity", b => + { + b.Property("PhotoSubjectId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PhotoSubjectId")); + + b.Property("Bounds") + .HasColumnType("longtext"); + + b.Property("PhotoId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("PhotoSubjectId"); + + b.HasIndex("PhotoId"); + + b.HasIndex("UserId"); + + b.ToTable("PhotoSubjects"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PlatformLinkAttemptEntity", b => + { + b.Property("PlatformLinkAttemptId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PlatformLinkAttemptId")); + + b.Property("IPAddress") + .HasColumnType("longtext"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("PlatformId") + .HasColumnType("bigint unsigned"); + + b.Property("Timestamp") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("PlatformLinkAttemptId"); + + b.HasIndex("UserId"); + + b.ToTable("PlatformLinkAttempts"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("UserId")); + + b.Property("AdminGrantedSlots") + .HasColumnType("int"); + + b.Property("BannedReason") + .HasColumnType("longtext"); + + b.Property("Biography") + .HasColumnType("longtext"); + + b.Property("BooHash") + .HasColumnType("longtext"); + + b.Property("CommentsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("EmailAddress") + .HasColumnType("longtext"); + + b.Property("EmailAddressVerified") + .HasColumnType("tinyint(1)"); + + b.Property("IconHash") + .HasColumnType("longtext"); + + b.Property("Language") + .HasColumnType("longtext"); + + b.Property("LastLogin") + .HasColumnType("bigint"); + + b.Property("LastLogout") + .HasColumnType("bigint"); + + b.Property("LevelVisibility") + .HasColumnType("int"); + + b.Property("LinkedPsnId") + .HasColumnType("bigint unsigned"); + + b.Property("LinkedRpcnId") + .HasColumnType("bigint unsigned"); + + b.Property("LocationPacked") + .HasColumnType("bigint unsigned"); + + b.Property("MehHash") + .HasColumnType("longtext"); + + b.Property("Password") + .HasColumnType("longtext"); + + b.Property("PasswordResetRequired") + .HasColumnType("tinyint(1)"); + + b.Property("PermissionLevel") + .HasColumnType("int"); + + b.Property("Pins") + .HasColumnType("longtext"); + + b.Property("PlanetHashLBP2") + .HasColumnType("longtext"); + + b.Property("PlanetHashLBP2CC") + .HasColumnType("longtext"); + + b.Property("PlanetHashLBP3") + .HasColumnType("longtext"); + + b.Property("PlanetHashLBPVita") + .HasColumnType("longtext"); + + b.Property("ProfileTag") + .HasColumnType("longtext"); + + b.Property("ProfileVisibility") + .HasColumnType("int"); + + b.Property("TimeZone") + .HasColumnType("longtext"); + + b.Property("TwoFactorBackup") + .HasColumnType("longtext"); + + b.Property("TwoFactorSecret") + .HasColumnType("longtext"); + + b.Property("Username") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("YayHash") + .HasColumnType("longtext"); + + b.HasKey("UserId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Token.ApiKeyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("APIKeys"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Token.EmailSetTokenEntity", b => + { + b.Property("EmailSetTokenId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmailSetTokenId")); + + b.Property("EmailToken") + .HasColumnType("longtext"); + + b.Property("ExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("EmailSetTokenId"); + + b.HasIndex("UserId"); + + b.ToTable("EmailSetTokens"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Token.EmailVerificationTokenEntity", b => + { + b.Property("EmailVerificationTokenId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmailVerificationTokenId")); + + b.Property("EmailToken") + .HasColumnType("longtext"); + + b.Property("ExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("EmailVerificationTokenId"); + + b.HasIndex("UserId"); + + b.ToTable("EmailVerificationTokens"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Token.GameTokenEntity", b => + { + b.Property("TokenId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TokenId")); + + b.Property("ExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("GameVersion") + .HasColumnType("int"); + + b.Property("LocationHash") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("TicketHash") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("UserToken") + .HasColumnType("longtext"); + + b.HasKey("TokenId"); + + b.HasIndex("UserId"); + + b.ToTable("GameTokens"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Token.PasswordResetTokenEntity", b => + { + b.Property("TokenId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TokenId")); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("ResetToken") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("TokenId"); + + b.ToTable("PasswordResetTokens"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Token.RegistrationTokenEntity", b => + { + b.Property("TokenId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TokenId")); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("Token") + .HasColumnType("longtext"); + + b.Property("Username") + .HasColumnType("longtext"); + + b.HasKey("TokenId"); + + b.ToTable("RegistrationTokens"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Token.WebTokenEntity", b => + { + b.Property("TokenId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TokenId")); + + b.Property("ExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("UserToken") + .HasColumnType("longtext"); + + b.Property("Verified") + .HasColumnType("tinyint(1)"); + + b.HasKey("TokenId"); + + b.ToTable("WebTokens"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Website.WebsiteAnnouncementEntity", b => + { + b.Property("AnnouncementId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("AnnouncementId")); + + b.Property("Content") + .HasColumnType("longtext"); + + b.Property("PublisherId") + .HasColumnType("int"); + + b.Property("Title") + .HasColumnType("longtext"); + + b.HasKey("AnnouncementId"); + + b.HasIndex("PublisherId"); + + b.ToTable("WebsiteAnnouncements"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.HeartedLevelEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot") + .WithMany() + .HasForeignKey("SlotId") + .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.Interaction.HeartedPlaylistEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.PlaylistEntity", "Playlist") + .WithMany() + .HasForeignKey("PlaylistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Playlist"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.HeartedProfileEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "HeartedUser") + .WithMany() + .HasForeignKey("HeartedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("HeartedUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.QueuedLevelEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot") + .WithMany() + .HasForeignKey("SlotId") + .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.Interaction.RatedCommentEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.CommentEntity", "Comment") + .WithMany() + .HasForeignKey("CommentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Comment"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.RatedLevelEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot") + .WithMany() + .HasForeignKey("SlotId") + .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.Interaction.RatedReviewEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.ReviewEntity", "Review") + .WithMany() + .HasForeignKey("ReviewId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Review"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.RecentlyPlayedEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.VisitedLevelEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot") + .WithMany() + .HasForeignKey("SlotId") + .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.PlaylistEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Level.ReviewEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "Reviewer") + .WithMany() + .HasForeignKey("ReviewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot") + .WithMany() + .HasForeignKey("SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Reviewer"); + + b.Navigation("Slot"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Level.ScoreEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot") + .WithMany() + .HasForeignKey("SlotId") + .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 => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Moderation.GriefReportEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "ReportingPlayer") + .WithMany() + .HasForeignKey("ReportingPlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReportingPlayer"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Moderation.ModerationCaseEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "Dismisser") + .WithMany() + .HasForeignKey("DismisserId"); + + b.Navigation("Creator"); + + b.Navigation("Dismisser"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Notifications.NotificationEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.BlockedProfileEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "BlockedUser") + .WithMany() + .HasForeignKey("BlockedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BlockedUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.CommentEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "Poster") + .WithMany() + .HasForeignKey("PosterUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "TargetSlot") + .WithMany() + .HasForeignKey("TargetSlotId"); + + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "TargetUser") + .WithMany() + .HasForeignKey("TargetUserId"); + + b.Navigation("Poster"); + + b.Navigation("TargetSlot"); + + b.Navigation("TargetUser"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.LastContactEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PhotoEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot") + .WithMany() + .HasForeignKey("SlotId"); + + b.Navigation("Creator"); + + b.Navigation("Slot"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PhotoSubjectEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PhotoEntity", "Photo") + .WithMany("PhotoSubjects") + .HasForeignKey("PhotoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Photo"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PlatformLinkAttemptEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Token.EmailSetTokenEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Token.EmailVerificationTokenEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Token.GameTokenEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Website.WebsiteAnnouncementEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "Publisher") + .WithMany() + .HasForeignKey("PublisherId"); + + b.Navigation("Publisher"); + }); + + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PhotoEntity", b => + { + b.Navigation("PhotoSubjects"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ProjectLighthouse/Migrations/20260812004441_AddRecentlyPlayed.cs b/ProjectLighthouse/Migrations/20260812004441_AddRecentlyPlayed.cs new file mode 100644 index 000000000..a8746257b --- /dev/null +++ b/ProjectLighthouse/Migrations/20260812004441_AddRecentlyPlayed.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LBPUnion.ProjectLighthouse.Migrations +{ + /// + public partial class AddRecentlyPlayed : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RecentlyPlayed", + columns: table => new + { + RecentlyPlayedId = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + UserId = table.Column(type: "int", nullable: false), + SlotIds = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + LastPlayedAt = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_RecentlyPlayed", x => x.RecentlyPlayedId); + table.ForeignKey( + name: "FK_RecentlyPlayed_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "UserId", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_RecentlyPlayed_UserId", + table: "RecentlyPlayed", + column: "UserId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RecentlyPlayed"); + } + } +} diff --git a/ProjectLighthouse/Migrations/DatabaseContextModelSnapshot.cs b/ProjectLighthouse/Migrations/DatabaseContextModelSnapshot.cs index 6c801706c..4a4eaa927 100644 --- a/ProjectLighthouse/Migrations/DatabaseContextModelSnapshot.cs +++ b/ProjectLighthouse/Migrations/DatabaseContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("ProductVersion", "8.0.18") .HasAnnotation("Relational:MaxIdentifierLength", 64); MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); @@ -198,6 +198,33 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("RatedReviews"); }); + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.RecentlyPlayedEntity", b => + { + b.Property("RecentlyPlayedId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RecentlyPlayedId")); + + b.Property("LastPlayedAt") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SlotIds") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("RecentlyPlayedId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("RecentlyPlayed"); + }); + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.VisitedLevelEntity", b => { b.Property("VisitedLevelId") @@ -1291,6 +1318,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.RecentlyPlayedEntity", b => + { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.VisitedLevelEntity", b => { b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot") diff --git a/ProjectLighthouse/Types/Entities/Interaction/RecentlyPlayedEntity.cs b/ProjectLighthouse/Types/Entities/Interaction/RecentlyPlayedEntity.cs new file mode 100644 index 000000000..6b2bacb2c --- /dev/null +++ b/ProjectLighthouse/Types/Entities/Interaction/RecentlyPlayedEntity.cs @@ -0,0 +1,23 @@ +#nullable enable + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using LBPUnion.ProjectLighthouse.Types.Entities.Profile; +using Microsoft.EntityFrameworkCore; + +namespace LBPUnion.ProjectLighthouse.Types.Entities.Interaction; + +[Index(nameof(UserId), IsUnique = true)] +public class RecentlyPlayedEntity +{ + [Key] + public int RecentlyPlayedId { get; set; } + public int UserId { get; set; } + + [ForeignKey(nameof(UserId))] + public UserEntity User { get; set; } = null!; + public List SlotIds { get; set; } = new(); + public List LastPlayedAt { get; set; } = new(); + +} \ No newline at end of file From daa8007b8c5c99748d7a33853a897b4ca45126b4 Mon Sep 17 00:00:00 2001 From: Tyler Ruark Date: Wed, 12 Aug 2026 19:59:03 -0400 Subject: [PATCH 02/15] Added the start to a LBP3 recommended category --- .../Types/Categories/CategoryHelper.cs | 1 + .../Types/Categories/RecommendedCategory.cs | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 ProjectLighthouse.Servers.GameServer/Types/Categories/RecommendedCategory.cs diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs index 4fd3f95f2..287b25dc8 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs @@ -10,6 +10,7 @@ public static class CategoryHelper static CategoryHelper() { + Categories.Add(new RecommendedCategory()); Categories.Add(new TeamPicksCategory()); Categories.Add(new MostHeartedCategory()); Categories.Add(new NewestLevelsCategory()); diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/RecommendedCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/RecommendedCategory.cs new file mode 100644 index 000000000..baa28b93a --- /dev/null +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/RecommendedCategory.cs @@ -0,0 +1,37 @@ +#nullable enable +using LBPUnion.ProjectLighthouse.Database; +using LBPUnion.ProjectLighthouse.Extensions; +using LBPUnion.ProjectLighthouse.Filter; +using LBPUnion.ProjectLighthouse.Filter.Sorts; +using LBPUnion.ProjectLighthouse.Types.Entities.Level; +using LBPUnion.ProjectLighthouse.Types.Entities.Token; + +namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Types.Categories; + +public class RecommendedCategory : SlotCategory +{ + public override string Name { get; set; } = "Recommended For You"; + public override string Description { get; set; } = "Stuff we think you'll like"; + public override string IconHash { get; set; } = "g820625"; + public override string Endpoint { get; set; } = "recommended"; + public override string Tag => "recommended"; + public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) + { + IQueryable heartedCreatorIds = database.HeartedProfiles + .Where(h => h.UserId == token.UserId) + .Select(h => h.HeartedUserId); + + IQueryable query = database.Slots + .Where(s => heartedCreatorIds.Contains(s.CreatorId)) + .Where(s => + !database.VisitedLevels.Any(v => + v.UserId == token.UserId && + v.SlotId == s.SlotId)) + .Where(queryBuilder.Build()); + + return query.ApplyOrdering( + new SlotSortBuilder() + .AddSort(new UniquePlaysTotalSort()) + .AddSort(new LastUpdatedSort())); + } +} \ No newline at end of file From dda4fc06a774ac912cf7af52079020802c1af660 Mon Sep 17 00:00:00 2001 From: Tyler Ruark Date: Thu, 13 Aug 2026 02:07:55 -0400 Subject: [PATCH 03/15] Implemented the recommended tab for LBP3 --- .../Controllers/Slots/CategoryController.cs | 19 + .../Types/Categories/RecommendedCategory.cs | 362 ++++++++++++++++-- .../Types/Serialization/GameUserSlot.cs | 9 + 3 files changed, 366 insertions(+), 24 deletions(-) diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/CategoryController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/CategoryController.cs index 60ac1c580..a9b6e16fd 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/CategoryController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/CategoryController.cs @@ -86,6 +86,7 @@ public async Task GetCategorySlots(string endpointName) GenericSerializableList returnList = category switch { + RecommendedCategory rc => await this.GetRecommendedCategory(rc, token, queryBuilder, pageData), SlotCategory gc => await this.GetSlotCategory(gc, token, queryBuilder, pageData), PlaylistCategory pc => await this.GetPlaylistCategory(pc, token, pageData), UserCategory uc => await this.GetUserCategory(uc, token, pageData), @@ -95,6 +96,24 @@ public async Task GetCategorySlots(string endpointName) return this.Ok(returnList); } + private async Task GetRecommendedCategory(RecommendedCategory recommendedCategory, GameTokenEntity token, SlotQueryBuilder queryBuilder, PaginationData pageData) + { + List recommendations = await recommendedCategory.GetScoredItems(this.database, token, queryBuilder); + + pageData.TotalElements = recommendations.Count; + + List page = recommendations + .AsQueryable() + .ApplyPagination(pageData) + .ToList(); + + List slots = page + .Select(recommendation => RecommendedCategory.CreateSerializableSlot(recommendation, token)) + .ToList(); + + return new GenericSerializableList(slots, pageData); + } + private async Task GetUserCategory(UserCategory userCategory, GameTokenEntity token, PaginationData pageData) { int totalUsers = await userCategory.GetItems(this.database, token).CountAsync(); diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/RecommendedCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/RecommendedCategory.cs index baa28b93a..b93d60fb9 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/RecommendedCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/RecommendedCategory.cs @@ -1,37 +1,351 @@ #nullable enable + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using LBPUnion.ProjectLighthouse.Database; -using LBPUnion.ProjectLighthouse.Extensions; using LBPUnion.ProjectLighthouse.Filter; -using LBPUnion.ProjectLighthouse.Filter.Sorts; using LBPUnion.ProjectLighthouse.Types.Entities.Level; using LBPUnion.ProjectLighthouse.Types.Entities.Token; +using LBPUnion.ProjectLighthouse.Types.Levels; +using LBPUnion.ProjectLighthouse.Types.Serialization; +using Microsoft.EntityFrameworkCore; namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Types.Categories; -public class RecommendedCategory : SlotCategory +public class RecommendedCategory : Category { - public override string Name { get; set; } = "Recommended For You"; - public override string Description { get; set; } = "Stuff we think you'll like"; - public override string IconHash { get; set; } = "g820625"; - public override string Endpoint { get; set; } = "recommended"; + //MaxNeighbors essentially sets a cap to the number of users who have a similar taste to what you have for the recommendation algorithm + //This also ensures that Lighthouse doesnt completely die when it tries to find good recommendations. + private const int MaxNeighbors = 250; + //MinimumNeighborOverlap is the minimum number of hearted levels required by another player before they're considered to have a "good taste" as you + //This is set at 2 levels, so if you and another player have 2 hearted levels that are the same, you'll see more similar results. + private const int MinimumNeighborOverlap = 2; + //This is the maximum number of levels that can be reviewed during a single recommendation request. + private const int MaxCandidatePool = 1000; + + public override string Name { get; set; } = + "Recommended For You"; + + public override string Description { get; set; } = + "Stuff we think you'll like"; + + public override string IconHash { get; set; } = + "g820625"; + + public override string Endpoint { get; set; } = + "recommended"; + public override string Tag => "recommended"; - public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) + + public override string[] Types { get; } = { - IQueryable heartedCreatorIds = database.HeartedProfiles - .Where(h => h.UserId == token.UserId) - .Select(h => h.HeartedUserId); - - IQueryable query = database.Slots - .Where(s => heartedCreatorIds.Contains(s.CreatorId)) - .Where(s => - !database.VisitedLevels.Any(v => - v.UserId == token.UserId && - v.SlotId == s.SlotId)) - .Where(queryBuilder.Build()); - - return query.ApplyOrdering( - new SlotSortBuilder() - .AddSort(new UniquePlaysTotalSort()) - .AddSort(new LastUpdatedSort())); + "slot", + "adventure", + }; + + //This represents one recommendation after the algorithm calculates how relevant it is to the user + //The PrevSearchScore here represents the first-stage of the users recommendation score + //It goes up when a hearted user hearts a level + //The SearchScore is the final score after the recommendation algorithm, + //Mostly from users with a similar taste. + public sealed record ScoredSlot( + SlotEntity Slot, + double SearchScore, + double PrevSearchScore, + int Hearts, + int Likes); + + //This will generate a more personalized recommendation tab based on the user.. + //It'll firstly look at the levels hearted by players that you currently heart.. + //This will become the PrevSearchScore. + // + //Then it finds users with a similar taste, look at levels they heartedm and it'll contribute to the SearchScore. + public async Task> GetScoredItems( + DatabaseContext database, + GameTokenEntity token, + SlotQueryBuilder queryBuilder) + { + //Gets the players that the user currently has hearted, this is REQUIRED if the tag 'recommended' is used. + List seedUserIds = await database.HeartedProfiles + .AsNoTracking() + .Where(h => h.UserId == token.UserId) + .Select(h => h.HeartedUserId) + .Distinct() + .ToListAsync(); + +if (seedUserIds.Count == 0) +{ + return new List(); +} + + //Finds levels hearted by the players you hearted + //Each hearted player will act as a recommendation source for you. + var seedHearts = await database.HeartedLevels + .AsNoTracking() + .Where(h => seedUserIds.Contains(h.UserId)) + .Select(h => new + { + h.UserId, + h.SlotId, + }) + .ToListAsync(); + + Dictionary directScores = seedHearts + .GroupBy(h => h.SlotId) + .ToDictionary( + group => group.Key, + group => group + .Select(h => h.UserId) + .Distinct() + .Count()); + + List seedTasteSlotIds = + directScores.Keys.ToList(); + + //This will find users who seem to have a similar taste to you, it'll look for several hearts of the same levels + Dictionary neighborScores = new(); + + if (seedTasteSlotIds.Count > 0) + { + var neighbors = await database.HeartedLevels + .AsNoTracking() + .Where(h => seedTasteSlotIds.Contains(h.SlotId)) + .Where(h => + h.UserId != token.UserId && + !seedUserIds.Contains(h.UserId)) + .GroupBy(h => h.UserId) + .Select(group => new + { + UserId = group.Key, + + //Number of levels the user shares with the use person who hearted them. + Overlap = group + .Select(h => h.SlotId) + .Distinct() + .Count(), + }) + .Where(user => + user.Overlap >= MinimumNeighborOverlap) + .OrderByDescending(user => user.Overlap) + .ThenBy(user => user.UserId) + .Take(MaxNeighbors) + .ToListAsync(); + + List neighborIds = neighbors + .Select(n => n.UserId) + .ToList(); + + //Finds hearted levels by users of the same taste + if (neighborIds.Count > 0) + { + var neighborScoreRows = await database.HeartedLevels + .AsNoTracking() + .Where(h => neighborIds.Contains(h.UserId)) + .GroupBy(h => h.SlotId) + .Select(group => new + { + SlotId = group.Key, + + Score = group + .Select(h => h.UserId) + .Distinct() + .Count(), + }) + .OrderByDescending(result => result.Score) + .Take(MaxCandidatePool) + .ToListAsync(); + + neighborScores = neighborScoreRows + .ToDictionary( + result => result.SlotId, + result => result.Score); + } + } + + //User that hearts a player may also sugguest that they'd want to see levels created by them. + List heartedCreatorSlotIds = await database.Slots + .AsNoTracking() + .Where(s => seedUserIds.Contains(s.CreatorId)) + .Where(queryBuilder.Build()) + .Where(s => !database.VisitedLevels.Any(v => v.UserId == token.UserId && v.SlotId == s.SlotId)) + .OrderByDescending(s => s.SlotId) + .Select(s => s.SlotId) + .Take(MaxCandidatePool) + .ToListAsync(); + + HashSet heartedCreatorSlotSet = + heartedCreatorSlotIds.ToHashSet(); + + //Combines 3 sources for recommendation candidates.. + //Sources are levels hearted by players that the user hearts + //Levels hearted by your 'taste neighbors' + //Abd keveks created directly by players you heart + HashSet allCandidateIds = directScores.Keys + .Concat(neighborScores.Keys) + .Concat(heartedCreatorSlotIds) + .ToHashSet(); + if (allCandidateIds.Count == 0) + { + return new List(); + } + + //Calculates a score before loading all the level metadata. + //PrevSearchScore - number of directly hearted players that support a level + //SearchScore - taste neighbors and added points for directly hearted creator. + List candidateIds = allCandidateIds + .Select(slotId => + { + int prevSearchScore = + directScores.GetValueOrDefault(slotId); + + int neighborScore = + neighborScores.GetValueOrDefault(slotId); + + int creatorBonus = + heartedCreatorSlotSet.Contains(slotId) + ? 1 + : 0; + + int searchScore = + prevSearchScore + + neighborScore + + creatorBonus; + + return new + { + SlotId = slotId, + SearchScore = searchScore, + PrevSearchScore = prevSearchScore, + }; + }) + .OrderByDescending(result => result.SearchScore) + .ThenByDescending(result => result.PrevSearchScore) + .ThenBy(result => result.SlotId) + .Take(MaxCandidatePool) + .Select(result => result.SlotId) + .ToList(); + + //Loads the levels and applies the standard slot filters, and ensures that your played levels are removed here + List slots = await database.Slots + .AsNoTracking() + .Where(s => candidateIds.Contains(s.SlotId)) + .Where(queryBuilder.Build()) + .Where(s => + !database.VisitedLevels.Any(v => + v.UserId == token.UserId && + v.SlotId == s.SlotId)) + .ToListAsync(); + + if (slots.Count == 0) + { + return new List(); + } + + List validSlotIds = slots + .Select(s => s.SlotId) + .ToList(); + + //Hearts and likes are used ONLY to break a tie between levels + Dictionary heartCounts = await database.HeartedLevels + .AsNoTracking() + .Where(h => validSlotIds.Contains(h.SlotId)) + .GroupBy(h => h.SlotId) + .ToDictionaryAsync( + group => group.Key, + group => group.Count()); + + Dictionary likeCounts = await database.RatedLevels + .AsNoTracking() + .Where(r => + validSlotIds.Contains(r.SlotId) && + r.Rating == 1) + .GroupBy(r => r.SlotId) + .ToDictionaryAsync( + group => group.Key, + group => group.Count()); + + //This produces the final recommendation result objects. + List recommendations = slots + .Select(slot => + { + int prevSearchScore = + directScores.GetValueOrDefault(slot.SlotId); + + int neighborScore = + neighborScores.GetValueOrDefault(slot.SlotId); + + int creatorBonus = + seedUserIds.Contains(slot.CreatorId) + ? 1 + : 0; + + int searchScore = + prevSearchScore + + neighborScore + + creatorBonus; + + return new ScoredSlot( + slot, + searchScore, + prevSearchScore, + heartCounts.GetValueOrDefault(slot.SlotId), + likeCounts.GetValueOrDefault(slot.SlotId)); + }) + .Where(result => result.SearchScore > 0) + .OrderByDescending(result => result.SearchScore) + .ThenByDescending(result => result.PrevSearchScore) + .ThenByDescending(result => result.Hearts) + .ThenByDescending(result => result.Likes) + .ThenByDescending(result => result.Slot.SlotId) + .ToList(); + + return recommendations; + } + + //Converts the scored recommendation into a regular slot. + public static ILbpSerializable CreateSerializableSlot(ScoredSlot recommendation, GameTokenEntity token) +{ + SlotBase serialized = SlotBase.CreateFromEntity(recommendation.Slot, token); + + if (serialized is GameUserSlot userSlot) + { + userSlot.SearchScore = + recommendation.SearchScore; + + userSlot.PrevSearchScore = + recommendation.PrevSearchScore; + } + + return serialized; +} + + public override async Task Serialize( + DatabaseContext database, + GameTokenEntity token, + SlotQueryBuilder queryBuilder, + int numResults = 1) + { + List recommendations = + await this.GetScoredItems( + database, + token, + queryBuilder); + + List serializedSlots = + recommendations + .Take(numResults) + .Select(recommendation => + CreateSerializableSlot( + recommendation, + token)) + .ToList(); + + return GameCategory.CreateFromEntity( + this, + new GenericSerializableList( + serializedSlots, + recommendations.Count, + numResults + 1)); } } \ No newline at end of file diff --git a/ProjectLighthouse/Types/Serialization/GameUserSlot.cs b/ProjectLighthouse/Types/Serialization/GameUserSlot.cs index 978818be4..37bb280d0 100644 --- a/ProjectLighthouse/Types/Serialization/GameUserSlot.cs +++ b/ProjectLighthouse/Types/Serialization/GameUserSlot.cs @@ -41,6 +41,15 @@ public class GameUserSlot : SlotBase, INeedsPreparationForSerialization [XmlElement("npHandle")] public NpHandle AuthorHandle { get; set; } = new(); + [XmlElement("searchScore")] + public double? SearchScore { get; set; } + public bool ShouldSerializeSearchScore() => + this.SearchScore.HasValue; + + [XmlElement("prevSearchScore")] + public double? PrevSearchScore { get; set; } + public bool ShouldSerializePrevSearchScore() => + this.PrevSearchScore.HasValue; [XmlElement("location")] public Location Location { get; set; } = new(); From cab253c4ad5689e6daece2fb8e35933ecd3579f9 Mon Sep 17 00:00:00 2001 From: Tyler Ruark Date: Thu, 13 Aug 2026 02:40:32 -0400 Subject: [PATCH 04/15] Made the code look a little neater --- .../Types/Categories/RecommendedCategory.cs | 124 ++++++------------ 1 file changed, 39 insertions(+), 85 deletions(-) diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/RecommendedCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/RecommendedCategory.cs index b93d60fb9..8e576f413 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/RecommendedCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/RecommendedCategory.cs @@ -23,19 +23,10 @@ public class RecommendedCategory : Category private const int MinimumNeighborOverlap = 2; //This is the maximum number of levels that can be reviewed during a single recommendation request. private const int MaxCandidatePool = 1000; - - public override string Name { get; set; } = - "Recommended For You"; - - public override string Description { get; set; } = - "Stuff we think you'll like"; - - public override string IconHash { get; set; } = - "g820625"; - - public override string Endpoint { get; set; } = - "recommended"; - + public override string Name { get; set; } = "Recommended For You"; + public override string Description { get; set; } = "Stuff we think you'll like"; + public override string IconHash { get; set; } = "g820625"; + public override string Endpoint { get; set; } = "recommended"; public override string Tag => "recommended"; public override string[] Types { get; } = @@ -49,22 +40,14 @@ public class RecommendedCategory : Category //It goes up when a hearted user hearts a level //The SearchScore is the final score after the recommendation algorithm, //Mostly from users with a similar taste. - public sealed record ScoredSlot( - SlotEntity Slot, - double SearchScore, - double PrevSearchScore, - int Hearts, - int Likes); + public sealed record ScoredSlot(SlotEntity Slot, double SearchScore, double PrevSearchScore, int Hearts, int Likes); //This will generate a more personalized recommendation tab based on the user.. //It'll firstly look at the levels hearted by players that you currently heart.. //This will become the PrevSearchScore. // //Then it finds users with a similar taste, look at levels they heartedm and it'll contribute to the SearchScore. - public async Task> GetScoredItems( - DatabaseContext database, - GameTokenEntity token, - SlotQueryBuilder queryBuilder) + public async Task> GetScoredItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) { //Gets the players that the user currently has hearted, this is REQUIRED if the tag 'recommended' is used. List seedUserIds = await database.HeartedProfiles @@ -81,27 +64,28 @@ public async Task> GetScoredItems( //Finds levels hearted by the players you hearted //Each hearted player will act as a recommendation source for you. - var seedHearts = await database.HeartedLevels + //OPTIMIZATION - I made it so that the grouping is done via the database and not within Lighthouse + //It loads every heart into memory on the servers with a lot of data + var directScoreRows = await database.HeartedLevels .AsNoTracking() .Where(h => seedUserIds.Contains(h.UserId)) - .Select(h => new + .GroupBy(h => h.SlotId) + .Select(group => new { - h.UserId, - h.SlotId, + SlotId = group.Key, + Score = group + .Select(h => h.UserId) + .Distinct() + .Count(), }) + .OrderByDescending(result => result.Score) + .ThenBy(result => result.SlotId) + .Take(MaxCandidatePool) .ToListAsync(); - Dictionary directScores = seedHearts - .GroupBy(h => h.SlotId) - .ToDictionary( - group => group.Key, - group => group - .Select(h => h.UserId) - .Distinct() - .Count()); + Dictionary directScores = directScoreRows.ToDictionary(result => result.SlotId, result => result.Score); - List seedTasteSlotIds = - directScores.Keys.ToList(); + List seedTasteSlotIds = directScores.Keys.ToList(); //This will find users who seem to have a similar taste to you, it'll look for several hearts of the same levels Dictionary neighborScores = new(); @@ -125,8 +109,7 @@ public async Task> GetScoredItems( .Distinct() .Count(), }) - .Where(user => - user.Overlap >= MinimumNeighborOverlap) + .Where(user => user.Overlap >= MinimumNeighborOverlap) .OrderByDescending(user => user.Overlap) .ThenBy(user => user.UserId) .Take(MaxNeighbors) @@ -155,7 +138,6 @@ public async Task> GetScoredItems( .OrderByDescending(result => result.Score) .Take(MaxCandidatePool) .ToListAsync(); - neighborScores = neighborScoreRows .ToDictionary( result => result.SlotId, @@ -174,8 +156,7 @@ public async Task> GetScoredItems( .Take(MaxCandidatePool) .ToListAsync(); - HashSet heartedCreatorSlotSet = - heartedCreatorSlotIds.ToHashSet(); + HashSet heartedCreatorSlotSet = heartedCreatorSlotIds.ToHashSet(); //Combines 3 sources for recommendation candidates.. //Sources are levels hearted by players that the user hearts @@ -196,16 +177,13 @@ public async Task> GetScoredItems( List candidateIds = allCandidateIds .Select(slotId => { - int prevSearchScore = - directScores.GetValueOrDefault(slotId); + int prevSearchScore = directScores.GetValueOrDefault(slotId); - int neighborScore = - neighborScores.GetValueOrDefault(slotId); + int neighborScore = neighborScores.GetValueOrDefault(slotId); - int creatorBonus = - heartedCreatorSlotSet.Contains(slotId) - ? 1 - : 0; + int creatorBonus = heartedCreatorSlotSet.Contains(slotId) + ? 1 + : 0; int searchScore = prevSearchScore + @@ -269,14 +247,11 @@ public async Task> GetScoredItems( List recommendations = slots .Select(slot => { - int prevSearchScore = - directScores.GetValueOrDefault(slot.SlotId); + int prevSearchScore = directScores.GetValueOrDefault(slot.SlotId); - int neighborScore = - neighborScores.GetValueOrDefault(slot.SlotId); + int neighborScore = neighborScores.GetValueOrDefault(slot.SlotId); - int creatorBonus = - seedUserIds.Contains(slot.CreatorId) + int creatorBonus = seedUserIds.Contains(slot.CreatorId) ? 1 : 0; @@ -285,12 +260,7 @@ public async Task> GetScoredItems( neighborScore + creatorBonus; - return new ScoredSlot( - slot, - searchScore, - prevSearchScore, - heartCounts.GetValueOrDefault(slot.SlotId), - likeCounts.GetValueOrDefault(slot.SlotId)); + return new ScoredSlot(slot, searchScore, prevSearchScore, heartCounts.GetValueOrDefault(slot.SlotId), likeCounts.GetValueOrDefault(slot.SlotId)); }) .Where(result => result.SearchScore > 0) .OrderByDescending(result => result.SearchScore) @@ -310,30 +280,19 @@ public static ILbpSerializable CreateSerializableSlot(ScoredSlot recommendation, if (serialized is GameUserSlot userSlot) { - userSlot.SearchScore = - recommendation.SearchScore; + userSlot.SearchScore = recommendation.SearchScore; - userSlot.PrevSearchScore = - recommendation.PrevSearchScore; + userSlot.PrevSearchScore = recommendation.PrevSearchScore; } return serialized; } - public override async Task Serialize( - DatabaseContext database, - GameTokenEntity token, - SlotQueryBuilder queryBuilder, - int numResults = 1) + public override async Task Serialize(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder, int numResults = 1) { - List recommendations = - await this.GetScoredItems( - database, - token, - queryBuilder); - - List serializedSlots = - recommendations + List recommendations = await this.GetScoredItems(database, token, queryBuilder); + + List serializedSlots = recommendations .Take(numResults) .Select(recommendation => CreateSerializableSlot( @@ -341,11 +300,6 @@ await this.GetScoredItems( token)) .ToList(); - return GameCategory.CreateFromEntity( - this, - new GenericSerializableList( - serializedSlots, - recommendations.Count, - numResults + 1)); + return GameCategory.CreateFromEntity(this, new GenericSerializableList(serializedSlots, recommendations.Count, numResults + 1)); } } \ No newline at end of file From 6a9d17ebdc4b8af44be1ae740eb6d45950a0ab4c Mon Sep 17 00:00:00 2001 From: Tyler Ruark Date: Thu, 13 Aug 2026 14:42:12 -0400 Subject: [PATCH 05/15] Recently played uses the play endpoint instead of gameState now --- .../Matching/EnterLevelController.cs | 5 ++ .../Controllers/Matching/MatchController.cs | 89 +------------------ .../Database/DatabaseContext.Slots.cs | 32 +++---- 3 files changed, 20 insertions(+), 106 deletions(-) diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Matching/EnterLevelController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Matching/EnterLevelController.cs index e8b2aa37a..6bb537060 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Matching/EnterLevelController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Matching/EnterLevelController.cs @@ -89,6 +89,11 @@ public async Task PlayLevel(string slotType, int slotId) return this.BadRequest(); } + if (token.GameVersion == GameVersion.LittleBigPlanet3 && slotType == "user") + { + await this.database.RecordRecentlyPlayedLevel(token.UserId, slotId, saveChanges: false); + } + await this.database.SaveChangesAsync(); return this.Ok(); diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs index 100e5a13b..5684495b7 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs @@ -1,5 +1,4 @@ #nullable enable -using System; using System.Text.Json; using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Database; @@ -34,94 +33,10 @@ public MatchController(DatabaseContext database) [HttpPost("gameState")] [Produces("text/plain")] - public async Task GameState() + public IActionResult GameState() { - GameTokenEntity token = this.GetToken(); - string bodyString = await this.ReadBodyAsync(); - - Logger.Info( - $"Server has received gameState, GameVersion={token.GameVersion}, Platform={token.Platform}, Body={bodyString}", - LogArea.Match); - - if (string.IsNullOrWhiteSpace(bodyString)) - return this.Ok("VALID"); - - try - { - int jsonStart = bodyString.IndexOf('{'); - int jsonEnd = bodyString.LastIndexOf('}'); - - if (jsonStart < 0 || jsonEnd < jsonStart) - return this.Ok("VALID"); - - string json = bodyString[jsonStart..(jsonEnd + 1)]; - - using JsonDocument document = JsonDocument.Parse(json); - JsonElement root = document.RootElement; - - if (!root.TryGetProperty("currentLevel", out JsonElement currentLevel)) - return this.Ok("VALID"); - - if (currentLevel.ValueKind != JsonValueKind.Array || - currentLevel.GetArrayLength() < 2) - return this.Ok("VALID"); - - string? levelType = currentLevel[0].GetString(); - - if (!currentLevel[1].TryGetInt32(out int slotId)) - return this.Ok("VALID"); - - Logger.Info( - $"Parsed gameState: GameVersion={token.GameVersion}, LevelType={levelType}, SlotId={slotId}", - LogArea.Match); - - //Makes it so that this is a LBP3 only feature - if (token.GameVersion != GameVersion.LittleBigPlanet3) - return this.Ok("VALID"); - - //Makes it so that only community/user levels belong in Recently Played. - if (!string.Equals(levelType, "user", StringComparison.OrdinalIgnoreCase)) - return this.Ok("VALID"); - - if (slotId <= 0) - return this.Ok("VALID"); - - //This checks the supplied slotId to see if its valid. - bool slotExists = await this.database.Slots - .AnyAsync(s => s.SlotId == slotId); - - if (!slotExists) - { - Logger.Info( - $"Ignoring recently played SlotId={slotId} since it doesn't exist.", - LogArea.Match); - - return this.Ok("VALID"); - } - - await this.database.RecordRecentlyPlayedLevel( - token.UserId, - slotId); - - Logger.Info( - $"Successfully updated Recently Played for UserId={token.UserId}, SlotId={slotId}", - LogArea.Match); - } - catch (JsonException e) - { - Logger.Error( - $"Failed to parse the gameState JSON: {e.Message}", - LogArea.Match); - } - catch (Exception e) - { - //Makes it so that this recently played implementation doesnt cause /gameState to cry and break. - Logger.Error( - $"Failed to update Recently Played: {e.Message}", - LogArea.Match); - } return this.Ok("VALID"); -} + } [HttpPost("match")] [Produces("text/plain")] diff --git a/ProjectLighthouse/Database/DatabaseContext.Slots.cs b/ProjectLighthouse/Database/DatabaseContext.Slots.cs index 381f0b37d..b1af81cfa 100644 --- a/ProjectLighthouse/Database/DatabaseContext.Slots.cs +++ b/ProjectLighthouse/Database/DatabaseContext.Slots.cs @@ -90,15 +90,13 @@ public async Task UnqueueLevel(int userId, SlotEntity queuedSlot) await this.SaveChangesAsync(); } - public async Task RecordRecentlyPlayedLevel(int userId, int slotId) + public async Task RecordRecentlyPlayedLevel(int userId, int slotId, bool saveChanges = true) { - RecentlyPlayedEntity? recentlyPlayed = - await this.RecentlyPlayed.FirstOrDefaultAsync(r => - r.UserId == userId); + RecentlyPlayedEntity? recentlyPlayed = await this.RecentlyPlayed.FirstOrDefaultAsync(r => r.UserId == userId); long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - //Top recently played level for the user. + //Level at the top of the recently played category for the user if (recentlyPlayed == null) { this.RecentlyPlayed.Add(new RecentlyPlayedEntity @@ -108,18 +106,18 @@ await this.RecentlyPlayed.FirstOrDefaultAsync(r => LastPlayedAt = new List { now }, }); - await this.SaveChangesAsync(); + if (saveChanges)await this.SaveChangesAsync(); + return; } - //LBP3 can send multiple gameState packets for one level, and if the player is already connected, it doesnt reqrite the entry with this - if (recentlyPlayed.SlotIds.Count > 0 && - recentlyPlayed.SlotIds[0] == slotId) + //This makes it so the users most recently played level isnt rewritten if it they're in said level + if (recentlyPlayed.SlotIds.Count > 0 && recentlyPlayed.SlotIds[0] == slotId) { return; } - //If the level already exists in the history it removes the slot id and the timestamp + //If the level already existed in the users history, it removes its old timestamp and position, then moves it to the top of the list. int existingIndex = recentlyPlayed.SlotIds.IndexOf(slotId); if (existingIndex >= 0) @@ -130,25 +128,21 @@ await this.RecentlyPlayed.FirstOrDefaultAsync(r => recentlyPlayed.LastPlayedAt.RemoveAt(existingIndex); } - //The newest added levels go to the start of the list + //The most recently played level is at the top recentlyPlayed.SlotIds.Insert(0, slotId); recentlyPlayed.LastPlayedAt.Insert(0, now); - //Keeps a max of 20 levels + //Max of 20 levels if (recentlyPlayed.SlotIds.Count > 20) { - recentlyPlayed.SlotIds.RemoveRange( - 20, - recentlyPlayed.SlotIds.Count - 20); + recentlyPlayed.SlotIds.RemoveRange(20, recentlyPlayed.SlotIds.Count - 20); } if (recentlyPlayed.LastPlayedAt.Count > 20) { - recentlyPlayed.LastPlayedAt.RemoveRange( - 20, - recentlyPlayed.LastPlayedAt.Count - 20); + recentlyPlayed.LastPlayedAt.RemoveRange(20, recentlyPlayed.LastPlayedAt.Count - 20); } - await this.SaveChangesAsync(); + if (saveChanges)await this.SaveChangesAsync(); } } \ No newline at end of file From 6a475afa9ce9f0e865b393dc8c05d4f3eca79db0 Mon Sep 17 00:00:00 2001 From: Tyler Ruark Date: Thu, 13 Aug 2026 14:43:04 -0400 Subject: [PATCH 06/15] Added Buisiest category to LBP3 --- .../Types/Categories/BusiestCategory.cs | 49 +++++++++++++++++++ .../Types/Categories/CategoryHelper.cs | 6 +-- ProjectLighthouse/Helpers/RoomHelper.cs | 20 ++++++++ .../Types/Serialization/GameUserSlot.cs | 6 ++- 4 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 ProjectLighthouse.Servers.GameServer/Types/Categories/BusiestCategory.cs diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/BusiestCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/BusiestCategory.cs new file mode 100644 index 000000000..02f4b1fdb --- /dev/null +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/BusiestCategory.cs @@ -0,0 +1,49 @@ +#nullable enable +using System.Linq.Expressions; +using LBPUnion.ProjectLighthouse.Database; +using LBPUnion.ProjectLighthouse.Filter; +using LBPUnion.ProjectLighthouse.Helpers; +using LBPUnion.ProjectLighthouse.Types.Entities.Level; +using LBPUnion.ProjectLighthouse.Types.Entities.Token; + +namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Types.Categories; + +public class BusiestCategory : SlotCategory +{ + public override string Name { get; set; } = "Busiest"; + public override string Description { get; set; } = "Levels being played right now!"; + public override string IconHash { get; set; } = "g820602"; + public override string Endpoint { get; set; } = "busiest"; + public override string Tag => "busiest"; + + public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) + { + Dictionary playerCounts = RoomHelper.GetUserLevelPlayerCounts(); + + //If nobody is inside of a user level + if (playerCounts.Count == 0) + return database.Slots.Where(_ => false); + + List slotIds = playerCounts.Keys.ToList(); + + ParameterExpression slotParameter = Expression.Parameter(typeof(SlotEntity), "slot"); + + MemberExpression slotIdProperty = Expression.Property(slotParameter, nameof(SlotEntity.SlotId)); + + Expression playerCountExpression = Expression.Constant(0); + + foreach (KeyValuePair playerCount in playerCounts) + { + playerCountExpression = Expression.Condition(Expression.Equal(slotIdProperty, Expression.Constant(playerCount.Key)), Expression.Constant(playerCount.Value), playerCountExpression); + } + + Expression> ordering = Expression.Lambda>(playerCountExpression, slotParameter); + + return database.Slots + .Where(slot => + slotIds.Contains(slot.SlotId)) + .Where(queryBuilder.Build()) + .OrderByDescending(ordering) + .ThenByDescending(slot => slot.SlotId); + } +} \ No newline at end of file diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs index 287b25dc8..4432f7704 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs @@ -10,18 +10,18 @@ public static class CategoryHelper static CategoryHelper() { + Categories.Add(new RecentlyPlayedCategory()); Categories.Add(new RecommendedCategory()); Categories.Add(new TeamPicksCategory()); Categories.Add(new MostHeartedCategory()); Categories.Add(new NewestLevelsCategory()); + Categories.Add(new BusiestCategory()); Categories.Add(new MostPlayedCategory()); - Categories.Add(new HighestRatedCategory()); - Categories.Add(new MyHeartedCreatorsCategory()); Categories.Add(new MyPlaylistsCategory()); Categories.Add(new QueueCategory()); Categories.Add(new HeartedCategory()); + Categories.Add(new HighestRatedCategory()); Categories.Add(new LuckyDipCategory()); - Categories.Add(new RecentlyPlayedCategory()); Categories.Add(new TextSearchCategory()); using DatabaseContext database = DatabaseContext.CreateNewInstance(); diff --git a/ProjectLighthouse/Helpers/RoomHelper.cs b/ProjectLighthouse/Helpers/RoomHelper.cs index 47d0771c3..8b1fb4100 100644 --- a/ProjectLighthouse/Helpers/RoomHelper.cs +++ b/ProjectLighthouse/Helpers/RoomHelper.cs @@ -156,6 +156,26 @@ public static Room CreateRoom(List users, GameVersion roomVersion, Platform return Rooms.FirstOrDefault(room => room.PlayerIds.Any(p => p == userId)); } + public static Dictionary GetUserLevelPlayerCounts() + { + lock (RoomLock) + { + return Rooms + .Where(room => + room.Slot.SlotType == SlotType.User && room.Slot.SlotId != 0) + .SelectMany(room => + room.PlayerIds.Select(playerId => new + { + SlotId = room.Slot.SlotId, + PlayerId = playerId, + })) + //Distinct being used here prevents a duplicate room state from messing up the player count. + .Distinct() + .GroupBy(entry => entry.SlotId) + .ToDictionary(group => group.Key, group => group.Count()); + } + } + [SuppressMessage("ReSharper", "InvertIf")] public static Task CleanupRooms(DatabaseContext database, int? hostId = null, Room? newRoom = null) { diff --git a/ProjectLighthouse/Types/Serialization/GameUserSlot.cs b/ProjectLighthouse/Types/Serialization/GameUserSlot.cs index 37bb280d0..73cf25286 100644 --- a/ProjectLighthouse/Types/Serialization/GameUserSlot.cs +++ b/ProjectLighthouse/Types/Serialization/GameUserSlot.cs @@ -303,7 +303,11 @@ public async Task PrepareSerialization(DatabaseContext database) } #nullable disable - this.PlayerCount = RoomHelper.Rooms.Count(r => r.Slot.SlotType == SlotType.User && r.Slot.SlotId == this.SlotId); + Dictionary playerCounts = RoomHelper.GetUserLevelPlayerCounts(); + + this.PlayerCount = playerCounts.TryGetValue(this.SlotId, out int playerCount) + ? playerCount + : 0; } } \ No newline at end of file From 6bd5dfda075931850c88858d97f53161c3c2c14e Mon Sep 17 00:00:00 2001 From: Tyler Ruark Date: Fri, 14 Aug 2026 16:53:48 -0400 Subject: [PATCH 07/15] Fixed category metadata and filters --- .../Controllers/Slots/CategoryController.cs | 87 ++++++++++--------- .../Extensions/ControllerExtensions.cs | 4 +- .../Types/Categories/BusiestCategory.cs | 4 + .../Types/Categories/HeartedCategory.cs | 4 + .../Types/Categories/HighestRatedCategory.cs | 20 +++-- .../Types/Categories/LuckyDipCategory.cs | 10 ++- .../Types/Categories/MostHeartedCategory.cs | 20 ++++- .../Types/Categories/MostPlayedCategory.cs | 11 +++ .../Types/Categories/NewestLevelsCategory.cs | 20 +++-- .../Types/Categories/QueueCategory.cs | 4 + .../Categories/RecentlyPlayedCategory.cs | 4 + .../Types/Categories/TeamPicksCategory.cs | 2 + .../Types/MyPlaylistsCategory.cs | 4 + ProjectLighthouse/ProjectLighthouse.csproj | 2 +- ProjectLighthouse/Types/Levels/Category.cs | 12 ++- .../Types/Levels/CategoryDefaults.cs | 32 +++++++ .../Types/Serialization/GameCategory.cs | 64 +++++++++----- 17 files changed, 222 insertions(+), 82 deletions(-) create mode 100644 ProjectLighthouse/Types/Levels/CategoryDefaults.cs diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/CategoryController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/CategoryController.cs index a9b6e16fd..dfea3c908 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/CategoryController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/CategoryController.cs @@ -49,13 +49,12 @@ public async Task GenresAndSearches() List categories = new(); - SlotQueryBuilder queryBuilder = this.FilterFromRequest(token); - foreach (Category category in CategoryHelper.Categories.Where(c => !string.IsNullOrWhiteSpace(c.Name)) .Skip(Math.Max(0, pageData.PageStart - 1)) .Take(Math.Min(pageData.PageSize, pageData.MaxElements)) .ToList()) { + SlotQueryBuilder queryBuilder = this.FilterFromRequest(token, defaultToCurrentGame: category.DefaultToCurrentGame); int numResults = results > 0 ? 1 : 0; categories.Add(await category.Serialize(this.database, token, queryBuilder, numResults)); results--; @@ -82,7 +81,7 @@ public async Task GetCategorySlots(string endpointName) Logger.Debug("Found category " + category, LogArea.Category); - SlotQueryBuilder queryBuilder = this.FilterFromRequest(token); + SlotQueryBuilder queryBuilder = this.FilterFromRequest(token, defaultToCurrentGame: category.DefaultToCurrentGame); GenericSerializableList returnList = category switch { @@ -140,53 +139,63 @@ private async Task GetPlaylistCategory(PlaylistCategory private async Task GetSlotCategory(SlotCategory slotCategory, GameTokenEntity token, SlotQueryBuilder queryBuilder, PaginationData pageData) { - int totalSlots = await slotCategory.GetItems(this.database, token, queryBuilder).CountAsync(); - pageData.TotalElements = totalSlots; - IQueryable slotQuery = slotCategory.GetItems(this.database, token, queryBuilder).ApplyPagination(pageData); + IQueryable slotQuery = slotCategory.GetItems(this.database, token, queryBuilder); if (bool.TryParse(this.Request.Query["includePlayed"], out bool includePlayed) && !includePlayed) { - slotQuery = slotQuery.Select(s => new SlotMetadata - { - Slot = s, - Played = this.database.VisitedLevels.Any(v => v.SlotId == s.SlotId && v.UserId == token.UserId), - }) - .Where(s => !s.Played) - .Select(s => s.Slot); + slotQuery = slotQuery.Where(s => !this.database.VisitedLevels.Any(v => v.SlotId == s.SlotId && v.UserId == token.UserId)); } if (this.Request.Query.ContainsKey("sort")) { string sort = (string?)this.Request.Query["sort"] ?? ""; - slotQuery = sort switch + + //Only accept sorts this category actually advertises. + if (slotCategory.Sorts.Contains(sort)) { - "relevance" => slotQuery.ApplyOrdering(new SlotSortBuilder() - .AddSort(new UniquePlaysTotalSort()) - .AddSort(new LastUpdatedSort())), - "likes" => slotQuery.Select(s => new SlotMetadata - { - Slot = s, - ThumbsUp = this.database.RatedLevels.Count(r => r.SlotId == s.SlotId && r.Rating == 1), - }) - .OrderByDescending(s => s.ThumbsUp) - .Select(s => s.Slot), - "hearts" => slotQuery.Select(s => new SlotMetadata - { - Slot = s, - Hearts = this.database.HeartedLevels.Count(h => h.SlotId == s.SlotId), - }) - .OrderByDescending(s => s.Hearts) - .Select(s => s.Slot), - "date" => slotQuery.ApplyOrdering(new SlotSortBuilder().AddSort(new FirstUploadedSort())), - "plays" => slotQuery.ApplyOrdering( - new SlotSortBuilder().AddSort(new UniquePlaysTotalSort()).AddSort(new TotalPlaysSort())), - _ => slotQuery, - }; + slotQuery = sort switch + { + "relevance" => slotQuery.ApplyOrdering(new SlotSortBuilder() + .AddSort(new UniquePlaysTotalSort()) + .AddSort(new LastUpdatedSort())), + + "likes" => slotQuery + .Select(s => new SlotMetadata + { + Slot = s, + ThumbsUp = this.database.RatedLevels.Count(r => r.SlotId == s.SlotId && r.Rating == 1), + }) + .OrderByDescending(s => s.ThumbsUp) + .Select(s => s.Slot), + + "hearts" => slotQuery + .Select(s => new SlotMetadata + { + Slot = s, + Hearts = this.database.HeartedLevels.Count(h => h.SlotId == s.SlotId), + }) + .OrderByDescending(s => s.Hearts) + .Select(s => s.Slot), + + "date" => slotQuery.ApplyOrdering(new SlotSortBuilder() + .AddSort(new FirstUploadedSort())), + + "plays" => slotQuery.ApplyOrdering(new SlotSortBuilder() + .AddSort(new UniquePlaysTotalSort()) + .AddSort(new TotalPlaysSort())), + + _ => slotQuery, + }; + } } - List slots = - (await slotQuery.ToListAsync()).ToSerializableList(s => - SlotBase.CreateFromEntity(s, token)); + pageData.TotalElements = await slotQuery.CountAsync(); + + slotQuery = slotQuery.ApplyPagination(pageData); + + List slots = (await slotQuery.ToListAsync()) + .ToSerializableList(s => SlotBase.CreateFromEntity(s, token)); + return new GenericSerializableList(slots, pageData); } } \ No newline at end of file diff --git a/ProjectLighthouse.Servers.GameServer/Extensions/ControllerExtensions.cs b/ProjectLighthouse.Servers.GameServer/Extensions/ControllerExtensions.cs index 7c654a6f2..090df1e45 100644 --- a/ProjectLighthouse.Servers.GameServer/Extensions/ControllerExtensions.cs +++ b/ProjectLighthouse.Servers.GameServer/Extensions/ControllerExtensions.cs @@ -33,7 +33,7 @@ public static SlotQueryBuilder GetDefaultFilters(this ControllerBase controller, .AddFilter(new HiddenSlotFilter()) .AddFilter(new SlotTypeFilter(SlotType.User)); - public static SlotQueryBuilder FilterFromRequest(this ControllerBase controller, GameTokenEntity token) + public static SlotQueryBuilder FilterFromRequest(this ControllerBase controller, GameTokenEntity token, bool defaultToCurrentGame = true) { SlotQueryBuilder queryBuilder = new(); @@ -158,7 +158,7 @@ void ParseLbp3Query(string key, Action allMust, Action noneCan, Action dontCare) .Select(s => GetGameFilter(s, token.GameVersion)) .ToArray())); } - else + else if (defaultToCurrentGame) { queryBuilder.AddFilter(new GameVersionFilter(GameVersion.LittleBigPlanet3)); } diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/BusiestCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/BusiestCategory.cs index 02f4b1fdb..f5e082fb1 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/BusiestCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/BusiestCategory.cs @@ -15,6 +15,10 @@ public class BusiestCategory : SlotCategory public override string IconHash { get; set; } = "g820602"; public override string Endpoint { get; set; } = "busiest"; public override string Tag => "busiest"; + public override string[] Sorts { get; } = + { + "relevance", + }; public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) { diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/HeartedCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/HeartedCategory.cs index 0a9eeee11..a816677b0 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/HeartedCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/HeartedCategory.cs @@ -13,6 +13,10 @@ public class HeartedCategory : SlotCategory public override string IconHash { get; set; } = "g820611"; public override string Endpoint { get; set; } = "hearted_levels"; public override string Tag => "my_hearted_levels"; + public override string[] Sorts { get; } = + { + "relevance" + }; public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) => database.HeartedLevels.Where(h => h.UserId == token.UserId) diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/HighestRatedCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/HighestRatedCategory.cs index 06727020a..6beb80cf7 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/HighestRatedCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/HighestRatedCategory.cs @@ -4,23 +4,33 @@ using LBPUnion.ProjectLighthouse.Types.Entities.Level; using LBPUnion.ProjectLighthouse.Types.Entities.Token; using LBPUnion.ProjectLighthouse.Types.Misc; +using LBPUnion.ProjectLighthouse.Types.Levels; namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Types.Categories; public class HighestRatedCategory : SlotCategory { public override string Name { get; set; } = "Highest Rated"; - public override string Description { get; set; } = "Community Highest Rated content"; + public override string Description { get; set; } = "Content with loads of thumbs up"; public override string IconHash { get; set; } = "g820603"; public override string Endpoint { get; set; } = "thumbs"; public override string Tag => "highest_rated"; + public override string[] Sorts { get; } = + { + "likes", + }; + public override CategoryDefaults DefaultFilters { get; } = new() + { + DateFilterType = "thisMonth", + }; + public override bool DefaultToCurrentGame => false; public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) => database.Slots.Select(s => new SlotMetadata - { - Slot = s, - ThumbsUp = database.RatedLevels.Count(r => r.SlotId == s.SlotId && r.Rating == 1), - }) + { + Slot = s, + ThumbsUp = database.RatedLevels.Count(r => r.SlotId == s.SlotId && r.Rating == 1), + }) .OrderByDescending(s => s.ThumbsUp) .Select(s => s.Slot) .Where(queryBuilder.Build()); diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/LuckyDipCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/LuckyDipCategory.cs index 6d8528b4d..ee7311cd7 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/LuckyDipCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/LuckyDipCategory.cs @@ -15,7 +15,15 @@ public class LuckyDipCategory : SlotCategory public override string Description { get; set; } = "A random selection of content"; public override string IconHash { get; set; } = "g820605"; public override string Endpoint { get; set; } = "lucky_dip"; - public override string Tag => "lucky_dip"; + public override string Tag => "level_of_the_day"; + public override string[] Sorts { get; } = + { + "relevance", + }; + public override bool Curated => false; + //The game client doesnt seem to work with this, but I'll leave it here in case its just a bug on my end in the future + public override bool DisableFilters => true; + public override bool DefaultToCurrentGame => false; public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) { diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/MostHeartedCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/MostHeartedCategory.cs index c06ba54dc..708df2adc 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/MostHeartedCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/MostHeartedCategory.cs @@ -5,6 +5,7 @@ using LBPUnion.ProjectLighthouse.Filter.Sorts.Metadata; using LBPUnion.ProjectLighthouse.Types.Entities.Level; using LBPUnion.ProjectLighthouse.Types.Entities.Token; +using LBPUnion.ProjectLighthouse.Types.Levels; using LBPUnion.ProjectLighthouse.Types.Misc; namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Types.Categories; @@ -16,13 +17,24 @@ public class MostHeartedCategory : SlotCategory public override string IconHash { get; set; } = "g820607"; public override string Endpoint { get; set; } = "most_hearted"; public override string Tag => "most_hearted"; + public override string[] Sorts { get; } = + { + "hearts", + "likes", + "plays", + }; + public override CategoryDefaults? DefaultFilters { get; } = new() + { + DateFilterType = "thisMonth", + }; + public override bool DefaultToCurrentGame => false; public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) => database.Slots.Select(s => new SlotMetadata - { - Slot = s, - Hearts = database.HeartedLevels.Count(r => r.SlotId == s.SlotId), - }) + { + Slot = s, + Hearts = database.HeartedLevels.Count(r => r.SlotId == s.SlotId), + }) .ApplyOrdering(new SlotSortBuilder().AddSort(new HeartsSort())) .Select(s => s.Slot) .Where(queryBuilder.Build()); diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/MostPlayedCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/MostPlayedCategory.cs index 68e725ee9..ce14e19c7 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/MostPlayedCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/MostPlayedCategory.cs @@ -3,6 +3,7 @@ using LBPUnion.ProjectLighthouse.Filter; using LBPUnion.ProjectLighthouse.Types.Entities.Level; using LBPUnion.ProjectLighthouse.Types.Entities.Token; +using LBPUnion.ProjectLighthouse.Types.Levels; namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Types.Categories; @@ -13,6 +14,16 @@ public class MostPlayedCategory : SlotCategory public override string IconHash { get; set; } = "g820608"; public override string Endpoint { get; set; } = "most_played"; public override string Tag => "most_played"; + public override string[] Sorts { get; } = + { + "plays", + }; + public override CategoryDefaults? DefaultFilters { get; } = new() + { + DateFilterType = "thisMonth", + IncludePlayed = false, + }; + public override bool DefaultToCurrentGame => false; public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) => database.Slots.Where(queryBuilder.Build()) diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/NewestLevelsCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/NewestLevelsCategory.cs index 3c86d5661..d5a465de0 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/NewestLevelsCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/NewestLevelsCategory.cs @@ -10,13 +10,17 @@ namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Types.Categories; public class NewestLevelsCategory : SlotCategory { - public override string Name { get; set; } = "Newest Levels"; - public override string Description { get; set; } = "The most recently published content"; - public override string IconHash { get; set; } = "g820623"; - public override string Endpoint { get; set; } = "newest"; - public override string Tag => "newest"; + public override string Name { get; set; } = "Newest Levels"; + public override string Description { get; set; } = "The most recently published content"; + public override string IconHash { get; set; } = "g820623"; + public override string Endpoint { get; set; } = "newest"; + public override string Tag => "newest"; + public override string[] Sorts { get; } = + { + "date" + }; - public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) => - database.Slots.Where(queryBuilder.Build()) - .ApplyOrdering(new SlotSortBuilder().AddSort(new FirstUploadedSort())); + public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) => + database.Slots.Where(queryBuilder.Build()) + .ApplyOrdering(new SlotSortBuilder().AddSort(new FirstUploadedSort())); } \ No newline at end of file diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/QueueCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/QueueCategory.cs index 28c5e2711..006f57f16 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/QueueCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/QueueCategory.cs @@ -13,6 +13,10 @@ public class QueueCategory : SlotCategory public override string IconHash { get; set; } = "g820614"; public override string Endpoint { get; set; } = "queue"; public override string Tag => "my_queue"; + public override string[] Sorts { get; } = + { + "relevance" + }; public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) => database.QueuedLevels.Where(q => q.UserId == token.UserId) diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/RecentlyPlayedCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/RecentlyPlayedCategory.cs index 67b34ee13..c5f3719b9 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/RecentlyPlayedCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/RecentlyPlayedCategory.cs @@ -21,6 +21,10 @@ public class RecentlyPlayedCategory : SlotCategory public override string IconHash { get; set; } = "g820616"; public override string Endpoint { get; set; } = "recently_played"; public override string Tag => "my_recently_played"; + public override string[] Sorts { get; } = + { + "relevance", + }; public override IQueryable GetItems( DatabaseContext database, diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/TeamPicksCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/TeamPicksCategory.cs index 606557a77..d7070c264 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/TeamPicksCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/TeamPicksCategory.cs @@ -16,6 +16,8 @@ public class TeamPicksCategory : SlotCategory public override string IconHash { get; set; } = "g820626"; public override string Endpoint { get; set; } = "team_picks"; public override string Tag => "team_picks"; + public override bool Curated => true; + public override bool DefaultToCurrentGame => false; public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) => database.Slots.Where(queryBuilder.Clone().AddFilter(new TeamPickFilter()).Build()) diff --git a/ProjectLighthouse.Servers.GameServer/Types/MyPlaylistsCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/MyPlaylistsCategory.cs index db80abe0a..fbe09bf3e 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/MyPlaylistsCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/MyPlaylistsCategory.cs @@ -13,6 +13,10 @@ public class MyPlaylistsCategory : PlaylistCategory public override string Endpoint { get; set; } = "my_playlists"; public override string Tag => "my_playlists"; public override string[] Types { get; } = { "playlist", }; + public override string[] Sorts { get; } = + { + "relevance" + }; public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token) => database.Playlists.Where(p => p.CreatorId == token.UserId).OrderByDescending(p => p.PlaylistId); diff --git a/ProjectLighthouse/ProjectLighthouse.csproj b/ProjectLighthouse/ProjectLighthouse.csproj index e157904d9..7b41ccc36 100644 --- a/ProjectLighthouse/ProjectLighthouse.csproj +++ b/ProjectLighthouse/ProjectLighthouse.csproj @@ -15,7 +15,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/ProjectLighthouse/Types/Levels/Category.cs b/ProjectLighthouse/Types/Levels/Category.cs index c18a4a00a..9d0a10674 100644 --- a/ProjectLighthouse/Types/Levels/Category.cs +++ b/ProjectLighthouse/Types/Levels/Category.cs @@ -18,12 +18,22 @@ public abstract class Category public abstract string Endpoint { get; set; } - public string[] Sorts { get; } = { "relevance", "likes", "plays", "hearts", "date", }; + public virtual string[] Sorts { get; } = { "relevance", "likes", "plays", "hearts", "date", }; public abstract string[] Types { get; } public abstract string Tag { get; } + public virtual bool Curated => false; + + public virtual bool DisableFilters => false; + + public virtual CategoryDefaults? DefaultFilters => null; + + public virtual string? Param => null; + + public virtual bool DefaultToCurrentGame => true; + public string IngameEndpoint => $"/searches/{this.Endpoint}"; public virtual Task Serialize(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder, int numResults = 1) => diff --git a/ProjectLighthouse/Types/Levels/CategoryDefaults.cs b/ProjectLighthouse/Types/Levels/CategoryDefaults.cs new file mode 100644 index 000000000..e300bd82f --- /dev/null +++ b/ProjectLighthouse/Types/Levels/CategoryDefaults.cs @@ -0,0 +1,32 @@ +#nullable enable +using System.Xml.Serialization; + +namespace LBPUnion.ProjectLighthouse.Types.Levels; + +public class CategoryDefaults +{ + [XmlElement("gameFilter")] + public string? GameFilter { get; set; } + + [XmlElement("dateFilterType")] + public string? DateFilterType { get; set; } + + [XmlElement("includePlayed")] + public bool? IncludePlayed { get; set; } + + [XmlElement("teamPicked")] + public string? TeamPicked { get; set; } + + [XmlElement("blacklisted")] + public string? Blacklisted { get; set; } + + public bool ShouldSerializeGameFilter() => !string.IsNullOrWhiteSpace(this.GameFilter); + + public bool ShouldSerializeDateFilterType() => !string.IsNullOrWhiteSpace(this.DateFilterType); + + public bool ShouldSerializeIncludePlayed() => this.IncludePlayed.HasValue; + + public bool ShouldSerializeTeamPicked() => !string.IsNullOrWhiteSpace(this.TeamPicked); + + public bool ShouldSerializeBlacklisted() => !string.IsNullOrWhiteSpace(this.Blacklisted); +} \ No newline at end of file diff --git a/ProjectLighthouse/Types/Serialization/GameCategory.cs b/ProjectLighthouse/Types/Serialization/GameCategory.cs index e097de974..881e12f53 100644 --- a/ProjectLighthouse/Types/Serialization/GameCategory.cs +++ b/ProjectLighthouse/Types/Serialization/GameCategory.cs @@ -1,5 +1,7 @@ -using System.ComponentModel; +#nullable enable +using System.ComponentModel; using System.Xml.Serialization; +using JetBrains.Annotations; using LBPUnion.ProjectLighthouse.Types.Levels; namespace LBPUnion.ProjectLighthouse.Types.Serialization; @@ -9,46 +11,66 @@ public class GameCategory : ILbpSerializable { [XmlElement("name")] [DefaultValue("")] - public string Name { get; set; } + public string Name { get; set; } = string.Empty; [XmlElement("description")] [DefaultValue("")] - public string Description { get; set; } + public string Description { get; set; } = string.Empty; [XmlElement("url")] - public string Url { get; set; } + public string Url { get; set; } = string.Empty; + + [XmlElement("tag")] + public string Tag { get; set; } = string.Empty; [XmlElement("icon")] [DefaultValue("")] - public string Icon { get; set; } + public string Icon { get; set; } = string.Empty; + + [XmlElement("curated")] + public bool Curated { get; set; } + + [XmlElement("disableFilters")] + public bool DisableFilters { get; set; } [DefaultValue("")] [XmlArray("sorts")] [XmlArrayItem("sort")] - public string[] Sorts { get; set; } + public string[] Sorts { get; set; } = []; [DefaultValue("")] [XmlArray("types")] [XmlArrayItem("type")] - public string[] Types { get; set; } + public string[] Types { get; set; } = []; - [XmlElement("tag")] - public string Tag { get; set; } + //This will likely be used in the future if Companion Capers ever get added in LBP3 + [XmlElement("param")] + public string? Param { get; set; } + + public bool ShouldSerializeParam() => !string.IsNullOrWhiteSpace(this.Param); + + [XmlElement("defaultFilters")] + public CategoryDefaults? DefaultFilters { get; set; } + + public bool ShouldSerializeDefaultFilters() => this.DefaultFilters is not null; [DefaultValue(null)] [XmlElement("results")] public GenericSerializableList? Results { get; set; } - public static GameCategory CreateFromEntity(Category category, GenericSerializableList? results) => - new() - { - Name = category.Name, - Description = category.Description, - Icon = category.IconHash, - Url = category.IngameEndpoint, - Sorts = category.Sorts, - Types = category.Types, - Tag = category.Tag, - Results = results, - }; + public static GameCategory CreateFromEntity(Category category, GenericSerializableList? results) => new() + { + Name = category.Name, + Description = category.Description, + Icon = category.IconHash, + Url = category.IngameEndpoint, + Sorts = category.Sorts, + Types = category.Types, + Tag = category.Tag, + Curated = category.Curated, + DisableFilters = category.DisableFilters, + Param = category.Param, + DefaultFilters = category.DefaultFilters, + Results = results, + }; } \ No newline at end of file From d4bf4d356eb5ec75295288b01d480c9844fd619b Mon Sep 17 00:00:00 2001 From: W0lf4llo <96880844+W0lf4llo@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:00:09 -0400 Subject: [PATCH 08/15] Change recently played slot limit to 30 Increased the limit of recently played slots from 20 to 30. --- .../Types/Categories/RecentlyPlayedCategory.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/RecentlyPlayedCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/RecentlyPlayedCategory.cs index c5f3719b9..7c50bbbac 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/RecentlyPlayedCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/RecentlyPlayedCategory.cs @@ -39,7 +39,7 @@ public override IQueryable GetItems( return database.Slots.Where(_ => false); List slotIds = recentlyPlayed.SlotIds - .Take(20) + .Take(30) .ToList(); ParameterExpression slotParameter = @@ -73,4 +73,4 @@ public override IQueryable GetItems( .Where(queryBuilder.Build()) .OrderBy(ordering); } -} \ No newline at end of file +} From d0048c844815d1bd7ca5e49ef7642608f5a2170d Mon Sep 17 00:00:00 2001 From: W0lf4llo <96880844+W0lf4llo@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:03:46 -0400 Subject: [PATCH 09/15] Updated level slots from 20 to 30 --- ProjectLighthouse/Database/DatabaseContext.Slots.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ProjectLighthouse/Database/DatabaseContext.Slots.cs b/ProjectLighthouse/Database/DatabaseContext.Slots.cs index b1af81cfa..bd568766c 100644 --- a/ProjectLighthouse/Database/DatabaseContext.Slots.cs +++ b/ProjectLighthouse/Database/DatabaseContext.Slots.cs @@ -132,17 +132,17 @@ public async Task RecordRecentlyPlayedLevel(int userId, int slotId, bool saveCha recentlyPlayed.SlotIds.Insert(0, slotId); recentlyPlayed.LastPlayedAt.Insert(0, now); - //Max of 20 levels - if (recentlyPlayed.SlotIds.Count > 20) + //Max of 30 levels + if (recentlyPlayed.SlotIds.Count > 30) { - recentlyPlayed.SlotIds.RemoveRange(20, recentlyPlayed.SlotIds.Count - 20); + recentlyPlayed.SlotIds.RemoveRange(30, recentlyPlayed.SlotIds.Count - 30); } - if (recentlyPlayed.LastPlayedAt.Count > 20) + if (recentlyPlayed.LastPlayedAt.Count > 30) { - recentlyPlayed.LastPlayedAt.RemoveRange(20, recentlyPlayed.LastPlayedAt.Count - 20); + recentlyPlayed.LastPlayedAt.RemoveRange(30, recentlyPlayed.LastPlayedAt.Count - 30); } if (saveChanges)await this.SaveChangesAsync(); } -} \ No newline at end of file +} From 93c0c28386f9d8a145d6d5d502fd2b4c179da4fb Mon Sep 17 00:00:00 2001 From: W0lf4llo <96880844+W0lf4llo@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:32:32 -0400 Subject: [PATCH 10/15] Update ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs Co-authored-by: FeTetra <166051662+FeTetra@users.noreply.github.com> --- .../Controllers/Matching/MatchController.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs index 5684495b7..00842e3df 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs @@ -33,10 +33,7 @@ public MatchController(DatabaseContext database) [HttpPost("gameState")] [Produces("text/plain")] - public IActionResult GameState() - { - return this.Ok("VALID"); - } + public IActionResult GameState() => this.Ok("VALID"); [HttpPost("match")] [Produces("text/plain")] From 1c6cf139e60206365daf928a484b0ccac88c9cef Mon Sep 17 00:00:00 2001 From: W0lf4llo <96880844+W0lf4llo@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:32:43 -0400 Subject: [PATCH 11/15] Update ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs Co-authored-by: FeTetra <166051662+FeTetra@users.noreply.github.com> --- .../Controllers/Matching/MatchController.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs index 00842e3df..2b3b295b9 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Matching/MatchController.cs @@ -14,7 +14,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -using LBPUnion.ProjectLighthouse.Types.Users; namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Controllers.Matching; From e76d59430ba83990f9ba9c6fe3deb80fd7786a67 Mon Sep 17 00:00:00 2001 From: W0lf4llo <96880844+W0lf4llo@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:32:57 -0400 Subject: [PATCH 12/15] Update ProjectLighthouse.Servers.GameServer/Types/Categories/NewestLevelsCategory.cs Co-authored-by: FeTetra <166051662+FeTetra@users.noreply.github.com> --- .../Types/Categories/NewestLevelsCategory.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/NewestLevelsCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/NewestLevelsCategory.cs index d5a465de0..ba00bd0a9 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/NewestLevelsCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/NewestLevelsCategory.cs @@ -10,14 +10,14 @@ namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Types.Categories; public class NewestLevelsCategory : SlotCategory { - public override string Name { get; set; } = "Newest Levels"; - public override string Description { get; set; } = "The most recently published content"; - public override string IconHash { get; set; } = "g820623"; - public override string Endpoint { get; set; } = "newest"; - public override string Tag => "newest"; - public override string[] Sorts { get; } = - { - "date" + public override string Name { get; set; } = "Newest Levels"; + public override string Description { get; set; } = "The most recently published content"; + public override string IconHash { get; set; } = "g820623"; + public override string Endpoint { get; set; } = "newest"; + public override string Tag => "newest"; + public override string[] Sorts { get; } = + { + "date" }; public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) => From 5d4f3d50fba3af7e416ddd73b9a6fc015ab36533 Mon Sep 17 00:00:00 2001 From: W0lf4llo <96880844+W0lf4llo@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:04:42 -0400 Subject: [PATCH 13/15] Update ProjectLighthouse.Servers.GameServer/Types/Categories/NewestLevelsCategory.cs Co-authored-by: FeTetra <166051662+FeTetra@users.noreply.github.com> --- .../Types/Categories/NewestLevelsCategory.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/NewestLevelsCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/NewestLevelsCategory.cs index ba00bd0a9..2870120e4 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/NewestLevelsCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/NewestLevelsCategory.cs @@ -20,7 +20,7 @@ public class NewestLevelsCategory : SlotCategory "date" }; - public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) => - database.Slots.Where(queryBuilder.Build()) - .ApplyOrdering(new SlotSortBuilder().AddSort(new FirstUploadedSort())); + public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) => + database.Slots.Where(queryBuilder.Build()) + .ApplyOrdering(new SlotSortBuilder().AddSort(new FirstUploadedSort())); } \ No newline at end of file From b05116951c5cfd17d87b0b1f512baa877ddd5edb Mon Sep 17 00:00:00 2001 From: Tyler Ruark Date: Tue, 25 Aug 2026 01:54:43 -0400 Subject: [PATCH 14/15] Followed feedback, added category config --- .../Controllers/Slots/CategoryController.cs | 11 +- .../Types/Categories/CategoryHelper.cs | 43 +- .../Types/Categories/RecommendedCategory.cs | 375 ++++++------------ .../Configuration/CategoryConfiguration.cs | 56 +++ 4 files changed, 220 insertions(+), 265 deletions(-) create mode 100644 ProjectLighthouse/Configuration/CategoryConfiguration.cs diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/CategoryController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/CategoryController.cs index dfea3c908..c1429a65a 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/CategoryController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/CategoryController.cs @@ -97,16 +97,13 @@ public async Task GetCategorySlots(string endpointName) private async Task GetRecommendedCategory(RecommendedCategory recommendedCategory, GameTokenEntity token, SlotQueryBuilder queryBuilder, PaginationData pageData) { - List recommendations = await recommendedCategory.GetScoredItems(this.database, token, queryBuilder); + IQueryable recommendations = recommendedCategory.GetScoredItems(this.database, token, queryBuilder); - pageData.TotalElements = recommendations.Count; + pageData.TotalElements = await recommendations.CountAsync(); - List page = recommendations - .AsQueryable() - .ApplyPagination(pageData) - .ToList(); + recommendations = recommendations.ApplyPagination(pageData); - List slots = page + List slots = (await recommendations.ToListAsync()) .Select(recommendation => RecommendedCategory.CreateSerializableSlot(recommendation, token)) .ToList(); diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs index 4432f7704..ac388c50f 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs @@ -1,6 +1,7 @@ using LBPUnion.ProjectLighthouse.Database; using LBPUnion.ProjectLighthouse.Types.Entities.Level; using LBPUnion.ProjectLighthouse.Types.Levels; +using LBPUnion.ProjectLighthouse.Configuration; namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Types.Categories; @@ -10,21 +11,35 @@ public static class CategoryHelper static CategoryHelper() { - Categories.Add(new RecentlyPlayedCategory()); - Categories.Add(new RecommendedCategory()); - Categories.Add(new TeamPicksCategory()); - Categories.Add(new MostHeartedCategory()); - Categories.Add(new NewestLevelsCategory()); - Categories.Add(new BusiestCategory()); - Categories.Add(new MostPlayedCategory()); - Categories.Add(new MyPlaylistsCategory()); - Categories.Add(new QueueCategory()); - Categories.Add(new HeartedCategory()); - Categories.Add(new HighestRatedCategory()); - Categories.Add(new LuckyDipCategory()); - Categories.Add(new TextSearchCategory()); + Dictionary> availableCategories = new() + { + ["recently_played"] = () => new RecentlyPlayedCategory(), + ["recommended"] = () => new RecommendedCategory(), + ["team_picks"] = () => new TeamPicksCategory(), + ["most_hearted"] = () => new MostHeartedCategory(), + ["newest"] = () => new NewestLevelsCategory(), + ["busiest"] = () => new BusiestCategory(), + ["most_played"] = () => new MostPlayedCategory(), + ["my_playlists"] = () => new MyPlaylistsCategory(), + ["favourite_creators"] = () => new MyHeartedCreatorsCategory(), + ["queue"] = () => new QueueCategory(), + ["hearted_levels"] = () => new HeartedCategory(), + ["highest_rated"] = () => new HighestRatedCategory(), + ["lucky_dip"] = () => new LuckyDipCategory(), + }; + + foreach (string categoryName in CategoryConfiguration.Instance.OrderOfCategory) + { + if (CategoryConfiguration.Instance.DisabledCategories.Contains(categoryName)) + continue; + if (availableCategories.TryGetValue(categoryName, out Func? categoryCreator)) + Categories.Add(categoryCreator()); + } + + Categories.Add(new TextSearchCategory()); using DatabaseContext database = DatabaseContext.CreateNewInstance(); - foreach (DatabaseCategoryEntity category in database.CustomCategories) Categories.Add(new CustomCategory(category)); + foreach (DatabaseCategoryEntity category in database.CustomCategories) + Categories.Add(new CustomCategory(category)); } } \ No newline at end of file diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/RecommendedCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/RecommendedCategory.cs index 8e576f413..7f2208ebe 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/RecommendedCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/RecommendedCategory.cs @@ -3,303 +3,190 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Database; using LBPUnion.ProjectLighthouse.Filter; using LBPUnion.ProjectLighthouse.Types.Entities.Level; using LBPUnion.ProjectLighthouse.Types.Entities.Token; -using LBPUnion.ProjectLighthouse.Types.Levels; using LBPUnion.ProjectLighthouse.Types.Serialization; using Microsoft.EntityFrameworkCore; namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Types.Categories; -public class RecommendedCategory : Category +public class RecommendedCategory : SlotCategory { - //MaxNeighbors essentially sets a cap to the number of users who have a similar taste to what you have for the recommendation algorithm - //This also ensures that Lighthouse doesnt completely die when it tries to find good recommendations. - private const int MaxNeighbors = 250; - //MinimumNeighborOverlap is the minimum number of hearted levels required by another player before they're considered to have a "good taste" as you - //This is set at 2 levels, so if you and another player have 2 hearted levels that are the same, you'll see more similar results. - private const int MinimumNeighborOverlap = 2; - //This is the maximum number of levels that can be reviewed during a single recommendation request. - private const int MaxCandidatePool = 1000; public override string Name { get; set; } = "Recommended For You"; public override string Description { get; set; } = "Stuff we think you'll like"; public override string IconHash { get; set; } = "g820625"; public override string Endpoint { get; set; } = "recommended"; public override string Tag => "recommended"; - public override string[] Types { get; } = + public sealed class ScoredSlot { - "slot", - "adventure", - }; - - //This represents one recommendation after the algorithm calculates how relevant it is to the user - //The PrevSearchScore here represents the first-stage of the users recommendation score - //It goes up when a hearted user hearts a level - //The SearchScore is the final score after the recommendation algorithm, - //Mostly from users with a similar taste. - public sealed record ScoredSlot(SlotEntity Slot, double SearchScore, double PrevSearchScore, int Hearts, int Likes); + public SlotEntity Slot { get; set; } = null!; + public int SearchScore { get; set; } + public int PrevSearchScore { get; set; } + public int Hearts { get; set; } + public int Likes { get; set; } + } - //This will generate a more personalized recommendation tab based on the user.. - //It'll firstly look at the levels hearted by players that you currently heart.. - //This will become the PrevSearchScore. - // - //Then it finds users with a similar taste, look at levels they heartedm and it'll contribute to the SearchScore. - public async Task> GetScoredItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) + public IQueryable GetScoredItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) { - //Gets the players that the user currently has hearted, this is REQUIRED if the tag 'recommended' is used. - List seedUserIds = await database.HeartedProfiles - .AsNoTracking() - .Where(h => h.UserId == token.UserId) - .Select(h => h.HeartedUserId) - .Distinct() - .ToListAsync(); + RecommendedCategoryConfig config = CategoryConfiguration.Instance.Recommended; -if (seedUserIds.Count == 0) -{ - return new List(); -} + IQueryable seedUserIds = database.HeartedProfiles + .AsNoTracking() + .Where(heartedProfile => heartedProfile.UserId == token.UserId) + .Select(heartedProfile => heartedProfile.HeartedUserId) + .Distinct(); - //Finds levels hearted by the players you hearted - //Each hearted player will act as a recommendation source for you. - //OPTIMIZATION - I made it so that the grouping is done via the database and not within Lighthouse - //It loads every heart into memory on the servers with a lot of data - var directScoreRows = await database.HeartedLevels + IQueryable seedTasteSlotIds = database.HeartedLevels .AsNoTracking() - .Where(h => seedUserIds.Contains(h.UserId)) - .GroupBy(h => h.SlotId) + .Where(heartedLevel => seedUserIds.Contains(heartedLevel.UserId)) + .Select(heartedLevel => heartedLevel.SlotId) + .Distinct(); + + IQueryable neighborUserIds = database.HeartedLevels + .AsNoTracking() + .Where(heartedLevel => seedTasteSlotIds.Contains(heartedLevel.SlotId)) + .Where(heartedLevel => heartedLevel.UserId != token.UserId && !seedUserIds.Contains(heartedLevel.UserId)) + .GroupBy(heartedLevel => heartedLevel.UserId) .Select(group => new { - SlotId = group.Key, - Score = group - .Select(h => h.UserId) + UserId = group.Key, + Overlap = group + .Select(heartedLevel => heartedLevel.SlotId) .Distinct() .Count(), }) - .OrderByDescending(result => result.Score) - .ThenBy(result => result.SlotId) - .Take(MaxCandidatePool) - .ToListAsync(); - - Dictionary directScores = directScoreRows.ToDictionary(result => result.SlotId, result => result.Score); - - List seedTasteSlotIds = directScores.Keys.ToList(); - - //This will find users who seem to have a similar taste to you, it'll look for several hearts of the same levels - Dictionary neighborScores = new(); - - if (seedTasteSlotIds.Count > 0) - { - var neighbors = await database.HeartedLevels - .AsNoTracking() - .Where(h => seedTasteSlotIds.Contains(h.SlotId)) - .Where(h => - h.UserId != token.UserId && - !seedUserIds.Contains(h.UserId)) - .GroupBy(h => h.UserId) - .Select(group => new - { - UserId = group.Key, - - //Number of levels the user shares with the use person who hearted them. - Overlap = group - .Select(h => h.SlotId) - .Distinct() - .Count(), - }) - .Where(user => user.Overlap >= MinimumNeighborOverlap) - .OrderByDescending(user => user.Overlap) - .ThenBy(user => user.UserId) - .Take(MaxNeighbors) - .ToListAsync(); - - List neighborIds = neighbors - .Select(n => n.UserId) - .ToList(); + .Where(user => user.Overlap >= config.MinimumNeighborOverlap) + .OrderByDescending(user => user.Overlap) + .ThenBy(user => user.UserId) + .Take(config.MaxNeighbors) + .Select(user => user.UserId); - //Finds hearted levels by users of the same taste - if (neighborIds.Count > 0) - { - var neighborScoreRows = await database.HeartedLevels - .AsNoTracking() - .Where(h => neighborIds.Contains(h.UserId)) - .GroupBy(h => h.SlotId) - .Select(group => new - { - SlotId = group.Key, - - Score = group - .Select(h => h.UserId) - .Distinct() - .Count(), - }) - .OrderByDescending(result => result.Score) - .Take(MaxCandidatePool) - .ToListAsync(); - neighborScores = neighborScoreRows - .ToDictionary( - result => result.SlotId, - result => result.Score); - } - } - - //User that hearts a player may also sugguest that they'd want to see levels created by them. - List heartedCreatorSlotIds = await database.Slots + var directContributions = database.HeartedLevels .AsNoTracking() - .Where(s => seedUserIds.Contains(s.CreatorId)) - .Where(queryBuilder.Build()) - .Where(s => !database.VisitedLevels.Any(v => v.UserId == token.UserId && v.SlotId == s.SlotId)) - .OrderByDescending(s => s.SlotId) - .Select(s => s.SlotId) - .Take(MaxCandidatePool) - .ToListAsync(); - - HashSet heartedCreatorSlotSet = heartedCreatorSlotIds.ToHashSet(); - - //Combines 3 sources for recommendation candidates.. - //Sources are levels hearted by players that the user hearts - //Levels hearted by your 'taste neighbors' - //Abd keveks created directly by players you heart - HashSet allCandidateIds = directScores.Keys - .Concat(neighborScores.Keys) - .Concat(heartedCreatorSlotIds) - .ToHashSet(); - if (allCandidateIds.Count == 0) - { - return new List(); - } - - //Calculates a score before loading all the level metadata. - //PrevSearchScore - number of directly hearted players that support a level - //SearchScore - taste neighbors and added points for directly hearted creator. - List candidateIds = allCandidateIds - .Select(slotId => + .Where(heartedLevel => seedUserIds.Contains(heartedLevel.UserId)) + .Select(heartedLevel => new { - int prevSearchScore = directScores.GetValueOrDefault(slotId); - - int neighborScore = neighborScores.GetValueOrDefault(slotId); - - int creatorBonus = heartedCreatorSlotSet.Contains(slotId) - ? 1 - : 0; - - int searchScore = - prevSearchScore + - neighborScore + - creatorBonus; - - return new - { - SlotId = slotId, - SearchScore = searchScore, - PrevSearchScore = prevSearchScore, - }; + heartedLevel.SlotId, + heartedLevel.UserId, }) - .OrderByDescending(result => result.SearchScore) - .ThenByDescending(result => result.PrevSearchScore) - .ThenBy(result => result.SlotId) - .Take(MaxCandidatePool) - .Select(result => result.SlotId) - .ToList(); - - //Loads the levels and applies the standard slot filters, and ensures that your played levels are removed here - List slots = await database.Slots - .AsNoTracking() - .Where(s => candidateIds.Contains(s.SlotId)) - .Where(queryBuilder.Build()) - .Where(s => - !database.VisitedLevels.Any(v => - v.UserId == token.UserId && - v.SlotId == s.SlotId)) - .ToListAsync(); - - if (slots.Count == 0) - { - return new List(); - } + .Distinct() + .Select(heartedLevel => new + { + heartedLevel.SlotId, + SearchScore = 1, + PrevSearchScore = 1, + }); + + var neighborContributions = + from heartedLevel in database.HeartedLevels.AsNoTracking() + join neighborUserId in neighborUserIds + on heartedLevel.UserId equals neighborUserId + select new + { + heartedLevel.SlotId, + heartedLevel.UserId, + }; - List validSlotIds = slots - .Select(s => s.SlotId) - .ToList(); + var distinctNeighborContributions = neighborContributions + .Distinct() + .Select(heartedLevel => new + { + heartedLevel.SlotId, + SearchScore = 1, + PrevSearchScore = 0, + }); - //Hearts and likes are used ONLY to break a tie between levels - Dictionary heartCounts = await database.HeartedLevels + var creatorContributions = database.Slots .AsNoTracking() - .Where(h => validSlotIds.Contains(h.SlotId)) - .GroupBy(h => h.SlotId) - .ToDictionaryAsync( - group => group.Key, - group => group.Count()); + .Where(slot => seedUserIds.Contains(slot.CreatorId)) + .Select(slot => new + { + slot.SlotId, + SearchScore = 1, + PrevSearchScore = 0, + }); + + var scores = directContributions + .Concat(distinctNeighborContributions) + .Concat(creatorContributions) + .GroupBy(contribution => contribution.SlotId) + .Select(group => new + { + SlotId = group.Key, + SearchScore = group.Sum(contribution => contribution.SearchScore), + PrevSearchScore = group.Sum(contribution => contribution.PrevSearchScore), + }); - Dictionary likeCounts = await database.RatedLevels - .AsNoTracking() - .Where(r => - validSlotIds.Contains(r.SlotId) && - r.Rating == 1) - .GroupBy(r => r.SlotId) - .ToDictionaryAsync( - group => group.Key, - group => group.Count()); + IQueryable recommendations = + from slot in database.Slots + .AsNoTracking() + .Where(queryBuilder.Build()) + .Where(slot => !database.VisitedLevels.Any(visitedLevel => + visitedLevel.UserId == token.UserId && + visitedLevel.SlotId == slot.SlotId)) - //This produces the final recommendation result objects. - List recommendations = slots - .Select(slot => - { - int prevSearchScore = directScores.GetValueOrDefault(slot.SlotId); + join score in scores + on slot.SlotId equals score.SlotId - int neighborScore = neighborScores.GetValueOrDefault(slot.SlotId); + let hearts = database.HeartedLevels.Count(heartedLevel => + heartedLevel.SlotId == slot.SlotId) - int creatorBonus = seedUserIds.Contains(slot.CreatorId) - ? 1 - : 0; + let likes = database.RatedLevels.Count(rating => + rating.SlotId == slot.SlotId && + rating.Rating == 1) - int searchScore = - prevSearchScore + - neighborScore + - creatorBonus; + orderby + score.SearchScore descending, + score.PrevSearchScore descending, + hearts descending, + likes descending, + slot.SlotId descending - return new ScoredSlot(slot, searchScore, prevSearchScore, heartCounts.GetValueOrDefault(slot.SlotId), likeCounts.GetValueOrDefault(slot.SlotId)); - }) - .Where(result => result.SearchScore > 0) - .OrderByDescending(result => result.SearchScore) - .ThenByDescending(result => result.PrevSearchScore) - .ThenByDescending(result => result.Hearts) - .ThenByDescending(result => result.Likes) - .ThenByDescending(result => result.Slot.SlotId) - .ToList(); + select new ScoredSlot + { + Slot = slot, + SearchScore = score.SearchScore, + PrevSearchScore = score.PrevSearchScore, + Hearts = hearts, + Likes = likes, + }; return recommendations; } - //Converts the scored recommendation into a regular slot. - public static ILbpSerializable CreateSerializableSlot(ScoredSlot recommendation, GameTokenEntity token) -{ - SlotBase serialized = SlotBase.CreateFromEntity(recommendation.Slot, token); + public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) => + this.GetScoredItems(database, token, queryBuilder) + .Select(recommendation => recommendation.Slot); - if (serialized is GameUserSlot userSlot) + public static ILbpSerializable CreateSerializableSlot(ScoredSlot recommendation, GameTokenEntity token) { - userSlot.SearchScore = recommendation.SearchScore; + SlotBase serialized = SlotBase.CreateFromEntity(recommendation.Slot, token); - userSlot.PrevSearchScore = recommendation.PrevSearchScore; - } + if (serialized is GameUserSlot userSlot) + { + userSlot.SearchScore = recommendation.SearchScore; + userSlot.PrevSearchScore = recommendation.PrevSearchScore; + } - return serialized; -} + return serialized; + } public override async Task Serialize(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder, int numResults = 1) { - List recommendations = await this.GetScoredItems(database, token, queryBuilder); + IQueryable recommendations = this.GetScoredItems(database, token, queryBuilder); - List serializedSlots = recommendations + List serializedSlots = (await recommendations .Take(numResults) - .Select(recommendation => - CreateSerializableSlot( - recommendation, - token)) - .ToList(); + .ToListAsync()) + .Select(recommendation => CreateSerializableSlot(recommendation, token)) + .ToList(); + + int totalSlots = await recommendations.CountAsync(); - return GameCategory.CreateFromEntity(this, new GenericSerializableList(serializedSlots, recommendations.Count, numResults + 1)); + return GameCategory.CreateFromEntity(this, new GenericSerializableList(serializedSlots, totalSlots, numResults + 1)); } } \ No newline at end of file diff --git a/ProjectLighthouse/Configuration/CategoryConfiguration.cs b/ProjectLighthouse/Configuration/CategoryConfiguration.cs new file mode 100644 index 000000000..f2c479be7 --- /dev/null +++ b/ProjectLighthouse/Configuration/CategoryConfiguration.cs @@ -0,0 +1,56 @@ +#nullable enable +using System.Collections.Generic; +using YamlDotNet.Serialization; + +namespace LBPUnion.ProjectLighthouse.Configuration; + +public class CategoryConfiguration : ConfigurationBase +{ + // HEY, YOU! + // THIS VALUE MUST BE INCREMENTED FOR EVERY CONFIG CHANGE! + // + // This is so Lighthouse can properly identify outdated configurations and update them with newer settings accordingly. + // If you are modifying anything here, this value MUST be incremented. + // Thanks for listening~ + public override int ConfigVersion { get; set; } = 1; + public override string ConfigName { get; set; } = "CategoryConfig.yml"; + public override bool NeedsConfiguration { get; set; } = false; + + public List OrderOfCategory { get; set; } = new() + { + "recently_played", + "recommended", + "team_picks", + "most_hearted", + "newest", + "busiest", + "most_played", + "my_playlists", + "favourite_creators", + "queue", + "hearted_levels", + "highest_rated", + "lucky_dip", + }; + + public List DisabledCategories { get; set; } = new() + { + "favourite_creators", + }; + + public RecommendedCategoryConfig Recommended { get; set; } = new(); + public RecentlyPlayedConfig RecentlyPlayed { get; set; } = new(); + public override ConfigurationBase Deserialize(IDeserializer deserializer, string text) => deserializer.Deserialize(text); +} + +public class RecommendedCategoryConfig +{ + public int MaxNeighbors { get; set; } = 250; + public int MinimumNeighborOverlap { get; set; } = 2; + public int MaxCandidatePool { get; set; } = 1000; +} + +public class RecentlyPlayedConfig +{ + public int MaxLevels { get; set; } = 30; +} \ No newline at end of file From aeab303ee5a48510fe45694dc3383b6b4f07f34e Mon Sep 17 00:00:00 2001 From: Tyler Ruark Date: Wed, 26 Aug 2026 19:18:49 -0400 Subject: [PATCH 15/15] Time to break prod! (I believe i fixed it all) --- .../Matching/EnterLevelController.cs | 6 +- .../Controllers/Slots/CategoryController.cs | 3 +- .../Extensions/ControllerExtensions.cs | 2 +- .../Startup/GameServerStartup.cs | 2 + .../Types/Categories/BusiestCategory.cs | 8 +- .../Types/Categories/CategoryHelper.cs | 7 +- .../Types/Categories/HeartedCategory.cs | 7 +- .../Types/Categories/HighestRatedCategory.cs | 17 +- .../Types/Categories/LuckyDipCategory.cs | 8 +- .../Types/Categories/MostHeartedCategory.cs | 17 +- .../Types/Categories/MostPlayedCategory.cs | 7 +- .../Types/Categories/NewestLevelsCategory.cs | 7 +- .../Types/Categories/QueueCategory.cs | 7 +- .../Categories/RecentlyPlayedCategory.cs | 66 +- .../Types/Categories/RecommendedCategory.cs | 68 +- .../Types/Categories/TeamPicksCategory.cs | 2 +- .../Types/MyPlaylistsCategory.cs | 7 +- .../Configuration/CategoryConfiguration.cs | 16 +- .../Database/DatabaseContext.Slots.cs | 60 +- ProjectLighthouse/Database/DatabaseContext.cs | 59 +- ProjectLighthouse/Helpers/RoomHelper.cs | 4 +- ...260812004441_AddRecentlyPlayed.Designer.cs | 1602 ----------------- .../20260812004441_AddRecentlyPlayed.cs | 27 +- .../DatabaseContextModelSnapshot.cs | 22 +- ProjectLighthouse/ProjectLighthouse.csproj | 2 +- .../Services/RoomPlayerCountService.cs | 17 + .../Interaction/RecentlyPlayedEntity.cs | 12 +- ProjectLighthouse/Types/Levels/Category.cs | 4 +- .../Types/Levels/CategoryDefaults.cs | 18 +- .../Types/Serialization/GameCategory.cs | 11 +- .../Types/Serialization/GameUserSlot.cs | 28 +- 31 files changed, 212 insertions(+), 1911 deletions(-) delete mode 100644 ProjectLighthouse/Migrations/20260812004441_AddRecentlyPlayed.Designer.cs create mode 100644 ProjectLighthouse/Services/RoomPlayerCountService.cs diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Matching/EnterLevelController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Matching/EnterLevelController.cs index 6bb537060..0d8038982 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Matching/EnterLevelController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Matching/EnterLevelController.cs @@ -89,9 +89,9 @@ public async Task PlayLevel(string slotType, int slotId) return this.BadRequest(); } - if (token.GameVersion == GameVersion.LittleBigPlanet3 && slotType == "user") + if (token.GameVersion == GameVersion.LittleBigPlanet3) { - await this.database.RecordRecentlyPlayedLevel(token.UserId, slotId, saveChanges: false); + await this.database.RecordRecentlyPlayedLevel(token.UserId, slotId); } await this.database.SaveChangesAsync(); @@ -139,4 +139,4 @@ public async Task EnterLevel(string slotType, int slotId) return this.Ok(); } -} \ No newline at end of file +} diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/CategoryController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/CategoryController.cs index c1429a65a..8f2fc2d11 100644 --- a/ProjectLighthouse.Servers.GameServer/Controllers/Slots/CategoryController.cs +++ b/ProjectLighthouse.Servers.GameServer/Controllers/Slots/CategoryController.cs @@ -147,7 +147,6 @@ private async Task GetSlotCategory(SlotCategory slotCat { string sort = (string?)this.Request.Query["sort"] ?? ""; - //Only accept sorts this category actually advertises. if (slotCategory.Sorts.Contains(sort)) { slotQuery = sort switch @@ -195,4 +194,4 @@ private async Task GetSlotCategory(SlotCategory slotCat return new GenericSerializableList(slots, pageData); } -} \ No newline at end of file +} diff --git a/ProjectLighthouse.Servers.GameServer/Extensions/ControllerExtensions.cs b/ProjectLighthouse.Servers.GameServer/Extensions/ControllerExtensions.cs index 090df1e45..6f7168630 100644 --- a/ProjectLighthouse.Servers.GameServer/Extensions/ControllerExtensions.cs +++ b/ProjectLighthouse.Servers.GameServer/Extensions/ControllerExtensions.cs @@ -182,4 +182,4 @@ void ParseLbp3Query(string key, Action allMust, Action noneCan, Action dontCare) return queryBuilder; } -} \ No newline at end of file +} diff --git a/ProjectLighthouse.Servers.GameServer/Startup/GameServerStartup.cs b/ProjectLighthouse.Servers.GameServer/Startup/GameServerStartup.cs index 0d372e8f1..fd20c0569 100644 --- a/ProjectLighthouse.Servers.GameServer/Startup/GameServerStartup.cs +++ b/ProjectLighthouse.Servers.GameServer/Startup/GameServerStartup.cs @@ -60,6 +60,8 @@ public void ConfigureServices(IServiceCollection services) MySqlServerVersion.LatestSupportedServerVersion); }); + services.AddScoped(); + IMailService mailService = ServerConfiguration.Instance.Mail.MailEnabled ? new MailQueueService(new SmtpMailSender()) : new NullMailService(); diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/BusiestCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/BusiestCategory.cs index f5e082fb1..e9d3e01cb 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/BusiestCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/BusiestCategory.cs @@ -15,16 +15,12 @@ public class BusiestCategory : SlotCategory public override string IconHash { get; set; } = "g820602"; public override string Endpoint { get; set; } = "busiest"; public override string Tag => "busiest"; - public override string[] Sorts { get; } = - { - "relevance", - }; + public override string[] Sorts { get; } = ["relevance",]; public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) { Dictionary playerCounts = RoomHelper.GetUserLevelPlayerCounts(); - //If nobody is inside of a user level if (playerCounts.Count == 0) return database.Slots.Where(_ => false); @@ -50,4 +46,4 @@ public override IQueryable GetItems(DatabaseContext database, GameTo .OrderByDescending(ordering) .ThenByDescending(slot => slot.SlotId); } -} \ No newline at end of file +} diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs index ac388c50f..f10677bd3 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs @@ -28,11 +28,8 @@ static CategoryHelper() ["lucky_dip"] = () => new LuckyDipCategory(), }; - foreach (string categoryName in CategoryConfiguration.Instance.OrderOfCategory) + foreach (string categoryName in CategoryConfiguration.Instance.Categories) { - if (CategoryConfiguration.Instance.DisabledCategories.Contains(categoryName)) - continue; - if (availableCategories.TryGetValue(categoryName, out Func? categoryCreator)) Categories.Add(categoryCreator()); } @@ -42,4 +39,4 @@ static CategoryHelper() foreach (DatabaseCategoryEntity category in database.CustomCategories) Categories.Add(new CustomCategory(category)); } -} \ No newline at end of file +} diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/HeartedCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/HeartedCategory.cs index a816677b0..4d44e3d18 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/HeartedCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/HeartedCategory.cs @@ -13,14 +13,11 @@ public class HeartedCategory : SlotCategory public override string IconHash { get; set; } = "g820611"; public override string Endpoint { get; set; } = "hearted_levels"; public override string Tag => "my_hearted_levels"; - public override string[] Sorts { get; } = - { - "relevance" - }; + public override string[] Sorts { get; } = ["relevance",]; public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) => database.HeartedLevels.Where(h => h.UserId == token.UserId) .OrderByDescending(h => h.HeartedLevelId) .Select(h => h.Slot) .Where(queryBuilder.Build()); -} \ No newline at end of file +} diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/HighestRatedCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/HighestRatedCategory.cs index 6beb80cf7..3e1bb9937 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/HighestRatedCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/HighestRatedCategory.cs @@ -3,8 +3,8 @@ using LBPUnion.ProjectLighthouse.Filter; using LBPUnion.ProjectLighthouse.Types.Entities.Level; using LBPUnion.ProjectLighthouse.Types.Entities.Token; -using LBPUnion.ProjectLighthouse.Types.Misc; using LBPUnion.ProjectLighthouse.Types.Levels; +using LBPUnion.ProjectLighthouse.Types.Misc; namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Types.Categories; @@ -15,10 +15,7 @@ public class HighestRatedCategory : SlotCategory public override string IconHash { get; set; } = "g820603"; public override string Endpoint { get; set; } = "thumbs"; public override string Tag => "highest_rated"; - public override string[] Sorts { get; } = - { - "likes", - }; + public override string[] Sorts { get; } = ["likes",]; public override CategoryDefaults DefaultFilters { get; } = new() { DateFilterType = "thisMonth", @@ -27,11 +24,11 @@ public class HighestRatedCategory : SlotCategory public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) => database.Slots.Select(s => new SlotMetadata - { - Slot = s, - ThumbsUp = database.RatedLevels.Count(r => r.SlotId == s.SlotId && r.Rating == 1), - }) + { + Slot = s, + ThumbsUp = database.RatedLevels.Count(r => r.SlotId == s.SlotId && r.Rating == 1), + }) .OrderByDescending(s => s.ThumbsUp) .Select(s => s.Slot) .Where(queryBuilder.Build()); -} \ No newline at end of file +} diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/LuckyDipCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/LuckyDipCategory.cs index ee7311cd7..76b3adef9 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/LuckyDipCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/LuckyDipCategory.cs @@ -16,12 +16,8 @@ public class LuckyDipCategory : SlotCategory public override string IconHash { get; set; } = "g820605"; public override string Endpoint { get; set; } = "lucky_dip"; public override string Tag => "level_of_the_day"; - public override string[] Sorts { get; } = - { - "relevance", - }; + public override string[] Sorts { get; } = ["relevance",]; public override bool Curated => false; - //The game client doesnt seem to work with this, but I'll leave it here in case its just a bug on my end in the future public override bool DisableFilters => true; public override bool DefaultToCurrentGame => false; @@ -31,4 +27,4 @@ public override IQueryable GetItems(DatabaseContext database, GameTo return database.Slots.Where(queryBuilder.Build()) .ApplyOrdering(new SlotSortBuilder().AddSort(new RandomFirstUploadedSort())); } -} \ No newline at end of file +} diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/MostHeartedCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/MostHeartedCategory.cs index 708df2adc..0185b7c3e 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/MostHeartedCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/MostHeartedCategory.cs @@ -17,12 +17,7 @@ public class MostHeartedCategory : SlotCategory public override string IconHash { get; set; } = "g820607"; public override string Endpoint { get; set; } = "most_hearted"; public override string Tag => "most_hearted"; - public override string[] Sorts { get; } = - { - "hearts", - "likes", - "plays", - }; + public override string[] Sorts { get; } = ["hearts", "likes", "plays",]; public override CategoryDefaults? DefaultFilters { get; } = new() { DateFilterType = "thisMonth", @@ -31,11 +26,11 @@ public class MostHeartedCategory : SlotCategory public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) => database.Slots.Select(s => new SlotMetadata - { - Slot = s, - Hearts = database.HeartedLevels.Count(r => r.SlotId == s.SlotId), - }) + { + Slot = s, + Hearts = database.HeartedLevels.Count(r => r.SlotId == s.SlotId), + }) .ApplyOrdering(new SlotSortBuilder().AddSort(new HeartsSort())) .Select(s => s.Slot) .Where(queryBuilder.Build()); -} \ No newline at end of file +} diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/MostPlayedCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/MostPlayedCategory.cs index ce14e19c7..427cb308f 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/MostPlayedCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/MostPlayedCategory.cs @@ -14,10 +14,7 @@ public class MostPlayedCategory : SlotCategory public override string IconHash { get; set; } = "g820608"; public override string Endpoint { get; set; } = "most_played"; public override string Tag => "most_played"; - public override string[] Sorts { get; } = - { - "plays", - }; + public override string[] Sorts { get; } = ["plays",]; public override CategoryDefaults? DefaultFilters { get; } = new() { DateFilterType = "thisMonth", @@ -29,4 +26,4 @@ public override IQueryable GetItems(DatabaseContext database, GameTo database.Slots.Where(queryBuilder.Build()) .OrderByDescending(s => s.PlaysLBP1Unique + s.PlaysLBP2Unique + s.PlaysLBP3Unique) .ThenByDescending(s => s.PlaysLBP1 + s.PlaysLBP2 + s.PlaysLBP3); -} \ No newline at end of file +} diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/NewestLevelsCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/NewestLevelsCategory.cs index 2870120e4..dd002201a 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/NewestLevelsCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/NewestLevelsCategory.cs @@ -15,12 +15,9 @@ public class NewestLevelsCategory : SlotCategory public override string IconHash { get; set; } = "g820623"; public override string Endpoint { get; set; } = "newest"; public override string Tag => "newest"; - public override string[] Sorts { get; } = - { - "date" - }; + public override string[] Sorts { get; } = ["date",]; public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) => database.Slots.Where(queryBuilder.Build()) .ApplyOrdering(new SlotSortBuilder().AddSort(new FirstUploadedSort())); -} \ No newline at end of file +} diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/QueueCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/QueueCategory.cs index 006f57f16..0cebb4e1e 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/QueueCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/QueueCategory.cs @@ -13,14 +13,11 @@ public class QueueCategory : SlotCategory public override string IconHash { get; set; } = "g820614"; public override string Endpoint { get; set; } = "queue"; public override string Tag => "my_queue"; - public override string[] Sorts { get; } = - { - "relevance" - }; + public override string[] Sorts { get; } = ["relevance",]; public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) => database.QueuedLevels.Where(q => q.UserId == token.UserId) .OrderByDescending(q => q.QueuedLevelId) .Select(q => q.Slot) .Where(queryBuilder.Build()); -} \ No newline at end of file +} diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/RecentlyPlayedCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/RecentlyPlayedCategory.cs index 7c50bbbac..f7ed47062 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/RecentlyPlayedCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/RecentlyPlayedCategory.cs @@ -1,17 +1,12 @@ #nullable enable -using System.Collections.Generic; using System.Linq; -using System.Linq.Expressions; - +using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Database; using LBPUnion.ProjectLighthouse.Filter; -using LBPUnion.ProjectLighthouse.Types.Entities.Interaction; using LBPUnion.ProjectLighthouse.Types.Entities.Level; using LBPUnion.ProjectLighthouse.Types.Entities.Token; -using Microsoft.EntityFrameworkCore; - namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Types.Categories; public class RecentlyPlayedCategory : SlotCategory @@ -21,56 +16,17 @@ public class RecentlyPlayedCategory : SlotCategory public override string IconHash { get; set; } = "g820616"; public override string Endpoint { get; set; } = "recently_played"; public override string Tag => "my_recently_played"; - public override string[] Sorts { get; } = - { - "relevance", - }; + public override string[] Sorts { get; } = ["relevance",]; - public override IQueryable GetItems( - DatabaseContext database, - GameTokenEntity token, - SlotQueryBuilder queryBuilder) + public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) { - RecentlyPlayedEntity? recentlyPlayed = database.RecentlyPlayed - .AsNoTracking() - .FirstOrDefault(r => r.UserId == token.UserId); - - if (recentlyPlayed == null || recentlyPlayed.SlotIds.Count == 0) - return database.Slots.Where(_ => false); - - List slotIds = recentlyPlayed.SlotIds - .Take(30) - .ToList(); - - ParameterExpression slotParameter = - Expression.Parameter(typeof(SlotEntity), "slot"); - - MemberExpression slotIdProperty = - Expression.Property( - slotParameter, - nameof(SlotEntity.SlotId)); - - Expression orderExpression = - Expression.Constant(slotIds.Count); - - for (int i = slotIds.Count - 1; i >= 0; i--) - { - orderExpression = Expression.Condition( - Expression.Equal( - slotIdProperty, - Expression.Constant(slotIds[i])), - Expression.Constant(i), - orderExpression); - } - - Expression> ordering = - Expression.Lambda>( - orderExpression, - slotParameter); - - return database.Slots - .Where(s => slotIds.Contains(s.SlotId)) - .Where(queryBuilder.Build()) - .OrderBy(ordering); + return ( + from recentlyPlayed in database.RecentlyPlayed + join slot in database.Slots.Where(queryBuilder.Build()) + on recentlyPlayed.SlotId equals slot.SlotId + where recentlyPlayed.UserId == token.UserId + orderby recentlyPlayed.LastPlayedAt descending + select slot + ).Take(CategoryConfiguration.Instance.RecentlyPlayed.MaxLevels); } } diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/RecommendedCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/RecommendedCategory.cs index 7f2208ebe..6d04a6bd9 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/RecommendedCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/RecommendedCategory.cs @@ -21,6 +21,13 @@ public class RecommendedCategory : SlotCategory public override string Endpoint { get; set; } = "recommended"; public override string Tag => "recommended"; + private sealed class RecommendationScore + { + public int SlotId { get; set; } + public int SearchScore { get; set; } + public int PrevSearchScore { get; set; } + } + public sealed class ScoredSlot { public SlotEntity Slot { get; set; } = null!; @@ -30,24 +37,21 @@ public sealed class ScoredSlot public int Likes { get; set; } } - public IQueryable GetScoredItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) + private IQueryable GetSearchScores(DatabaseContext database, GameTokenEntity token) { RecommendedCategoryConfig config = CategoryConfiguration.Instance.Recommended; IQueryable seedUserIds = database.HeartedProfiles - .AsNoTracking() .Where(heartedProfile => heartedProfile.UserId == token.UserId) .Select(heartedProfile => heartedProfile.HeartedUserId) .Distinct(); IQueryable seedTasteSlotIds = database.HeartedLevels - .AsNoTracking() .Where(heartedLevel => seedUserIds.Contains(heartedLevel.UserId)) .Select(heartedLevel => heartedLevel.SlotId) .Distinct(); IQueryable neighborUserIds = database.HeartedLevels - .AsNoTracking() .Where(heartedLevel => seedTasteSlotIds.Contains(heartedLevel.SlotId)) .Where(heartedLevel => heartedLevel.UserId != token.UserId && !seedUserIds.Contains(heartedLevel.UserId)) .GroupBy(heartedLevel => heartedLevel.UserId) @@ -66,7 +70,6 @@ public IQueryable GetScoredItems(DatabaseContext database, GameToken .Select(user => user.UserId); var directContributions = database.HeartedLevels - .AsNoTracking() .Where(heartedLevel => seedUserIds.Contains(heartedLevel.UserId)) .Select(heartedLevel => new { @@ -82,7 +85,7 @@ public IQueryable GetScoredItems(DatabaseContext database, GameToken }); var neighborContributions = - from heartedLevel in database.HeartedLevels.AsNoTracking() + from heartedLevel in database.HeartedLevels join neighborUserId in neighborUserIds on heartedLevel.UserId equals neighborUserId select new @@ -101,7 +104,6 @@ on heartedLevel.UserId equals neighborUserId }); var creatorContributions = database.Slots - .AsNoTracking() .Where(slot => seedUserIds.Contains(slot.CreatorId)) .Select(slot => new { @@ -110,20 +112,45 @@ on heartedLevel.UserId equals neighborUserId PrevSearchScore = 0, }); - var scores = directContributions + return directContributions .Concat(distinctNeighborContributions) .Concat(creatorContributions) .GroupBy(contribution => contribution.SlotId) - .Select(group => new + .Select(group => new RecommendationScore { SlotId = group.Key, SearchScore = group.Sum(contribution => contribution.SearchScore), PrevSearchScore = group.Sum(contribution => contribution.PrevSearchScore), + }) + .OrderByDescending(score => score.SearchScore) + .ThenByDescending(score => score.PrevSearchScore) + .ThenByDescending(score => score.SlotId) + .Take(config.MaxCandidatePool); + } + + public IQueryable GetScoredItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) + { + IQueryable scores = this.GetSearchScores(database, token); + + var heartCounts = database.HeartedLevels + .GroupBy(heartedLevel => heartedLevel.SlotId) + .Select(group => new + { + SlotId = group.Key, + Count = (int?)group.Count(), + }); + + var likeCounts = database.RatedLevels + .Where(rating => rating.Rating == 1) + .GroupBy(rating => rating.SlotId) + .Select(group => new + { + SlotId = group.Key, + Count = (int?)group.Count(), }); IQueryable recommendations = from slot in database.Slots - .AsNoTracking() .Where(queryBuilder.Build()) .Where(slot => !database.VisitedLevels.Any(visitedLevel => visitedLevel.UserId == token.UserId && @@ -132,18 +159,19 @@ from slot in database.Slots join score in scores on slot.SlotId equals score.SlotId - let hearts = database.HeartedLevels.Count(heartedLevel => - heartedLevel.SlotId == slot.SlotId) + join heartCount in heartCounts + on slot.SlotId equals heartCount.SlotId into heartCountGroup + from heartCount in heartCountGroup.DefaultIfEmpty() - let likes = database.RatedLevels.Count(rating => - rating.SlotId == slot.SlotId && - rating.Rating == 1) + join likeCount in likeCounts + on slot.SlotId equals likeCount.SlotId into likeCountGroup + from likeCount in likeCountGroup.DefaultIfEmpty() orderby score.SearchScore descending, score.PrevSearchScore descending, - hearts descending, - likes descending, + heartCount.Count descending, + likeCount.Count descending, slot.SlotId descending select new ScoredSlot @@ -151,8 +179,8 @@ slot.SlotId descending Slot = slot, SearchScore = score.SearchScore, PrevSearchScore = score.PrevSearchScore, - Hearts = hearts, - Likes = likes, + Hearts = heartCount.Count ?? 0, + Likes = likeCount.Count ?? 0, }; return recommendations; @@ -189,4 +217,4 @@ public override async Task Serialize(DatabaseContext database, Gam return GameCategory.CreateFromEntity(this, new GenericSerializableList(serializedSlots, totalSlots, numResults + 1)); } -} \ No newline at end of file +} diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/TeamPicksCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/TeamPicksCategory.cs index d7070c264..23d8c20a7 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/TeamPicksCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/TeamPicksCategory.cs @@ -22,4 +22,4 @@ public class TeamPicksCategory : SlotCategory public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) => database.Slots.Where(queryBuilder.Clone().AddFilter(new TeamPickFilter()).Build()) .ApplyOrdering(new SlotSortBuilder().AddSort(new TeamPickSort()).AddSort(new FirstUploadedSort())); -} \ No newline at end of file +} diff --git a/ProjectLighthouse.Servers.GameServer/Types/MyPlaylistsCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/MyPlaylistsCategory.cs index fbe09bf3e..357ad6de3 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/MyPlaylistsCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/MyPlaylistsCategory.cs @@ -13,11 +13,8 @@ public class MyPlaylistsCategory : PlaylistCategory public override string Endpoint { get; set; } = "my_playlists"; public override string Tag => "my_playlists"; public override string[] Types { get; } = { "playlist", }; - public override string[] Sorts { get; } = - { - "relevance" - }; + public override string[] Sorts { get; } = ["relevance",]; public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token) => database.Playlists.Where(p => p.CreatorId == token.UserId).OrderByDescending(p => p.PlaylistId); -} \ No newline at end of file +} diff --git a/ProjectLighthouse/Configuration/CategoryConfiguration.cs b/ProjectLighthouse/Configuration/CategoryConfiguration.cs index f2c479be7..1d7f46507 100644 --- a/ProjectLighthouse/Configuration/CategoryConfiguration.cs +++ b/ProjectLighthouse/Configuration/CategoryConfiguration.cs @@ -12,11 +12,11 @@ public class CategoryConfiguration : ConfigurationBase // This is so Lighthouse can properly identify outdated configurations and update them with newer settings accordingly. // If you are modifying anything here, this value MUST be incremented. // Thanks for listening~ - public override int ConfigVersion { get; set; } = 1; + public override int ConfigVersion { get; set; } = 2; public override string ConfigName { get; set; } = "CategoryConfig.yml"; public override bool NeedsConfiguration { get; set; } = false; - public List OrderOfCategory { get; set; } = new() + public List Categories { get; set; } = new() { "recently_played", "recommended", @@ -26,21 +26,17 @@ public class CategoryConfiguration : ConfigurationBase "busiest", "most_played", "my_playlists", - "favourite_creators", "queue", "hearted_levels", "highest_rated", "lucky_dip", }; - public List DisabledCategories { get; set; } = new() - { - "favourite_creators", - }; - public RecommendedCategoryConfig Recommended { get; set; } = new(); public RecentlyPlayedConfig RecentlyPlayed { get; set; } = new(); - public override ConfigurationBase Deserialize(IDeserializer deserializer, string text) => deserializer.Deserialize(text); + + public override ConfigurationBase Deserialize(IDeserializer deserializer, string text) => + deserializer.Deserialize(text); } public class RecommendedCategoryConfig @@ -53,4 +49,4 @@ public class RecommendedCategoryConfig public class RecentlyPlayedConfig { public int MaxLevels { get; set; } = 30; -} \ No newline at end of file +} diff --git a/ProjectLighthouse/Database/DatabaseContext.Slots.cs b/ProjectLighthouse/Database/DatabaseContext.Slots.cs index bd568766c..efdec16b6 100644 --- a/ProjectLighthouse/Database/DatabaseContext.Slots.cs +++ b/ProjectLighthouse/Database/DatabaseContext.Slots.cs @@ -1,8 +1,9 @@ #nullable enable -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using LBPUnion.ProjectLighthouse.Configuration; +using LBPUnion.ProjectLighthouse.Helpers; using LBPUnion.ProjectLighthouse.Types.Entities.Interaction; using LBPUnion.ProjectLighthouse.Types.Entities.Level; using Microsoft.EntityFrameworkCore; @@ -90,59 +91,34 @@ public async Task UnqueueLevel(int userId, SlotEntity queuedSlot) await this.SaveChangesAsync(); } - public async Task RecordRecentlyPlayedLevel(int userId, int slotId, bool saveChanges = true) + public async Task RecordRecentlyPlayedLevel(int userId, int slotId) { - RecentlyPlayedEntity? recentlyPlayed = await this.RecentlyPlayed.FirstOrDefaultAsync(r => r.UserId == userId); + long now = TimeHelper.TimestampMillis; + int maxLevels = CategoryConfiguration.Instance.RecentlyPlayed.MaxLevels; - long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + RecentlyPlayedEntity? recentlyPlayed = await this.RecentlyPlayed + .FirstOrDefaultAsync(r => r.UserId == userId && r.SlotId == slotId); - //Level at the top of the recently played category for the user if (recentlyPlayed == null) { this.RecentlyPlayed.Add(new RecentlyPlayedEntity { UserId = userId, - SlotIds = new List { slotId }, - LastPlayedAt = new List { now }, + SlotId = slotId, + LastPlayedAt = now, }); - - if (saveChanges)await this.SaveChangesAsync(); - - return; - } - - //This makes it so the users most recently played level isnt rewritten if it they're in said level - if (recentlyPlayed.SlotIds.Count > 0 && recentlyPlayed.SlotIds[0] == slotId) - { - return; - } - - //If the level already existed in the users history, it removes its old timestamp and position, then moves it to the top of the list. - int existingIndex = recentlyPlayed.SlotIds.IndexOf(slotId); - - if (existingIndex >= 0) - { - recentlyPlayed.SlotIds.RemoveAt(existingIndex); - - if (existingIndex < recentlyPlayed.LastPlayedAt.Count) - recentlyPlayed.LastPlayedAt.RemoveAt(existingIndex); } - - //The most recently played level is at the top - recentlyPlayed.SlotIds.Insert(0, slotId); - recentlyPlayed.LastPlayedAt.Insert(0, now); - - //Max of 30 levels - if (recentlyPlayed.SlotIds.Count > 30) + else { - recentlyPlayed.SlotIds.RemoveRange(30, recentlyPlayed.SlotIds.Count - 30); + recentlyPlayed.LastPlayedAt = now; } - if (recentlyPlayed.LastPlayedAt.Count > 30) - { - recentlyPlayed.LastPlayedAt.RemoveRange(30, recentlyPlayed.LastPlayedAt.Count - 30); - } + List excessEntries = await this.RecentlyPlayed + .Where(r => r.UserId == userId && r.SlotId != slotId) + .OrderByDescending(r => r.LastPlayedAt) + .Skip(maxLevels - 1) + .ToListAsync(); - if (saveChanges)await this.SaveChangesAsync(); + this.RecentlyPlayed.RemoveRange(excessEntries); } } diff --git a/ProjectLighthouse/Database/DatabaseContext.cs b/ProjectLighthouse/Database/DatabaseContext.cs index cae796344..65f79ea00 100644 --- a/ProjectLighthouse/Database/DatabaseContext.cs +++ b/ProjectLighthouse/Database/DatabaseContext.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; using LBPUnion.ProjectLighthouse.Configuration; using LBPUnion.ProjectLighthouse.Types.Entities.Interaction; using LBPUnion.ProjectLighthouse.Types.Entities.Level; @@ -12,8 +8,6 @@ using LBPUnion.ProjectLighthouse.Types.Entities.Token; using LBPUnion.ProjectLighthouse.Types.Entities.Website; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.ChangeTracking; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace LBPUnion.ProjectLighthouse.Database; @@ -89,53 +83,10 @@ public DatabaseContext(DbContextOptions options) : base(options { } public static DatabaseContext CreateNewInstance() -{ - DbContextOptionsBuilder builder = new(); - builder.UseMySql(ServerConfiguration.Instance.DbConnectionString, - MySqlServerVersion.LatestSupportedServerVersion); - return new DatabaseContext(builder.Options); -} - - protected override void OnModelCreating(ModelBuilder modelBuilder) { - base.OnModelCreating(modelBuilder); - - ValueConverter, string> slotIdsConverter = new( - value => JsonSerializer.Serialize( - value, - (JsonSerializerOptions)null), - value => JsonSerializer.Deserialize>( - value, - (JsonSerializerOptions)null) ?? new List()); - - ValueComparer> slotIdsComparer = new( - (left, right) => left.SequenceEqual(right), - value => value.Aggregate( - 0, - (hash, item) => HashCode.Combine(hash, item.GetHashCode())), - value => value.ToList()); - - modelBuilder.Entity() - .Property(r => r.SlotIds) - .HasConversion(slotIdsConverter, slotIdsComparer); - - ValueConverter, string> timestampsConverter = new( - value => JsonSerializer.Serialize( - value, - (JsonSerializerOptions)null), - value => JsonSerializer.Deserialize>( - value, - (JsonSerializerOptions)null) ?? new List()); - - ValueComparer> timestampsComparer = new( - (left, right) => left.SequenceEqual(right), - value => value.Aggregate( - 0, - (hash, item) => HashCode.Combine(hash, item.GetHashCode())), - value => value.ToList()); - - modelBuilder.Entity() - .Property(r => r.LastPlayedAt) - .HasConversion(timestampsConverter, timestampsComparer); + DbContextOptionsBuilder builder = new(); + builder.UseMySql(ServerConfiguration.Instance.DbConnectionString, + MySqlServerVersion.LatestSupportedServerVersion); + return new DatabaseContext(builder.Options); } -} \ No newline at end of file +} diff --git a/ProjectLighthouse/Helpers/RoomHelper.cs b/ProjectLighthouse/Helpers/RoomHelper.cs index 8b1fb4100..3fa5aa581 100644 --- a/ProjectLighthouse/Helpers/RoomHelper.cs +++ b/ProjectLighthouse/Helpers/RoomHelper.cs @@ -169,7 +169,7 @@ public static Dictionary GetUserLevelPlayerCounts() SlotId = room.Slot.SlotId, PlayerId = playerId, })) - //Distinct being used here prevents a duplicate room state from messing up the player count. + // Distinct being used here prevents a duplicate room state from messing up the player count. .Distinct() .GroupBy(entry => entry.SlotId) .ToDictionary(group => group.Key, group => group.Count()); @@ -275,4 +275,4 @@ public static Task CleanupRooms(DatabaseContext database, int? hostId = null, Ro return Task.FromResult(0); } -} \ No newline at end of file +} diff --git a/ProjectLighthouse/Migrations/20260812004441_AddRecentlyPlayed.Designer.cs b/ProjectLighthouse/Migrations/20260812004441_AddRecentlyPlayed.Designer.cs deleted file mode 100644 index 30c01f46a..000000000 --- a/ProjectLighthouse/Migrations/20260812004441_AddRecentlyPlayed.Designer.cs +++ /dev/null @@ -1,1602 +0,0 @@ -// -using System; -using LBPUnion.ProjectLighthouse.Database; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace LBPUnion.ProjectLighthouse.Migrations -{ - [DbContext(typeof(DatabaseContext))] - [Migration("20260812004441_AddRecentlyPlayed")] - partial class AddRecentlyPlayed - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "8.0.18") - .HasAnnotation("Relational:MaxIdentifierLength", 64); - - MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.HeartedLevelEntity", b => - { - b.Property("HeartedLevelId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("HeartedLevelId")); - - b.Property("SlotId") - .HasColumnType("int"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("HeartedLevelId"); - - b.HasIndex("SlotId"); - - b.HasIndex("UserId"); - - b.ToTable("HeartedLevels"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.HeartedPlaylistEntity", b => - { - b.Property("HeartedPlaylistId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("HeartedPlaylistId")); - - b.Property("PlaylistId") - .HasColumnType("int"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("HeartedPlaylistId"); - - b.HasIndex("PlaylistId"); - - b.HasIndex("UserId"); - - b.ToTable("HeartedPlaylists"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.HeartedProfileEntity", b => - { - b.Property("HeartedProfileId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("HeartedProfileId")); - - b.Property("HeartedUserId") - .HasColumnType("int"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("HeartedProfileId"); - - b.HasIndex("HeartedUserId"); - - b.HasIndex("UserId"); - - b.ToTable("HeartedProfiles"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.QueuedLevelEntity", b => - { - b.Property("QueuedLevelId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("QueuedLevelId")); - - b.Property("SlotId") - .HasColumnType("int"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("QueuedLevelId"); - - b.HasIndex("SlotId"); - - b.HasIndex("UserId"); - - b.ToTable("QueuedLevels"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.RatedCommentEntity", b => - { - b.Property("RatingId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RatingId")); - - b.Property("CommentId") - .HasColumnType("int"); - - b.Property("Rating") - .HasColumnType("int"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("RatingId"); - - b.HasIndex("CommentId"); - - b.HasIndex("UserId"); - - b.ToTable("RatedComments"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.RatedLevelEntity", b => - { - b.Property("RatedLevelId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RatedLevelId")); - - b.Property("Rating") - .HasColumnType("int"); - - b.Property("RatingLBP1") - .HasColumnType("double"); - - b.Property("SlotId") - .HasColumnType("int"); - - b.Property("TagLBP1") - .HasColumnType("longtext"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("RatedLevelId"); - - b.HasIndex("SlotId"); - - b.HasIndex("UserId"); - - b.ToTable("RatedLevels"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.RatedReviewEntity", b => - { - b.Property("RatedReviewId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RatedReviewId")); - - b.Property("ReviewId") - .HasColumnType("int"); - - b.Property("Thumb") - .HasColumnType("int"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("RatedReviewId"); - - b.HasIndex("ReviewId"); - - b.HasIndex("UserId"); - - b.ToTable("RatedReviews"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.RecentlyPlayedEntity", b => - { - b.Property("RecentlyPlayedId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RecentlyPlayedId")); - - b.Property("LastPlayedAt") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("SlotIds") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("RecentlyPlayedId"); - - b.HasIndex("UserId") - .IsUnique(); - - b.ToTable("RecentlyPlayed"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.VisitedLevelEntity", b => - { - b.Property("VisitedLevelId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("VisitedLevelId")); - - b.Property("PlaysLBP1") - .HasColumnType("int"); - - b.Property("PlaysLBP2") - .HasColumnType("int"); - - b.Property("PlaysLBP3") - .HasColumnType("int"); - - b.Property("SlotId") - .HasColumnType("int"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("VisitedLevelId"); - - b.HasIndex("SlotId"); - - b.HasIndex("UserId"); - - b.ToTable("VisitedLevels"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Level.DatabaseCategoryEntity", b => - { - b.Property("CategoryId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CategoryId")); - - b.Property("Description") - .HasColumnType("longtext"); - - b.Property("Endpoint") - .HasColumnType("longtext"); - - b.Property("IconHash") - .HasColumnType("longtext"); - - b.Property("Name") - .HasColumnType("longtext"); - - b.Property("SlotIdsCollection") - .HasColumnType("longtext"); - - b.HasKey("CategoryId"); - - b.ToTable("CustomCategories"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Level.PlaylistEntity", b => - { - b.Property("PlaylistId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PlaylistId")); - - b.Property("CreatorId") - .HasColumnType("int"); - - b.Property("Description") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("Name") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("SlotCollection") - .IsRequired() - .HasColumnType("longtext"); - - b.HasKey("PlaylistId"); - - b.HasIndex("CreatorId"); - - b.ToTable("Playlists"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Level.ReviewEntity", b => - { - b.Property("ReviewId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReviewId")); - - b.Property("Deleted") - .HasColumnType("tinyint(1)"); - - b.Property("DeletedBy") - .HasColumnType("int"); - - b.Property("LabelCollection") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("ReviewerId") - .HasColumnType("int"); - - b.Property("SlotId") - .HasColumnType("int"); - - b.Property("Text") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("Thumb") - .HasColumnType("int"); - - b.Property("ThumbsDown") - .HasColumnType("int"); - - b.Property("ThumbsUp") - .HasColumnType("int"); - - b.Property("Timestamp") - .HasColumnType("bigint"); - - b.HasKey("ReviewId"); - - b.HasIndex("ReviewerId"); - - b.HasIndex("SlotId"); - - b.ToTable("Reviews"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Level.ScoreEntity", b => - { - b.Property("ScoreId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ScoreId")); - - b.Property("ChildSlotId") - .HasColumnType("int"); - - 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"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", b => - { - b.Property("SlotId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SlotId")); - - b.Property("AuthorLabels") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("BackgroundHash") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("CommentsEnabled") - .HasColumnType("tinyint(1)"); - - b.Property("CreatorId") - .HasColumnType("int"); - - b.Property("CrossControllerRequired") - .HasColumnType("tinyint(1)"); - - b.Property("Description") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("FirstUploaded") - .HasColumnType("bigint"); - - b.Property("GameVersion") - .HasColumnType("int"); - - b.Property("Hidden") - .HasColumnType("tinyint(1)"); - - b.Property("HiddenReason") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("IconHash") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("InitiallyLocked") - .HasColumnType("tinyint(1)"); - - b.Property("InternalSlotId") - .HasColumnType("int"); - - b.Property("IsAdventurePlanet") - .HasColumnType("tinyint(1)"); - - b.Property("LastUpdated") - .HasColumnType("bigint"); - - b.Property("Lbp1Only") - .HasColumnType("tinyint(1)"); - - b.Property("LevelType") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("LocationPacked") - .HasColumnType("bigint unsigned"); - - b.Property("LockedByModerator") - .HasColumnType("tinyint(1)"); - - b.Property("LockedReason") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("MaximumPlayers") - .HasColumnType("int"); - - b.Property("MinimumPlayers") - .HasColumnType("int"); - - b.Property("MoveRequired") - .HasColumnType("tinyint(1)"); - - b.Property("Name") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("PlaysLBP1") - .HasColumnType("int"); - - b.Property("PlaysLBP1Complete") - .HasColumnType("int"); - - b.Property("PlaysLBP1Unique") - .HasColumnType("int"); - - b.Property("PlaysLBP2") - .HasColumnType("int"); - - b.Property("PlaysLBP2Complete") - .HasColumnType("int"); - - b.Property("PlaysLBP2Unique") - .HasColumnType("int"); - - b.Property("PlaysLBP3") - .HasColumnType("int"); - - b.Property("PlaysLBP3Complete") - .HasColumnType("int"); - - b.Property("PlaysLBP3Unique") - .HasColumnType("int"); - - b.Property("ResourceCollection") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("RootLevel") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("Shareable") - .HasColumnType("int"); - - b.Property("SubLevel") - .HasColumnType("tinyint(1)"); - - b.Property("TeamPickTime") - .HasColumnType("bigint"); - - b.Property("Type") - .HasColumnType("int"); - - b.HasKey("SlotId"); - - b.HasIndex("CreatorId"); - - b.ToTable("Slots"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Maintenance.CompletedMigrationEntity", b => - { - b.Property("MigrationName") - .HasColumnType("varchar(255)"); - - b.Property("RanAt") - .HasColumnType("datetime(6)"); - - b.HasKey("MigrationName"); - - b.ToTable("CompletedMigrations"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Moderation.GriefReportEntity", b => - { - b.Property("ReportId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReportId")); - - b.Property("Bounds") - .HasColumnType("longtext"); - - b.Property("GriefStateHash") - .HasColumnType("longtext"); - - b.Property("InitialStateHash") - .HasColumnType("longtext"); - - b.Property("JpegHash") - .HasColumnType("longtext"); - - b.Property("LevelId") - .HasColumnType("int"); - - b.Property("LevelOwner") - .HasColumnType("longtext"); - - b.Property("LevelType") - .HasColumnType("longtext"); - - b.Property("Players") - .HasColumnType("longtext"); - - b.Property("ReportingPlayerId") - .HasColumnType("int"); - - b.Property("Timestamp") - .HasColumnType("bigint"); - - b.Property("Type") - .HasColumnType("int"); - - b.HasKey("ReportId"); - - b.HasIndex("ReportingPlayerId"); - - b.ToTable("Reports"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Moderation.ModerationCaseEntity", b => - { - b.Property("CaseId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CaseId")); - - b.Property("AffectedId") - .HasColumnType("int"); - - b.Property("CreatedAt") - .HasColumnType("datetime(6)"); - - b.Property("CreatorId") - .HasColumnType("int"); - - b.Property("CreatorUsername") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("DismissedAt") - .HasColumnType("datetime(6)"); - - b.Property("DismisserId") - .HasColumnType("int"); - - b.Property("DismisserUsername") - .HasColumnType("longtext"); - - b.Property("ExpiresAt") - .HasColumnType("datetime(6)"); - - b.Property("ModeratorNotes") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("Processed") - .HasColumnType("tinyint(1)"); - - b.Property("Reason") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("Type") - .HasColumnType("int"); - - b.HasKey("CaseId"); - - b.HasIndex("CreatorId"); - - b.HasIndex("DismisserId"); - - b.ToTable("Cases"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Notifications.NotificationEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); - - b.Property("IsDismissed") - .HasColumnType("tinyint(1)"); - - b.Property("Text") - .HasColumnType("longtext"); - - b.Property("Type") - .HasColumnType("int"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("Notifications"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.BlockedProfileEntity", b => - { - b.Property("BlockedProfileId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BlockedProfileId")); - - b.Property("BlockedUserId") - .HasColumnType("int"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("BlockedProfileId"); - - b.HasIndex("BlockedUserId"); - - b.HasIndex("UserId"); - - b.ToTable("BlockedProfiles"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.CommentEntity", b => - { - b.Property("CommentId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CommentId")); - - b.Property("Deleted") - .HasColumnType("tinyint(1)"); - - b.Property("DeletedBy") - .HasColumnType("longtext"); - - b.Property("DeletedType") - .HasColumnType("longtext"); - - b.Property("Message") - .HasColumnType("longtext"); - - b.Property("PosterUserId") - .HasColumnType("int"); - - b.Property("TargetSlotId") - .HasColumnType("int"); - - b.Property("TargetUserId") - .HasColumnType("int"); - - b.Property("ThumbsDown") - .HasColumnType("int"); - - b.Property("ThumbsUp") - .HasColumnType("int"); - - b.Property("Timestamp") - .HasColumnType("bigint"); - - b.Property("Type") - .HasColumnType("int"); - - b.HasKey("CommentId"); - - b.HasIndex("PosterUserId"); - - b.HasIndex("TargetSlotId"); - - b.HasIndex("TargetUserId"); - - b.ToTable("Comments"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.LastContactEntity", b => - { - b.Property("UserId") - .HasColumnType("int"); - - b.Property("GameVersion") - .HasColumnType("int"); - - b.Property("Platform") - .HasColumnType("int"); - - b.Property("Timestamp") - .HasColumnType("bigint"); - - b.HasKey("UserId"); - - b.ToTable("LastContacts"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PhotoEntity", b => - { - b.Property("PhotoId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PhotoId")); - - b.Property("CreatorId") - .HasColumnType("int"); - - b.Property("LargeHash") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("MediumHash") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("PlanHash") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("SlotId") - .HasColumnType("int"); - - b.Property("SmallHash") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("Timestamp") - .HasColumnType("bigint"); - - b.HasKey("PhotoId"); - - b.HasIndex("CreatorId"); - - b.HasIndex("SlotId"); - - b.ToTable("Photos"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PhotoSubjectEntity", b => - { - b.Property("PhotoSubjectId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PhotoSubjectId")); - - b.Property("Bounds") - .HasColumnType("longtext"); - - b.Property("PhotoId") - .HasColumnType("int"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("PhotoSubjectId"); - - b.HasIndex("PhotoId"); - - b.HasIndex("UserId"); - - b.ToTable("PhotoSubjects"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PlatformLinkAttemptEntity", b => - { - b.Property("PlatformLinkAttemptId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PlatformLinkAttemptId")); - - b.Property("IPAddress") - .HasColumnType("longtext"); - - b.Property("Platform") - .HasColumnType("int"); - - b.Property("PlatformId") - .HasColumnType("bigint unsigned"); - - b.Property("Timestamp") - .HasColumnType("bigint"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("PlatformLinkAttemptId"); - - b.HasIndex("UserId"); - - b.ToTable("PlatformLinkAttempts"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", b => - { - b.Property("UserId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("UserId")); - - b.Property("AdminGrantedSlots") - .HasColumnType("int"); - - b.Property("BannedReason") - .HasColumnType("longtext"); - - b.Property("Biography") - .HasColumnType("longtext"); - - b.Property("BooHash") - .HasColumnType("longtext"); - - b.Property("CommentsEnabled") - .HasColumnType("tinyint(1)"); - - b.Property("EmailAddress") - .HasColumnType("longtext"); - - b.Property("EmailAddressVerified") - .HasColumnType("tinyint(1)"); - - b.Property("IconHash") - .HasColumnType("longtext"); - - b.Property("Language") - .HasColumnType("longtext"); - - b.Property("LastLogin") - .HasColumnType("bigint"); - - b.Property("LastLogout") - .HasColumnType("bigint"); - - b.Property("LevelVisibility") - .HasColumnType("int"); - - b.Property("LinkedPsnId") - .HasColumnType("bigint unsigned"); - - b.Property("LinkedRpcnId") - .HasColumnType("bigint unsigned"); - - b.Property("LocationPacked") - .HasColumnType("bigint unsigned"); - - b.Property("MehHash") - .HasColumnType("longtext"); - - b.Property("Password") - .HasColumnType("longtext"); - - b.Property("PasswordResetRequired") - .HasColumnType("tinyint(1)"); - - b.Property("PermissionLevel") - .HasColumnType("int"); - - b.Property("Pins") - .HasColumnType("longtext"); - - b.Property("PlanetHashLBP2") - .HasColumnType("longtext"); - - b.Property("PlanetHashLBP2CC") - .HasColumnType("longtext"); - - b.Property("PlanetHashLBP3") - .HasColumnType("longtext"); - - b.Property("PlanetHashLBPVita") - .HasColumnType("longtext"); - - b.Property("ProfileTag") - .HasColumnType("longtext"); - - b.Property("ProfileVisibility") - .HasColumnType("int"); - - b.Property("TimeZone") - .HasColumnType("longtext"); - - b.Property("TwoFactorBackup") - .HasColumnType("longtext"); - - b.Property("TwoFactorSecret") - .HasColumnType("longtext"); - - b.Property("Username") - .IsRequired() - .HasColumnType("longtext"); - - b.Property("YayHash") - .HasColumnType("longtext"); - - b.HasKey("UserId"); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Token.ApiKeyEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); - - b.Property("Created") - .HasColumnType("datetime(6)"); - - b.Property("Description") - .HasColumnType("longtext"); - - b.Property("Key") - .HasColumnType("longtext"); - - b.HasKey("Id"); - - b.ToTable("APIKeys"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Token.EmailSetTokenEntity", b => - { - b.Property("EmailSetTokenId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmailSetTokenId")); - - b.Property("EmailToken") - .HasColumnType("longtext"); - - b.Property("ExpiresAt") - .HasColumnType("datetime(6)"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("EmailSetTokenId"); - - b.HasIndex("UserId"); - - b.ToTable("EmailSetTokens"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Token.EmailVerificationTokenEntity", b => - { - b.Property("EmailVerificationTokenId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmailVerificationTokenId")); - - b.Property("EmailToken") - .HasColumnType("longtext"); - - b.Property("ExpiresAt") - .HasColumnType("datetime(6)"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("EmailVerificationTokenId"); - - b.HasIndex("UserId"); - - b.ToTable("EmailVerificationTokens"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Token.GameTokenEntity", b => - { - b.Property("TokenId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TokenId")); - - b.Property("ExpiresAt") - .HasColumnType("datetime(6)"); - - b.Property("GameVersion") - .HasColumnType("int"); - - b.Property("LocationHash") - .HasMaxLength(64) - .HasColumnType("varchar(64)"); - - b.Property("Platform") - .HasColumnType("int"); - - b.Property("TicketHash") - .HasMaxLength(64) - .HasColumnType("varchar(64)"); - - b.Property("UserId") - .HasColumnType("int"); - - b.Property("UserToken") - .HasColumnType("longtext"); - - b.HasKey("TokenId"); - - b.HasIndex("UserId"); - - b.ToTable("GameTokens"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Token.PasswordResetTokenEntity", b => - { - b.Property("TokenId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TokenId")); - - b.Property("Created") - .HasColumnType("datetime(6)"); - - b.Property("ResetToken") - .HasColumnType("longtext"); - - b.Property("UserId") - .HasColumnType("int"); - - b.HasKey("TokenId"); - - b.ToTable("PasswordResetTokens"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Token.RegistrationTokenEntity", b => - { - b.Property("TokenId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TokenId")); - - b.Property("Created") - .HasColumnType("datetime(6)"); - - b.Property("Token") - .HasColumnType("longtext"); - - b.Property("Username") - .HasColumnType("longtext"); - - b.HasKey("TokenId"); - - b.ToTable("RegistrationTokens"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Token.WebTokenEntity", b => - { - b.Property("TokenId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TokenId")); - - b.Property("ExpiresAt") - .HasColumnType("datetime(6)"); - - b.Property("UserId") - .HasColumnType("int"); - - b.Property("UserToken") - .HasColumnType("longtext"); - - b.Property("Verified") - .HasColumnType("tinyint(1)"); - - b.HasKey("TokenId"); - - b.ToTable("WebTokens"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Website.WebsiteAnnouncementEntity", b => - { - b.Property("AnnouncementId") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("AnnouncementId")); - - b.Property("Content") - .HasColumnType("longtext"); - - b.Property("PublisherId") - .HasColumnType("int"); - - b.Property("Title") - .HasColumnType("longtext"); - - b.HasKey("AnnouncementId"); - - b.HasIndex("PublisherId"); - - b.ToTable("WebsiteAnnouncements"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.HeartedLevelEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot") - .WithMany() - .HasForeignKey("SlotId") - .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.Interaction.HeartedPlaylistEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.PlaylistEntity", "Playlist") - .WithMany() - .HasForeignKey("PlaylistId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Playlist"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.HeartedProfileEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "HeartedUser") - .WithMany() - .HasForeignKey("HeartedUserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("HeartedUser"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.QueuedLevelEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot") - .WithMany() - .HasForeignKey("SlotId") - .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.Interaction.RatedCommentEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.CommentEntity", "Comment") - .WithMany() - .HasForeignKey("CommentId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Comment"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.RatedLevelEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot") - .WithMany() - .HasForeignKey("SlotId") - .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.Interaction.RatedReviewEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.ReviewEntity", "Review") - .WithMany() - .HasForeignKey("ReviewId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Review"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.RecentlyPlayedEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.VisitedLevelEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot") - .WithMany() - .HasForeignKey("SlotId") - .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.PlaylistEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "Creator") - .WithMany() - .HasForeignKey("CreatorId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Creator"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Level.ReviewEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "Reviewer") - .WithMany() - .HasForeignKey("ReviewerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot") - .WithMany() - .HasForeignKey("SlotId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Reviewer"); - - b.Navigation("Slot"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Level.ScoreEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot") - .WithMany() - .HasForeignKey("SlotId") - .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 => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "Creator") - .WithMany() - .HasForeignKey("CreatorId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Creator"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Moderation.GriefReportEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "ReportingPlayer") - .WithMany() - .HasForeignKey("ReportingPlayerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ReportingPlayer"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Moderation.ModerationCaseEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "Creator") - .WithMany() - .HasForeignKey("CreatorId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "Dismisser") - .WithMany() - .HasForeignKey("DismisserId"); - - b.Navigation("Creator"); - - b.Navigation("Dismisser"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Notifications.NotificationEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.BlockedProfileEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "BlockedUser") - .WithMany() - .HasForeignKey("BlockedUserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("BlockedUser"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.CommentEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "Poster") - .WithMany() - .HasForeignKey("PosterUserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "TargetSlot") - .WithMany() - .HasForeignKey("TargetSlotId"); - - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "TargetUser") - .WithMany() - .HasForeignKey("TargetUserId"); - - b.Navigation("Poster"); - - b.Navigation("TargetSlot"); - - b.Navigation("TargetUser"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.LastContactEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PhotoEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "Creator") - .WithMany() - .HasForeignKey("CreatorId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot") - .WithMany() - .HasForeignKey("SlotId"); - - b.Navigation("Creator"); - - b.Navigation("Slot"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PhotoSubjectEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PhotoEntity", "Photo") - .WithMany("PhotoSubjects") - .HasForeignKey("PhotoId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Photo"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PlatformLinkAttemptEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Token.EmailSetTokenEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Token.EmailVerificationTokenEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Token.GameTokenEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Website.WebsiteAnnouncementEntity", b => - { - b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "Publisher") - .WithMany() - .HasForeignKey("PublisherId"); - - b.Navigation("Publisher"); - }); - - modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PhotoEntity", b => - { - b.Navigation("PhotoSubjects"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/ProjectLighthouse/Migrations/20260812004441_AddRecentlyPlayed.cs b/ProjectLighthouse/Migrations/20260812004441_AddRecentlyPlayed.cs index a8746257b..7de5a26bb 100644 --- a/ProjectLighthouse/Migrations/20260812004441_AddRecentlyPlayed.cs +++ b/ProjectLighthouse/Migrations/20260812004441_AddRecentlyPlayed.cs @@ -1,4 +1,6 @@ -using Microsoft.EntityFrameworkCore.Metadata; +using LBPUnion.ProjectLighthouse.Database; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -6,6 +8,8 @@ namespace LBPUnion.ProjectLighthouse.Migrations { /// + [DbContext(typeof(DatabaseContext))] + [Migration("20260812004441_AddRecentlyPlayed")] public partial class AddRecentlyPlayed : Migration { /// @@ -18,14 +22,18 @@ protected override void Up(MigrationBuilder migrationBuilder) RecentlyPlayedId = table.Column(type: "int", nullable: false) .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), UserId = table.Column(type: "int", nullable: false), - SlotIds = table.Column(type: "longtext", nullable: false) - .Annotation("MySql:CharSet", "utf8mb4"), - LastPlayedAt = table.Column(type: "longtext", nullable: false) - .Annotation("MySql:CharSet", "utf8mb4") + SlotId = table.Column(type: "int", nullable: false), + LastPlayedAt = table.Column(type: "bigint", nullable: false) }, constraints: table => { table.PrimaryKey("PK_RecentlyPlayed", x => x.RecentlyPlayedId); + table.ForeignKey( + name: "FK_RecentlyPlayed_Slots_SlotId", + column: x => x.SlotId, + principalTable: "Slots", + principalColumn: "SlotId", + onDelete: ReferentialAction.Cascade); table.ForeignKey( name: "FK_RecentlyPlayed_Users_UserId", column: x => x.UserId, @@ -36,9 +44,14 @@ protected override void Up(MigrationBuilder migrationBuilder) .Annotation("MySql:CharSet", "utf8mb4"); migrationBuilder.CreateIndex( - name: "IX_RecentlyPlayed_UserId", + name: "IX_RecentlyPlayed_SlotId", + table: "RecentlyPlayed", + column: "SlotId"); + + migrationBuilder.CreateIndex( + name: "IX_RecentlyPlayed_UserId_SlotId", table: "RecentlyPlayed", - column: "UserId", + columns: new[] { "UserId", "SlotId" }, unique: true); } diff --git a/ProjectLighthouse/Migrations/DatabaseContextModelSnapshot.cs b/ProjectLighthouse/Migrations/DatabaseContextModelSnapshot.cs index 4a4eaa927..2cfb871ca 100644 --- a/ProjectLighthouse/Migrations/DatabaseContextModelSnapshot.cs +++ b/ProjectLighthouse/Migrations/DatabaseContextModelSnapshot.cs @@ -206,20 +206,20 @@ protected override void BuildModel(ModelBuilder modelBuilder) MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RecentlyPlayedId")); - b.Property("LastPlayedAt") - .IsRequired() - .HasColumnType("longtext"); + b.Property("LastPlayedAt") + .HasColumnType("bigint"); - b.Property("SlotIds") - .IsRequired() - .HasColumnType("longtext"); + b.Property("SlotId") + .HasColumnType("int"); b.Property("UserId") .HasColumnType("int"); b.HasKey("RecentlyPlayedId"); - b.HasIndex("UserId") + b.HasIndex("SlotId"); + + b.HasIndex("UserId", "SlotId") .IsUnique(); b.ToTable("RecentlyPlayed"); @@ -1320,12 +1320,20 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.RecentlyPlayedEntity", b => { + b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot") + .WithMany() + .HasForeignKey("SlotId") + .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"); }); diff --git a/ProjectLighthouse/ProjectLighthouse.csproj b/ProjectLighthouse/ProjectLighthouse.csproj index 7b41ccc36..e157904d9 100644 --- a/ProjectLighthouse/ProjectLighthouse.csproj +++ b/ProjectLighthouse/ProjectLighthouse.csproj @@ -15,7 +15,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/ProjectLighthouse/Services/RoomPlayerCountService.cs b/ProjectLighthouse/Services/RoomPlayerCountService.cs new file mode 100644 index 000000000..e683d4b6f --- /dev/null +++ b/ProjectLighthouse/Services/RoomPlayerCountService.cs @@ -0,0 +1,17 @@ +#nullable enable +using System.Collections.Generic; +using LBPUnion.ProjectLighthouse.Helpers; + +namespace LBPUnion.ProjectLighthouse.Services; + +public class RoomPlayerCountService +{ + private readonly Dictionary playerCounts = RoomHelper.GetUserLevelPlayerCounts(); + + public int GetPlayerCount(int slotId) + { + return this.playerCounts.TryGetValue(slotId, out int playerCount) + ? playerCount + : 0; + } +} diff --git a/ProjectLighthouse/Types/Entities/Interaction/RecentlyPlayedEntity.cs b/ProjectLighthouse/Types/Entities/Interaction/RecentlyPlayedEntity.cs index 6b2bacb2c..e1241aef5 100644 --- a/ProjectLighthouse/Types/Entities/Interaction/RecentlyPlayedEntity.cs +++ b/ProjectLighthouse/Types/Entities/Interaction/RecentlyPlayedEntity.cs @@ -1,14 +1,14 @@ #nullable enable -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using LBPUnion.ProjectLighthouse.Types.Entities.Level; using LBPUnion.ProjectLighthouse.Types.Entities.Profile; using Microsoft.EntityFrameworkCore; namespace LBPUnion.ProjectLighthouse.Types.Entities.Interaction; -[Index(nameof(UserId), IsUnique = true)] +[Index(nameof(UserId), nameof(SlotId), IsUnique = true)] public class RecentlyPlayedEntity { [Key] @@ -17,7 +17,9 @@ public class RecentlyPlayedEntity [ForeignKey(nameof(UserId))] public UserEntity User { get; set; } = null!; - public List SlotIds { get; set; } = new(); - public List LastPlayedAt { get; set; } = new(); + public int SlotId { get; set; } -} \ No newline at end of file + [ForeignKey(nameof(SlotId))] + public SlotEntity Slot { get; set; } = null!; + public long LastPlayedAt { get; set; } +} diff --git a/ProjectLighthouse/Types/Levels/Category.cs b/ProjectLighthouse/Types/Levels/Category.cs index 9d0a10674..639916374 100644 --- a/ProjectLighthouse/Types/Levels/Category.cs +++ b/ProjectLighthouse/Types/Levels/Category.cs @@ -18,7 +18,7 @@ public abstract class Category public abstract string Endpoint { get; set; } - public virtual string[] Sorts { get; } = { "relevance", "likes", "plays", "hearts", "date", }; + public virtual string[] Sorts { get; } = ["relevance", "likes", "plays", "hearts", "date",]; public abstract string[] Types { get; } @@ -38,4 +38,4 @@ public abstract class Category public virtual Task Serialize(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder, int numResults = 1) => Task.FromResult(GameCategory.CreateFromEntity(this, new GenericSerializableList(new List(), 0, 0))); -} \ No newline at end of file +} diff --git a/ProjectLighthouse/Types/Levels/CategoryDefaults.cs b/ProjectLighthouse/Types/Levels/CategoryDefaults.cs index e300bd82f..6f73bc592 100644 --- a/ProjectLighthouse/Types/Levels/CategoryDefaults.cs +++ b/ProjectLighthouse/Types/Levels/CategoryDefaults.cs @@ -1,32 +1,28 @@ #nullable enable +using System.ComponentModel; using System.Xml.Serialization; namespace LBPUnion.ProjectLighthouse.Types.Levels; public class CategoryDefaults { + [DefaultValue("")] [XmlElement("gameFilter")] public string? GameFilter { get; set; } + [DefaultValue("")] [XmlElement("dateFilterType")] public string? DateFilterType { get; set; } + [DefaultValue(null)] [XmlElement("includePlayed")] public bool? IncludePlayed { get; set; } + [DefaultValue("")] [XmlElement("teamPicked")] public string? TeamPicked { get; set; } + [DefaultValue("")] [XmlElement("blacklisted")] public string? Blacklisted { get; set; } - - public bool ShouldSerializeGameFilter() => !string.IsNullOrWhiteSpace(this.GameFilter); - - public bool ShouldSerializeDateFilterType() => !string.IsNullOrWhiteSpace(this.DateFilterType); - - public bool ShouldSerializeIncludePlayed() => this.IncludePlayed.HasValue; - - public bool ShouldSerializeTeamPicked() => !string.IsNullOrWhiteSpace(this.TeamPicked); - - public bool ShouldSerializeBlacklisted() => !string.IsNullOrWhiteSpace(this.Blacklisted); -} \ No newline at end of file +} diff --git a/ProjectLighthouse/Types/Serialization/GameCategory.cs b/ProjectLighthouse/Types/Serialization/GameCategory.cs index 881e12f53..d3e812493 100644 --- a/ProjectLighthouse/Types/Serialization/GameCategory.cs +++ b/ProjectLighthouse/Types/Serialization/GameCategory.cs @@ -1,7 +1,6 @@ #nullable enable using System.ComponentModel; using System.Xml.Serialization; -using JetBrains.Annotations; using LBPUnion.ProjectLighthouse.Types.Levels; namespace LBPUnion.ProjectLighthouse.Types.Serialization; @@ -43,17 +42,15 @@ public class GameCategory : ILbpSerializable [XmlArrayItem("type")] public string[] Types { get; set; } = []; - //This will likely be used in the future if Companion Capers ever get added in LBP3 + // This will likely be used in the future if Companion Capers ever get added in LBP3 + [DefaultValue("")] [XmlElement("param")] public string? Param { get; set; } - public bool ShouldSerializeParam() => !string.IsNullOrWhiteSpace(this.Param); - + [DefaultValue(null)] [XmlElement("defaultFilters")] public CategoryDefaults? DefaultFilters { get; set; } - public bool ShouldSerializeDefaultFilters() => this.DefaultFilters is not null; - [DefaultValue(null)] [XmlElement("results")] public GenericSerializableList? Results { get; set; } @@ -73,4 +70,4 @@ public class GameCategory : ILbpSerializable DefaultFilters = category.DefaultFilters, Results = results, }; -} \ No newline at end of file +} diff --git a/ProjectLighthouse/Types/Serialization/GameUserSlot.cs b/ProjectLighthouse/Types/Serialization/GameUserSlot.cs index 73cf25286..c5859bcea 100644 --- a/ProjectLighthouse/Types/Serialization/GameUserSlot.cs +++ b/ProjectLighthouse/Types/Serialization/GameUserSlot.cs @@ -9,9 +9,9 @@ using LBPUnion.ProjectLighthouse.Database; using LBPUnion.ProjectLighthouse.Files; using LBPUnion.ProjectLighthouse.Helpers; +using LBPUnion.ProjectLighthouse.Services; using LBPUnion.ProjectLighthouse.Types.Entities.Interaction; using LBPUnion.ProjectLighthouse.Types.Entities.Level; -using LBPUnion.ProjectLighthouse.Types.Levels; using LBPUnion.ProjectLighthouse.Types.Misc; using LBPUnion.ProjectLighthouse.Types.Users; using Microsoft.EntityFrameworkCore; @@ -41,15 +41,14 @@ public class GameUserSlot : SlotBase, INeedsPreparationForSerialization [XmlElement("npHandle")] public NpHandle AuthorHandle { get; set; } = new(); + + [DefaultValue(null)] [XmlElement("searchScore")] public double? SearchScore { get; set; } - public bool ShouldSerializeSearchScore() => - this.SearchScore.HasValue; + [DefaultValue(null)] [XmlElement("prevSearchScore")] public double? PrevSearchScore { get; set; } - public bool ShouldSerializePrevSearchScore() => - this.PrevSearchScore.HasValue; [XmlElement("location")] public Location Location { get; set; } = new(); @@ -170,7 +169,8 @@ public bool ShouldSerializePrevSearchScore() => // The C# XML serializer doesn't serialize fields that don't have public getters and setters // even though it doesn't use the setter, these fields were originally meant to be expression bodies to another variable // but unfortunately that's not supported. - public string Labels { + public string Labels + { get => this.AuthorLabels; set => throw new NotSupportedException(); } @@ -185,7 +185,7 @@ public string Labels { [DefaultValue(null)] [XmlElement("yourReview")] public GameReview? YourReview { get; set; } - public bool ShouldSerializeYourReview() => this.SerializationMode == SerializationMode.Full; + public bool ShouldSerializeYourReview() => this.SerializationMode == SerializationMode.Full; [XmlElement("reviewsEnabled")] public bool ReviewsEnabled @@ -241,7 +241,7 @@ public bool CommentsEnabled public int ResourcesSize { get; set; } public bool ShouldSerializeResourcesSize() => this.TargetGame == GameVersion.LittleBigPlanetVita; - public async Task PrepareSerialization(DatabaseContext database) + public async Task PrepareSerialization(DatabaseContext database, RoomPlayerCountService playerCountService) { var stats = await database.Slots.Where(s => s.SlotId == this.SlotId) .Select(_ => new @@ -282,7 +282,7 @@ public async Task PrepareSerialization(DatabaseContext database) if (this.GameVersion == GameVersion.LittleBigPlanetVita && this.Resources != null) this.ResourcesSize = this.Resources.Sum(FileHelper.ResourceSize); - #nullable enable +#nullable enable RatedLevelEntity? yourRating = await database.RatedLevels.FirstOrDefaultAsync(r => r.UserId == this.TargetUserId && r.SlotId == this.SlotId); ReviewEntity? yourReview = await database.Reviews.FirstOrDefaultAsync(r => r.ReviewerId == this.TargetUserId && r.SlotId == this.SlotId); VisitedLevelEntity? yourVisitedStats = await database.VisitedLevels.FirstOrDefaultAsync(v => v.UserId == this.TargetUserId && v.SlotId == this.SlotId); @@ -301,13 +301,9 @@ public async Task PrepareSerialization(DatabaseContext database) { this.YourReview = GameReview.CreateFromEntity(yourReview, this.TargetUserId); } - #nullable disable - - Dictionary playerCounts = RoomHelper.GetUserLevelPlayerCounts(); +#nullable disable - this.PlayerCount = playerCounts.TryGetValue(this.SlotId, out int playerCount) - ? playerCount - : 0; + this.PlayerCount = playerCountService.GetPlayerCount(this.SlotId); } -} \ No newline at end of file +}