diff --git a/ProjectLighthouse.Servers.GameServer/Controllers/Matching/EnterLevelController.cs b/ProjectLighthouse.Servers.GameServer/Controllers/Matching/EnterLevelController.cs index e8b2aa37a..0d8038982 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) + { + await this.database.RecordRecentlyPlayedLevel(token.UserId, slotId); + } + await this.database.SaveChangesAsync(); return this.Ok(); @@ -134,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 60ac1c580..8f2fc2d11 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,10 +81,11 @@ 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 { + 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 +95,21 @@ public async Task GetCategorySlots(string endpointName) return this.Ok(returnList); } + private async Task GetRecommendedCategory(RecommendedCategory recommendedCategory, GameTokenEntity token, SlotQueryBuilder queryBuilder, PaginationData pageData) + { + IQueryable recommendations = recommendedCategory.GetScoredItems(this.database, token, queryBuilder); + + pageData.TotalElements = await recommendations.CountAsync(); + + recommendations = recommendations.ApplyPagination(pageData); + + List slots = (await recommendations.ToListAsync()) + .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(); @@ -121,53 +136,62 @@ 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 + + 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..6f7168630 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)); } @@ -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 new file mode 100644 index 000000000..e9d3e01cb --- /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 string[] Sorts { get; } = ["relevance",]; + + public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) + { + Dictionary playerCounts = RoomHelper.GetUserLevelPlayerCounts(); + + 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); + } +} diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/CategoryHelper.cs index c4bfc5fda..f10677bd3 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,19 +11,32 @@ public static class CategoryHelper static CategoryHelper() { - Categories.Add(new TeamPicksCategory()); - Categories.Add(new MostHeartedCategory()); - Categories.Add(new NewestLevelsCategory()); - 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 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.Categories) + { + 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/HeartedCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/HeartedCategory.cs index 0a9eeee11..4d44e3d18 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/HeartedCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/HeartedCategory.cs @@ -13,10 +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 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 06727020a..3e1bb9937 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/HighestRatedCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/HighestRatedCategory.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; using LBPUnion.ProjectLighthouse.Types.Misc; namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Types.Categories; @@ -10,10 +11,16 @@ 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 @@ -24,4 +31,4 @@ public override IQueryable GetItems(DatabaseContext database, GameTo .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 6d8528b4d..76b3adef9 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/LuckyDipCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/LuckyDipCategory.cs @@ -15,7 +15,11 @@ 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; + public override bool DisableFilters => true; + public override bool DefaultToCurrentGame => false; public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) { @@ -23,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 c06ba54dc..0185b7c3e 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,6 +17,12 @@ 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 @@ -26,4 +33,4 @@ public override IQueryable GetItems(DatabaseContext database, GameTo .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 68e725ee9..427cb308f 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,9 +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()) .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 3c86d5661..dd002201a 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/NewestLevelsCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/NewestLevelsCategory.cs @@ -15,8 +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 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..0cebb4e1e 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/QueueCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/QueueCategory.cs @@ -13,10 +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 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 new file mode 100644 index 000000000..f7ed47062 --- /dev/null +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/RecentlyPlayedCategory.cs @@ -0,0 +1,32 @@ +#nullable enable + +using System.Linq; +using LBPUnion.ProjectLighthouse.Configuration; +using LBPUnion.ProjectLighthouse.Database; +using LBPUnion.ProjectLighthouse.Filter; +using LBPUnion.ProjectLighthouse.Types.Entities.Level; +using LBPUnion.ProjectLighthouse.Types.Entities.Token; + +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 string[] Sorts { get; } = ["relevance",]; + + public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) + { + 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 new file mode 100644 index 000000000..6d04a6bd9 --- /dev/null +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/RecommendedCategory.cs @@ -0,0 +1,220 @@ +#nullable enable + +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.Serialization; +using Microsoft.EntityFrameworkCore; + +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"; + + 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!; + public int SearchScore { get; set; } + public int PrevSearchScore { get; set; } + public int Hearts { get; set; } + public int Likes { get; set; } + } + + private IQueryable GetSearchScores(DatabaseContext database, GameTokenEntity token) + { + RecommendedCategoryConfig config = CategoryConfiguration.Instance.Recommended; + + IQueryable seedUserIds = database.HeartedProfiles + .Where(heartedProfile => heartedProfile.UserId == token.UserId) + .Select(heartedProfile => heartedProfile.HeartedUserId) + .Distinct(); + + IQueryable seedTasteSlotIds = database.HeartedLevels + .Where(heartedLevel => seedUserIds.Contains(heartedLevel.UserId)) + .Select(heartedLevel => heartedLevel.SlotId) + .Distinct(); + + IQueryable neighborUserIds = database.HeartedLevels + .Where(heartedLevel => seedTasteSlotIds.Contains(heartedLevel.SlotId)) + .Where(heartedLevel => heartedLevel.UserId != token.UserId && !seedUserIds.Contains(heartedLevel.UserId)) + .GroupBy(heartedLevel => heartedLevel.UserId) + .Select(group => new + { + UserId = group.Key, + Overlap = group + .Select(heartedLevel => heartedLevel.SlotId) + .Distinct() + .Count(), + }) + .Where(user => user.Overlap >= config.MinimumNeighborOverlap) + .OrderByDescending(user => user.Overlap) + .ThenBy(user => user.UserId) + .Take(config.MaxNeighbors) + .Select(user => user.UserId); + + var directContributions = database.HeartedLevels + .Where(heartedLevel => seedUserIds.Contains(heartedLevel.UserId)) + .Select(heartedLevel => new + { + heartedLevel.SlotId, + heartedLevel.UserId, + }) + .Distinct() + .Select(heartedLevel => new + { + heartedLevel.SlotId, + SearchScore = 1, + PrevSearchScore = 1, + }); + + var neighborContributions = + from heartedLevel in database.HeartedLevels + join neighborUserId in neighborUserIds + on heartedLevel.UserId equals neighborUserId + select new + { + heartedLevel.SlotId, + heartedLevel.UserId, + }; + + var distinctNeighborContributions = neighborContributions + .Distinct() + .Select(heartedLevel => new + { + heartedLevel.SlotId, + SearchScore = 1, + PrevSearchScore = 0, + }); + + var creatorContributions = database.Slots + .Where(slot => seedUserIds.Contains(slot.CreatorId)) + .Select(slot => new + { + slot.SlotId, + SearchScore = 1, + PrevSearchScore = 0, + }); + + return directContributions + .Concat(distinctNeighborContributions) + .Concat(creatorContributions) + .GroupBy(contribution => contribution.SlotId) + .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 + .Where(queryBuilder.Build()) + .Where(slot => !database.VisitedLevels.Any(visitedLevel => + visitedLevel.UserId == token.UserId && + visitedLevel.SlotId == slot.SlotId)) + + join score in scores + on slot.SlotId equals score.SlotId + + join heartCount in heartCounts + on slot.SlotId equals heartCount.SlotId into heartCountGroup + from heartCount in heartCountGroup.DefaultIfEmpty() + + join likeCount in likeCounts + on slot.SlotId equals likeCount.SlotId into likeCountGroup + from likeCount in likeCountGroup.DefaultIfEmpty() + + orderby + score.SearchScore descending, + score.PrevSearchScore descending, + heartCount.Count descending, + likeCount.Count descending, + slot.SlotId descending + + select new ScoredSlot + { + Slot = slot, + SearchScore = score.SearchScore, + PrevSearchScore = score.PrevSearchScore, + Hearts = heartCount.Count ?? 0, + Likes = likeCount.Count ?? 0, + }; + + return recommendations; + } + + public override IQueryable GetItems(DatabaseContext database, GameTokenEntity token, SlotQueryBuilder queryBuilder) => + this.GetScoredItems(database, token, queryBuilder) + .Select(recommendation => recommendation.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) + { + IQueryable recommendations = this.GetScoredItems(database, token, queryBuilder); + + List serializedSlots = (await recommendations + .Take(numResults) + .ToListAsync()) + .Select(recommendation => CreateSerializableSlot(recommendation, token)) + .ToList(); + + int totalSlots = await recommendations.CountAsync(); + + return GameCategory.CreateFromEntity(this, new GenericSerializableList(serializedSlots, totalSlots, numResults + 1)); + } +} diff --git a/ProjectLighthouse.Servers.GameServer/Types/Categories/TeamPicksCategory.cs b/ProjectLighthouse.Servers.GameServer/Types/Categories/TeamPicksCategory.cs index 606557a77..23d8c20a7 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/Categories/TeamPicksCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/Categories/TeamPicksCategory.cs @@ -16,8 +16,10 @@ 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()) .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 db80abe0a..357ad6de3 100644 --- a/ProjectLighthouse.Servers.GameServer/Types/MyPlaylistsCategory.cs +++ b/ProjectLighthouse.Servers.GameServer/Types/MyPlaylistsCategory.cs @@ -13,7 +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 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 new file mode 100644 index 000000000..1d7f46507 --- /dev/null +++ b/ProjectLighthouse/Configuration/CategoryConfiguration.cs @@ -0,0 +1,52 @@ +#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; } = 2; + public override string ConfigName { get; set; } = "CategoryConfig.yml"; + public override bool NeedsConfiguration { get; set; } = false; + + public List Categories { get; set; } = new() + { + "recently_played", + "recommended", + "team_picks", + "most_hearted", + "newest", + "busiest", + "most_played", + "my_playlists", + "queue", + "hearted_levels", + "highest_rated", + "lucky_dip", + }; + + 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; +} diff --git a/ProjectLighthouse/Database/DatabaseContext.Slots.cs b/ProjectLighthouse/Database/DatabaseContext.Slots.cs index 41d3e92cd..efdec16b6 100644 --- a/ProjectLighthouse/Database/DatabaseContext.Slots.cs +++ b/ProjectLighthouse/Database/DatabaseContext.Slots.cs @@ -1,5 +1,9 @@ #nullable enable +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; @@ -87,4 +91,34 @@ public async Task UnqueueLevel(int userId, SlotEntity queuedSlot) await this.SaveChangesAsync(); } -} \ No newline at end of file + public async Task RecordRecentlyPlayedLevel(int userId, int slotId) + { + long now = TimeHelper.TimestampMillis; + int maxLevels = CategoryConfiguration.Instance.RecentlyPlayed.MaxLevels; + + RecentlyPlayedEntity? recentlyPlayed = await this.RecentlyPlayed + .FirstOrDefaultAsync(r => r.UserId == userId && r.SlotId == slotId); + + if (recentlyPlayed == null) + { + this.RecentlyPlayed.Add(new RecentlyPlayedEntity + { + UserId = userId, + SlotId = slotId, + LastPlayedAt = now, + }); + } + else + { + recentlyPlayed.LastPlayedAt = now; + } + + List excessEntries = await this.RecentlyPlayed + .Where(r => r.UserId == userId && r.SlotId != slotId) + .OrderByDescending(r => r.LastPlayedAt) + .Skip(maxLevels - 1) + .ToListAsync(); + + this.RecentlyPlayed.RemoveRange(excessEntries); + } +} diff --git a/ProjectLighthouse/Database/DatabaseContext.cs b/ProjectLighthouse/Database/DatabaseContext.cs index 0a2e09e15..65f79ea00 100644 --- a/ProjectLighthouse/Database/DatabaseContext.cs +++ b/ProjectLighthouse/Database/DatabaseContext.cs @@ -53,6 +53,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 @@ -88,4 +89,4 @@ public static DatabaseContext CreateNewInstance() 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 47d0771c3..3fa5aa581 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) { @@ -255,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.cs b/ProjectLighthouse/Migrations/20260812004441_AddRecentlyPlayed.cs new file mode 100644 index 000000000..7de5a26bb --- /dev/null +++ b/ProjectLighthouse/Migrations/20260812004441_AddRecentlyPlayed.cs @@ -0,0 +1,65 @@ +using LBPUnion.ProjectLighthouse.Database; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LBPUnion.ProjectLighthouse.Migrations +{ + /// + [DbContext(typeof(DatabaseContext))] + [Migration("20260812004441_AddRecentlyPlayed")] + 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), + 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, + principalTable: "Users", + principalColumn: "UserId", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_RecentlyPlayed_SlotId", + table: "RecentlyPlayed", + column: "SlotId"); + + migrationBuilder.CreateIndex( + name: "IX_RecentlyPlayed_UserId_SlotId", + table: "RecentlyPlayed", + columns: new[] { "UserId", "SlotId" }, + 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 817a93916..816330f4c 100644 --- a/ProjectLighthouse/Migrations/DatabaseContextModelSnapshot.cs +++ b/ProjectLighthouse/Migrations/DatabaseContextModelSnapshot.cs @@ -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") + .HasColumnType("bigint"); + + b.Property("SlotId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("RecentlyPlayedId"); + + b.HasIndex("SlotId"); + + b.HasIndex("UserId", "SlotId") + .IsUnique(); + + b.ToTable("RecentlyPlayed"); + }); + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.VisitedLevelEntity", b => { b.Property("VisitedLevelId") @@ -1300,6 +1327,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + 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"); + }); + modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.VisitedLevelEntity", b => { b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot") 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 new file mode 100644 index 000000000..e1241aef5 --- /dev/null +++ b/ProjectLighthouse/Types/Entities/Interaction/RecentlyPlayedEntity.cs @@ -0,0 +1,25 @@ +#nullable enable + +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), nameof(SlotId), 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 int SlotId { get; set; } + + [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 c18a4a00a..639916374 100644 --- a/ProjectLighthouse/Types/Levels/Category.cs +++ b/ProjectLighthouse/Types/Levels/Category.cs @@ -18,14 +18,24 @@ 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) => 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 new file mode 100644 index 000000000..6f73bc592 --- /dev/null +++ b/ProjectLighthouse/Types/Levels/CategoryDefaults.cs @@ -0,0 +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; } +} diff --git a/ProjectLighthouse/Types/Serialization/GameCategory.cs b/ProjectLighthouse/Types/Serialization/GameCategory.cs index e097de974..d3e812493 100644 --- a/ProjectLighthouse/Types/Serialization/GameCategory.cs +++ b/ProjectLighthouse/Types/Serialization/GameCategory.cs @@ -1,4 +1,5 @@ -using System.ComponentModel; +#nullable enable +using System.ComponentModel; using System.Xml.Serialization; using LBPUnion.ProjectLighthouse.Types.Levels; @@ -9,46 +10,64 @@ 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 + [DefaultValue("")] + [XmlElement("param")] + public string? Param { get; set; } + + [DefaultValue(null)] + [XmlElement("defaultFilters")] + public CategoryDefaults? DefaultFilters { get; set; } [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, - }; -} \ No newline at end of file + 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, + }; +} diff --git a/ProjectLighthouse/Types/Serialization/GameUserSlot.cs b/ProjectLighthouse/Types/Serialization/GameUserSlot.cs index 978818be4..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; @@ -42,6 +42,14 @@ public class GameUserSlot : SlotBase, INeedsPreparationForSerialization [XmlElement("npHandle")] public NpHandle AuthorHandle { get; set; } = new(); + [DefaultValue(null)] + [XmlElement("searchScore")] + public double? SearchScore { get; set; } + + [DefaultValue(null)] + [XmlElement("prevSearchScore")] + public double? PrevSearchScore { get; set; } + [XmlElement("location")] public Location Location { get; set; } = new(); @@ -161,7 +169,8 @@ public class GameUserSlot : SlotBase, INeedsPreparationForSerialization // 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(); } @@ -176,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 @@ -232,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 @@ -273,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); @@ -292,9 +301,9 @@ public async Task PrepareSerialization(DatabaseContext database) { this.YourReview = GameReview.CreateFromEntity(yourReview, this.TargetUserId); } - #nullable disable +#nullable disable - this.PlayerCount = RoomHelper.Rooms.Count(r => r.Slot.SlotType == SlotType.User && r.Slot.SlotId == this.SlotId); + this.PlayerCount = playerCountService.GetPlayerCount(this.SlotId); } -} \ No newline at end of file +}