From 7000e5e538555d6d61875a739003442f427cecee Mon Sep 17 00:00:00 2001 From: Beyley Thomas Date: Thu, 25 Jul 2024 17:54:41 -0700 Subject: [PATCH] Implement level tagging --- .../Database/GameDatabaseContext.Levels.cs | 19 ++ .../Database/GameDatabaseContext.Relations.cs | 33 ++- .../Database/GameDatabaseContext.cs | 1 + .../Database/GameDatabaseProvider.cs | 1 + .../Response/Levels/ApiGameLevelResponse.cs | 2 + .../DataTypes/Response/GameLevelResponse.cs | 2 + .../Game/Handshake/MetadataEndpoints.cs | 6 +- .../Endpoints/Game/RelationEndpoints.cs | 23 +++ .../Types/Levels/Categories/ByTagCategory.cs | 37 ++++ .../Levels/Categories/CategoryService.cs | 1 + .../Types/Levels/GameMinimalLevelResponse.cs | 4 +- Refresh.GameServer/Types/Levels/Tag.cs | 193 ++++++++++++++++++ .../Types/Relations/TagLevelRelation.cs | 20 ++ 13 files changed, 338 insertions(+), 4 deletions(-) create mode 100644 Refresh.GameServer/Types/Levels/Categories/ByTagCategory.cs create mode 100644 Refresh.GameServer/Types/Levels/Tag.cs create mode 100644 Refresh.GameServer/Types/Relations/TagLevelRelation.cs diff --git a/Refresh.GameServer/Database/GameDatabaseContext.Levels.cs b/Refresh.GameServer/Database/GameDatabaseContext.Levels.cs index 42e53957..c968ac8c 100644 --- a/Refresh.GameServer/Database/GameDatabaseContext.Levels.cs +++ b/Refresh.GameServer/Database/GameDatabaseContext.Levels.cs @@ -153,6 +153,7 @@ public void DeleteLevel(GameLevel level) this.QueueLevelRelations.RemoveRange(r => r.Level == level); this.RateLevelRelations.RemoveRange(r => r.Level == level); this.UniquePlayLevelRelations.RemoveRange(r => r.Level == level); + this.TagLevelRelations.RemoveRange(r => r.Level == level); IQueryable scores = this.GameSubmittedScores.Where(r => r.Level == level); @@ -241,6 +242,24 @@ public DatabaseList GetMostHeartedLevels(int count, int skip, GameUse return new DatabaseList(mostHeartedLevels, skip, count); } + [Pure] + public DatabaseList GetLevelsByTag(int count, int skip, GameUser? user, Tag tag, LevelFilterSettings levelFilterSettings) + { + IQueryable tagRelations = this.TagLevelRelations; + + IEnumerable filteredTaggedLevels = tagRelations + .Where(x => x._Tag == (int)tag) + .AsEnumerable() + .Select(x => x.Level) + .Distinct() + .Where(l => l._Source == (int)GameLevelSource.User) + .OrderByDescending(l => l.PublishDate) + .FilterByLevelFilterSettings(user, levelFilterSettings) + .FilterByGameVersion(levelFilterSettings.GameVersion); + + return new DatabaseList(filteredTaggedLevels, skip, count); + } + [Pure] public DatabaseList GetMostUniquelyPlayedLevels(int count, int skip, GameUser? user, LevelFilterSettings levelFilterSettings) { diff --git a/Refresh.GameServer/Database/GameDatabaseContext.Relations.cs b/Refresh.GameServer/Database/GameDatabaseContext.Relations.cs index 60df85b2..e995cbb9 100644 --- a/Refresh.GameServer/Database/GameDatabaseContext.Relations.cs +++ b/Refresh.GameServer/Database/GameDatabaseContext.Relations.cs @@ -1,5 +1,4 @@ using System.Diagnostics.Contracts; -using Realms; using Refresh.GameServer.Authentication; using Refresh.GameServer.Endpoints.Game.Levels.FilterSettings; using Refresh.GameServer.Extensions; @@ -493,4 +492,36 @@ public bool RateLevelComment(GameUser user, GameLevelComment comment, RatingType => this.RateComment(user, comment, ratingType, this.LevelCommentRelations); #endregion + + #region Tags + + public void AddTagRelation(GameUser user, GameLevel level, Tag tag) + { + this.Write(() => + { + // Remove any old tags from this user on this level + this.TagLevelRelations.RemoveRange(this.TagLevelRelations.Where(t => t.User == user && t.Level == level)); + + this.TagLevelRelations.Add(new TagLevelRelation + { + Tag = tag, + User = user, + Level = level, + }); + }); + } + + public IEnumerable GetTagsForLevel(GameLevel level) + { + IQueryable levelTags = this.TagLevelRelations.Where(t => t.Level == level); + + IOrderedEnumerable tags = levelTags + .AsEnumerable() + .DistinctBy(t => t._Tag) + .OrderByDescending(t => levelTags.Count(levelTag => levelTag._Tag == t._Tag)); + + return tags; + } + + #endregion } \ No newline at end of file diff --git a/Refresh.GameServer/Database/GameDatabaseContext.cs b/Refresh.GameServer/Database/GameDatabaseContext.cs index 6df31a23..408dd7f2 100644 --- a/Refresh.GameServer/Database/GameDatabaseContext.cs +++ b/Refresh.GameServer/Database/GameDatabaseContext.cs @@ -56,6 +56,7 @@ public partial class GameDatabaseContext : RealmDatabaseContext private RealmDbSet GameReviews => new(this._realm); private RealmDbSet DisallowedUsers => new(this._realm); private RealmDbSet RateReviewRelations => new(this._realm); + private RealmDbSet TagLevelRelations => new(this._realm); internal GameDatabaseContext(IDateTimeProvider time) { diff --git a/Refresh.GameServer/Database/GameDatabaseProvider.cs b/Refresh.GameServer/Database/GameDatabaseProvider.cs index 07673d57..37463c96 100644 --- a/Refresh.GameServer/Database/GameDatabaseProvider.cs +++ b/Refresh.GameServer/Database/GameDatabaseProvider.cs @@ -70,6 +70,7 @@ protected GameDatabaseProvider(IDateTimeProvider time) typeof(GameReview), typeof(DisallowedUser), typeof(RateReviewRelation), + typeof(TagLevelRelation), }; public override void Warmup() diff --git a/Refresh.GameServer/Endpoints/ApiV3/DataTypes/Response/Levels/ApiGameLevelResponse.cs b/Refresh.GameServer/Endpoints/ApiV3/DataTypes/Response/Levels/ApiGameLevelResponse.cs index dd2a021d..ef7cd0be 100644 --- a/Refresh.GameServer/Endpoints/ApiV3/DataTypes/Response/Levels/ApiGameLevelResponse.cs +++ b/Refresh.GameServer/Endpoints/ApiV3/DataTypes/Response/Levels/ApiGameLevelResponse.cs @@ -49,6 +49,7 @@ public class ApiGameLevelResponse : IApiResponse, IDataConvertableFrom Tags { get; set; } public static ApiGameLevelResponse? FromOld(GameLevel? level, DataContext dataContext) { @@ -86,6 +87,7 @@ public class ApiGameLevelResponse : IApiResponse, IDataConvertableFrom t.Tag), }; } diff --git a/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameLevelResponse.cs b/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameLevelResponse.cs index 0e45b364..c9b070a9 100644 --- a/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameLevelResponse.cs +++ b/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameLevelResponse.cs @@ -79,6 +79,7 @@ public class GameLevelResponse : IDataConvertableFrom /// Provides a unique level ID for ~1.1 billion hashed levels, uses the hash directly, so this is deterministic @@ -174,6 +175,7 @@ public static GameLevelResponse FromHash(string hash, DataContext dataContext) AverageStarRating = old.CalculateAverageStarRating(dataContext.Database), ReviewCount = old.Reviews.Count, CommentCount = dataContext.Database.GetTotalCommentsForLevel(old), + Tags = string.Join(',', dataContext.Database.GetTagsForLevel(old).Select(t => t.Tag.ToLbpString())) , }; response.Type = "user"; diff --git a/Refresh.GameServer/Endpoints/Game/Handshake/MetadataEndpoints.cs b/Refresh.GameServer/Endpoints/Game/Handshake/MetadataEndpoints.cs index 1f0b3e86..f9c44f29 100644 --- a/Refresh.GameServer/Endpoints/Game/Handshake/MetadataEndpoints.cs +++ b/Refresh.GameServer/Endpoints/Game/Handshake/MetadataEndpoints.cs @@ -1,4 +1,3 @@ -using System.Xml.Serialization; using Bunkum.Core; using Bunkum.Core.Endpoints; using Bunkum.Core.Endpoints.Debugging; @@ -7,8 +6,8 @@ using Bunkum.Protocols.Http; using Refresh.GameServer.Database; using Refresh.GameServer.Time; -using Refresh.GameServer.Types; using Refresh.GameServer.Types.Challenges; +using Refresh.GameServer.Types.Levels; using Refresh.GameServer.Types.Roles; using Refresh.GameServer.Types.UserData; @@ -191,4 +190,7 @@ public SerializedGameChallengeList ChallengeConfig(RequestContext context, IDate Challenges = [], }; } + + [GameEndpoint("tags")] + public string Tags(RequestContext context) => TagExtensions.AllTags; } \ No newline at end of file diff --git a/Refresh.GameServer/Endpoints/Game/RelationEndpoints.cs b/Refresh.GameServer/Endpoints/Game/RelationEndpoints.cs index acb78044..cb536efc 100644 --- a/Refresh.GameServer/Endpoints/Game/RelationEndpoints.cs +++ b/Refresh.GameServer/Endpoints/Game/RelationEndpoints.cs @@ -124,4 +124,27 @@ public Response ClearQueue(RequestContext context, GameDatabaseContext database, database.ClearQueue(user); return OK; } + + [GameEndpoint("tag/{slotType}/{id}", HttpMethods.Post)] + public Response SubmitTagsForLevel(RequestContext context, GameDatabaseContext database, GameUser user, string slotType, int id, string body) + { + GameLevel? level = database.GetLevelByIdAndType(slotType, id); + + if (level == null) + return NotFound; + + // The format of the POST body is `t=TAG_Name`, so assert this is followed + if (!body.StartsWith("t=")) + return BadRequest; + + Tag? tag = TagExtensions.FromLbpString(body[2..]); + + // If it was an invalid tag, return BadRequest + if (tag == null) + return BadRequest; + + database.AddTagRelation(user, level, tag.Value); + + return OK; + } } diff --git a/Refresh.GameServer/Types/Levels/Categories/ByTagCategory.cs b/Refresh.GameServer/Types/Levels/Categories/ByTagCategory.cs new file mode 100644 index 00000000..55d88e5b --- /dev/null +++ b/Refresh.GameServer/Types/Levels/Categories/ByTagCategory.cs @@ -0,0 +1,37 @@ +using Bunkum.Core; +using Refresh.GameServer.Database; +using Refresh.GameServer.Endpoints.Game.Levels.FilterSettings; +using Refresh.GameServer.Services; +using Refresh.GameServer.Types.UserData; + +namespace Refresh.GameServer.Types.Levels.Categories; + +public class ByTagCategory : LevelCategory +{ + internal ByTagCategory() : base("tag", "tag", false) + { + // Technically this category can apply to any user, but since we fallback to the regular user this name & description still applies + this.Name = "Tag Search"; + this.Description = "Search for levels using tags given by users like you!"; + this.IconHash = "g820605"; + this.FontAwesomeIcon = "tag"; + this.Hidden = true; // The by-tag category is not meant to be shown, as it requires a special implementation on all frontends + } + + public override DatabaseList? Fetch(RequestContext context, int skip, int count, + MatchService matchService, GameDatabaseContext database, GameUser? accessor, + LevelFilterSettings levelFilterSettings, GameUser? user) + { + string? tagStr = context.QueryString["tag"]; + + if (tagStr == null) + return null; + + Tag? tag = TagExtensions.FromLbpString(tagStr); + + if (tag == null) + return null; + + return database.GetLevelsByTag(count, skip, user, tag.Value, levelFilterSettings); + } +} \ No newline at end of file diff --git a/Refresh.GameServer/Types/Levels/Categories/CategoryService.cs b/Refresh.GameServer/Types/Levels/Categories/CategoryService.cs index 5470fa47..52e694eb 100644 --- a/Refresh.GameServer/Types/Levels/Categories/CategoryService.cs +++ b/Refresh.GameServer/Types/Levels/Categories/CategoryService.cs @@ -28,6 +28,7 @@ public class CategoryService : EndpointService new QueuedLevelsByUserCategory(), new SearchLevelCategory(), + new ByTagCategory(), new DeveloperLevelsCategory(), new ContestCategory(), ]; diff --git a/Refresh.GameServer/Types/Levels/GameMinimalLevelResponse.cs b/Refresh.GameServer/Types/Levels/GameMinimalLevelResponse.cs index d963a78d..f1e8ce52 100644 --- a/Refresh.GameServer/Types/Levels/GameMinimalLevelResponse.cs +++ b/Refresh.GameServer/Types/Levels/GameMinimalLevelResponse.cs @@ -47,7 +47,8 @@ public class GameMinimalLevelResponse : IDataConvertableFrom @@ -99,6 +100,7 @@ public static GameMinimalLevelResponse FromHash(string hash, DataContext dataCon IsSubLevel = level.IsSubLevel, IsCopyable = level.IsCopyable, PlayerCount = dataContext.Match.GetPlayerCountForLevel(RoomSlotType.Online, level.LevelId), + Tags = level.Tags, }; } diff --git a/Refresh.GameServer/Types/Levels/Tag.cs b/Refresh.GameServer/Types/Levels/Tag.cs new file mode 100644 index 00000000..687e1a41 --- /dev/null +++ b/Refresh.GameServer/Types/Levels/Tag.cs @@ -0,0 +1,193 @@ +using System.Text; + +namespace Refresh.GameServer.Types.Levels; + +public enum Tag : byte +{ + Boss = 0, + Varied = 1, + Repetitive = 2, + MultiPath = 3, + SinglePath = 4, + Frustrating = 5, + Relaxing = 6, + Coop = 7, + Competitive = 8, + Fun = 9, + Funny = 10, + Complex = 11, + Simple = 12, + Long = 13, + Short = 14, + Quick = 15, + Slow = 16, + Tricky = 17, + Horizontal = 18, + Vertical = 19, + Musical = 20, + Moody = 21, + Timing = 22, + Perilous = 23, + NerveWracking = 24, + Cute = 25, + Mad = 26, + Hectic = 27, + Creepy = 28, + Daft = 29, + Hilarious = 30, + Puzzler = 31, + Platformer = 32, + Speedy = 33, + Fast = 34, + PointsFest = 35, + Artistic = 36, + Funky = 37, + Empty = 38, + Mechanical = 39, + Race = 40, + Fiery = 41, + Spikes = 42, + Vehicles = 43, + Ramps = 44, + Machines = 45, + Toys = 46, + Stickers = 47, + Gas = 48, + Secrets = 49, + Collectables = 50, + Braaains = 51, + Hoists = 52, + Bubbly = 53, + Swingy = 54, + Balancing = 55, + Floaty = 56, + Springy = 57, + Machinery = 58, + Annoying = 59, + Satisfying = 60, + Brilliant = 61, + Great = 62, + Good = 63, + Rubbish = 64, + Pretty = 65, + Ugly = 66, + Difficult = 67, + Easy = 68, + Weird = 69, + Boring = 70, + Splendid = 71, + Lousy = 72, + Ingenious = 73, + Beautiful = 74, + Electric = 75, +} + +public static class TagExtensions +{ + static TagExtensions() + { + StringBuilder allTags = new(); + + // Create the conversion which goes the other way + foreach ((string? key, Tag value) in TagsMap) + { + StringMap[value] = key; + + allTags.Append(key); + allTags.Append(','); + } + + if (allTags.Length > 1) + allTags.Remove(allTags.Length - 1, 1); + + AllTags = allTags.ToString(); + } + + public static string AllTags { get; private set; } + + // C# doesnt seem to have a better construct for this... + private static readonly Dictionary StringMap = new(); + private static readonly Dictionary TagsMap = new() + { + { "TAG_Boss", Tag.Boss }, + { "TAG_Varied", Tag.Varied }, + { "TAG_Repetitive", Tag.Repetitive }, + { "TAG_Multi-Path", Tag.MultiPath }, + { "TAG_Single-Path", Tag.SinglePath }, + { "TAG_Frustrating", Tag.Frustrating }, + { "TAG_Relaxing", Tag.Relaxing }, + { "TAG_Co-op", Tag.Coop }, + { "TAG_Competitive", Tag.Competitive }, + { "TAG_Fun", Tag.Fun }, + { "TAG_Funny", Tag.Funny }, + { "TAG_Complex", Tag.Complex }, + { "TAG_Simple", Tag.Simple }, + { "TAG_Long", Tag.Long }, + { "TAG_Short", Tag.Short }, + { "TAG_Quick", Tag.Quick }, + { "TAG_Slow", Tag.Slow }, + { "TAG_Tricky", Tag.Tricky }, + { "TAG_Horizontal", Tag.Horizontal }, + { "TAG_Vertical", Tag.Vertical }, + { "TAG_Musical", Tag.Musical }, + { "TAG_Moody", Tag.Moody }, + { "TAG_Timing", Tag.Timing }, + { "TAG_Perilous", Tag.Perilous }, + { "TAG_Nerve-wracking", Tag.NerveWracking }, + { "TAG_Cute", Tag.Cute }, + { "TAG_Mad", Tag.Mad }, + { "TAG_Hectic", Tag.Hectic }, + { "TAG_Creepy", Tag.Creepy }, + { "TAG_Daft", Tag.Daft }, + { "TAG_Hilarious", Tag.Hilarious }, + { "TAG_Puzzler", Tag.Puzzler }, + { "TAG_Platformer", Tag.Platformer }, + { "TAG_Speedy", Tag.Speedy }, + { "TAG_Fast", Tag.Fast }, + { "TAG_Points-Fest", Tag.PointsFest }, + { "TAG_Artistic", Tag.Artistic }, + { "TAG_Funky", Tag.Funky }, + { "TAG_Empty", Tag.Empty }, + { "TAG_Mechanical", Tag.Mechanical }, + { "TAG_Race", Tag.Race }, + { "TAG_Fiery", Tag.Fiery }, + { "TAG_Spikes", Tag.Spikes }, + { "TAG_Vehicles", Tag.Vehicles }, + { "TAG_Ramps", Tag.Ramps }, + { "TAG_Machines", Tag.Machines }, + { "TAG_Toys", Tag.Toys }, + { "TAG_Stickers", Tag.Stickers }, + { "TAG_Gas", Tag.Gas }, + { "TAG_Secrets", Tag.Secrets }, + { "TAG_Collectables", Tag.Collectables }, + { "TAG_Braaains", Tag.Braaains }, + { "TAG_Hoists", Tag.Hoists }, + { "TAG_Bubbly", Tag.Bubbly }, + { "TAG_Swingy", Tag.Swingy }, + { "TAG_Balancing", Tag.Balancing }, + { "TAG_Floaty", Tag.Floaty }, + { "TAG_Springy", Tag.Springy }, + { "TAG_Machinery", Tag.Machinery }, + { "TAG_Annoying", Tag.Annoying }, + { "TAG_Satisfying", Tag.Satisfying }, + { "TAG_Brilliant", Tag.Brilliant }, + { "TAG_Great", Tag.Great }, + { "TAG_Good", Tag.Good }, + { "TAG_Rubbish", Tag.Rubbish }, + { "TAG_Pretty", Tag.Pretty }, + { "TAG_Ugly", Tag.Ugly }, + { "TAG_Difficult", Tag.Difficult }, + { "TAG_Easy", Tag.Easy }, + { "TAG_Weird", Tag.Weird }, + { "TAG_Boring", Tag.Boring }, + { "TAG_Splendid", Tag.Splendid }, + { "TAG_Lousy", Tag.Lousy }, + { "TAG_Ingenious", Tag.Ingenious }, + { "TAG_Beautiful", Tag.Beautiful }, + { "TAG_Electric", Tag.Electric }, + }; + + public static string? ToLbpString(this Tag tag) => StringMap.GetValueOrDefault(tag); + + public static Tag? FromLbpString(string str) => TagsMap.GetValueOrDefault(str); +} \ No newline at end of file diff --git a/Refresh.GameServer/Types/Relations/TagLevelRelation.cs b/Refresh.GameServer/Types/Relations/TagLevelRelation.cs new file mode 100644 index 00000000..c4cdd04f --- /dev/null +++ b/Refresh.GameServer/Types/Relations/TagLevelRelation.cs @@ -0,0 +1,20 @@ +using Realms; +using Refresh.GameServer.Types.Levels; +using Refresh.GameServer.Types.UserData; + +namespace Refresh.GameServer.Types.Relations; + +public partial class TagLevelRelation : IRealmObject +{ + [Ignored] + public Tag Tag + { + get => (Tag)this._Tag; + set => this._Tag = (byte)value; + } + + public byte _Tag { get; set; } + + public GameUser User { get; set; } + public GameLevel Level { get; set; } +} \ No newline at end of file