Skip to content

Commit

Permalink
Implement level tagging (#585)
Browse files Browse the repository at this point in the history
This is the first part of #260, implementing in-game level tagging for
LBP1.

This also exposes an APIv3 endpoint for getting a list of levels by tag,
and also shows the tags of a level on API level responses.
  • Loading branch information
jvyden authored Jul 26, 2024
2 parents 4638747 + bd647c3 commit c5875a2
Show file tree
Hide file tree
Showing 13 changed files with 338 additions and 4 deletions.
19 changes: 19 additions & 0 deletions Refresh.GameServer/Database/GameDatabaseContext.Levels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<GameSubmittedScore> scores = this.GameSubmittedScores.Where(r => r.Level == level);

Expand Down Expand Up @@ -241,6 +242,24 @@ public DatabaseList<GameLevel> GetMostHeartedLevels(int count, int skip, GameUse
return new DatabaseList<GameLevel>(mostHeartedLevels, skip, count);
}

[Pure]
public DatabaseList<GameLevel> GetLevelsByTag(int count, int skip, GameUser? user, Tag tag, LevelFilterSettings levelFilterSettings)
{
IQueryable<TagLevelRelation> tagRelations = this.TagLevelRelations;

IEnumerable<GameLevel> 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<GameLevel>(filteredTaggedLevels, skip, count);
}

[Pure]
public DatabaseList<GameLevel> GetMostUniquelyPlayedLevels(int count, int skip, GameUser? user, LevelFilterSettings levelFilterSettings)
{
Expand Down
33 changes: 32 additions & 1 deletion Refresh.GameServer/Database/GameDatabaseContext.Relations.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<TagLevelRelation> GetTagsForLevel(GameLevel level)
{
IQueryable<TagLevelRelation> levelTags = this.TagLevelRelations.Where(t => t.Level == level);

IOrderedEnumerable<TagLevelRelation> tags = levelTags
.AsEnumerable()
.DistinctBy(t => t._Tag)
.OrderByDescending(t => levelTags.Count(levelTag => levelTag._Tag == t._Tag));

return tags;
}

#endregion
}
1 change: 1 addition & 0 deletions Refresh.GameServer/Database/GameDatabaseContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public partial class GameDatabaseContext : RealmDatabaseContext
private RealmDbSet<GameReview> GameReviews => new(this._realm);
private RealmDbSet<DisallowedUser> DisallowedUsers => new(this._realm);
private RealmDbSet<RateReviewRelation> RateReviewRelations => new(this._realm);
private RealmDbSet<TagLevelRelation> TagLevelRelations => new(this._realm);

internal GameDatabaseContext(IDateTimeProvider time)
{
Expand Down
1 change: 1 addition & 0 deletions Refresh.GameServer/Database/GameDatabaseProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ protected GameDatabaseProvider(IDateTimeProvider time)
typeof(GameReview),
typeof(DisallowedUser),
typeof(RateReviewRelation),
typeof(TagLevelRelation),
};

public override void Warmup()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public class ApiGameLevelResponse : IApiResponse, IDataConvertableFrom<ApiGameLe
public required bool IsSubLevel { get; set; }
public required bool IsCopyable { get; set; }
public required float Score { get; set; }
public required IEnumerable<Tag> Tags { get; set; }

public static ApiGameLevelResponse? FromOld(GameLevel? level, DataContext dataContext)
{
Expand Down Expand Up @@ -86,6 +87,7 @@ public class ApiGameLevelResponse : IApiResponse, IDataConvertableFrom<ApiGameLe
PhotosTaken = dataContext.Database.GetTotalPhotosInLevel(level),
LevelComments = dataContext.Database.GetTotalCommentsForLevel(level),
Reviews = dataContext.Database.GetTotalReviewsForLevel(level),
Tags = dataContext.Database.GetTagsForLevel(level).Select(t => t.Tag),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ public class GameLevelResponse : IDataConvertableFrom<GameLevelResponse, GameLev
[XmlElement("reviewsEnabled")] public bool ReviewsEnabled { get; set; } = true;
[XmlElement("commentCount")] public int CommentCount { get; set; } = 0;
[XmlElement("commentsEnabled")] public bool CommentsEnabled { get; set; } = true;
[XmlElement("tags")] public string Tags { get; set; } = "";

/// <summary>
/// Provides a unique level ID for ~1.1 billion hashed levels, uses the hash directly, so this is deterministic
Expand Down Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Xml.Serialization;
using Bunkum.Core;
using Bunkum.Core.Endpoints;
using Bunkum.Core.Endpoints.Debugging;
Expand All @@ -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;

Expand Down Expand Up @@ -191,4 +190,7 @@ public SerializedGameChallengeList ChallengeConfig(RequestContext context, IDate
Challenges = [],
};
}

[GameEndpoint("tags")]
public string Tags(RequestContext context) => TagExtensions.AllTags;
}
23 changes: 23 additions & 0 deletions Refresh.GameServer/Endpoints/Game/RelationEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
37 changes: 37 additions & 0 deletions Refresh.GameServer/Types/Levels/Categories/ByTagCategory.cs
Original file line number Diff line number Diff line change
@@ -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<GameLevel>? 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public class CategoryService : EndpointService
new QueuedLevelsByUserCategory(),

new SearchLevelCategory(),
new ByTagCategory(),
new DeveloperLevelsCategory(),
new ContestCategory(),
];
Expand Down
4 changes: 3 additions & 1 deletion Refresh.GameServer/Types/Levels/GameMinimalLevelResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ public class GameMinimalLevelResponse : IDataConvertableFrom<GameMinimalLevelRes
[XmlElement("initiallyLocked")] public bool IsLocked { get; set; }
[XmlElement("isSubLevel")] public bool IsSubLevel { get; set; }
[XmlElement("shareable")] public int IsCopyable { get; set; }

[XmlElement("tags")] public string Tags { get; set; } = "";

private GameMinimalLevelResponse() {}

/// <summary>
Expand Down Expand Up @@ -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,
};
}

Expand Down
Loading

0 comments on commit c5875a2

Please sign in to comment.