From 84c3e88b000b63030c38d2fe0a5429fc9904e79e Mon Sep 17 00:00:00 2001 From: Beyley Thomas Date: Fri, 30 Aug 2024 18:19:44 -0700 Subject: [PATCH 01/12] Implement LBP1 playlists --- .../Database/GameDatabaseContext.Levels.cs | 26 +- .../Database/GameDatabaseContext.Playlists.cs | 154 ++++++++ .../Database/GameDatabaseContext.Users.cs | 11 +- .../Database/GameDatabaseContext.cs | 4 + .../Database/GameDatabaseProvider.cs | 86 +++-- .../DataTypes/Request/GameLevelRequest.cs | 2 +- .../DataTypes/Response/GameLevelResponse.cs | 69 +++- .../DataTypes/Response/GameUserResponse.cs | 2 + .../Game/Handshake/MetadataEndpoints.cs | 28 +- .../Endpoints/Game/Levels/LevelEndpoints.cs | 27 +- .../Game/Playlists/PlaylistEndpoints.cs | 338 ++++++++++++++++++ .../Endpoints/Game/ReportingEndpoints.cs | 4 +- .../Extensions/GamePlaylistExtensions.cs | 28 ++ Refresh.GameServer/Types/Levels/GameLevel.cs | 9 +- .../Types/Levels/GameLevelSource.cs | 26 -- .../Types/Levels/GameMinimalLevelResponse.cs | 51 ++- .../Types/Levels/GameSlotType.cs | 36 ++ .../Types/Matching/IRoomAccessor.cs | 4 +- .../Types/Playlists/GamePlaylist.cs | 58 +++ .../Types/Playlists/LevelPlaylistRelation.cs | 19 + .../Types/Playlists/SerializedPlaylist.cs | 41 +++ .../Types/Playlists/SubPlaylistRelation.cs | 18 + Refresh.GameServer/Types/UserData/GameUser.cs | 16 +- RefreshTests.GameServer/TestContext.cs | 2 +- .../Tests/Levels/UploadTests.cs | 8 +- 25 files changed, 957 insertions(+), 110 deletions(-) create mode 100644 Refresh.GameServer/Database/GameDatabaseContext.Playlists.cs create mode 100644 Refresh.GameServer/Endpoints/Game/Playlists/PlaylistEndpoints.cs create mode 100644 Refresh.GameServer/Extensions/GamePlaylistExtensions.cs delete mode 100644 Refresh.GameServer/Types/Levels/GameLevelSource.cs create mode 100644 Refresh.GameServer/Types/Levels/GameSlotType.cs create mode 100644 Refresh.GameServer/Types/Playlists/GamePlaylist.cs create mode 100644 Refresh.GameServer/Types/Playlists/LevelPlaylistRelation.cs create mode 100644 Refresh.GameServer/Types/Playlists/SerializedPlaylist.cs create mode 100644 Refresh.GameServer/Types/Playlists/SubPlaylistRelation.cs diff --git a/Refresh.GameServer/Database/GameDatabaseContext.Levels.cs b/Refresh.GameServer/Database/GameDatabaseContext.Levels.cs index 96220409..4641af1f 100644 --- a/Refresh.GameServer/Database/GameDatabaseContext.Levels.cs +++ b/Refresh.GameServer/Database/GameDatabaseContext.Levels.cs @@ -51,7 +51,7 @@ public GameLevel GetStoryLevelById(int id) { Title = $"Story level #{id}", Publisher = null, - Source = GameLevelSource.Story, + Source = GameSlotType.Story, StoryId = id, }; @@ -181,7 +181,7 @@ public void DeleteLevel(GameLevel level) } private IQueryable GetLevelsByGameVersion(TokenGame gameVersion) - => this.GameLevels.Where(l => l._Source == (int)GameLevelSource.User).FilterByGameVersion(gameVersion); + => this.GameLevels.Where(l => l._Source == (int)GameSlotType.User).FilterByGameVersion(gameVersion); [Pure] public DatabaseList GetLevelsByUser(GameUser user, int count, int skip, LevelFilterSettings levelFilterSettings, GameUser? accessor) @@ -209,11 +209,11 @@ public DatabaseList GetLevelsByUser(GameUser user, int count, int ski [Pure] public DatabaseList GetUserLevelsChunk(int skip, int count) - => new(this.GameLevels.Where(l => l._Source == (int)GameLevelSource.User), skip, count); + => new(this.GameLevels.Where(l => l._Source == (int)GameSlotType.User), skip, count); [Pure] public IQueryable GetAllUserLevels() - => this.GameLevels.Where(l => l._Source == (int)GameLevelSource.User); + => this.GameLevels.Where(l => l._Source == (int)GameSlotType.User); [Pure] public DatabaseList GetNewestLevels(int count, int skip, GameUser? user, LevelFilterSettings levelFilterSettings) => @@ -245,7 +245,7 @@ public DatabaseList GetMostHeartedLevels(int count, int skip, GameUse .OrderByDescending(x => x.Count) .Select(x => x.Level) .Where(l => l != null) - .Where(l => l._Source == (int)GameLevelSource.User) + .Where(l => l._Source == (int)GameSlotType.User) .FilterByLevelFilterSettings(user, levelFilterSettings) .FilterByGameVersion(levelFilterSettings.GameVersion); @@ -262,7 +262,7 @@ public DatabaseList GetLevelsByTag(int count, int skip, GameUser? use .AsEnumerable() .Select(x => x.Level) .Distinct() - .Where(l => l._Source == (int)GameLevelSource.User) + .Where(l => l._Source == (int)GameSlotType.User) .OrderByDescending(l => l.PublishDate) .FilterByLevelFilterSettings(user, levelFilterSettings) .FilterByGameVersion(levelFilterSettings.GameVersion); @@ -282,7 +282,7 @@ public DatabaseList GetMostUniquelyPlayedLevels(int count, int skip, .OrderByDescending(x => x.Count) .Select(x => x.Level) .Where(l => l != null) - .Where(l => l._Source == (int)GameLevelSource.User) + .Where(l => l._Source == (int)GameSlotType.User) .FilterByLevelFilterSettings(user, levelFilterSettings) .FilterByGameVersion(levelFilterSettings.GameVersion); @@ -319,7 +319,7 @@ public DatabaseList GetHighestRatedLevels(int count, int skip, GameUs .OrderByDescending(x => x.Karma) // reddit moment .Select(x => x.Level) .Where(l => l != null) - .Where(l => l._Source == (int)GameLevelSource.User) + .Where(l => l._Source == (int)GameSlotType.User) .FilterByLevelFilterSettings(user, levelFilterSettings) .FilterByGameVersion(levelFilterSettings.GameVersion); @@ -336,7 +336,7 @@ public DatabaseList GetTeamPickedLevels(int count, int skip, GameUser [Pure] public DatabaseList GetDeveloperLevels(int count, int skip, LevelFilterSettings levelFilterSettings) => new(this.GameLevels - .Where(l => l._Source == (int)GameLevelSource.Story) + .Where(l => l._Source == (int)GameSlotType.Story) .FilterByLevelFilterSettings(null, levelFilterSettings) .OrderByDescending(l => l.Title), skip, count); @@ -349,7 +349,7 @@ public DatabaseList GetBusiestLevels(int count, int skip, MatchServic .OrderBy(r => r.Sum(room => room.PlayerIds.Count)); return new DatabaseList(rooms.Select(r => r.Key) - .Where(l => l != null && l._Source == (int)GameLevelSource.User)! + .Where(l => l != null && l._Source == (int)GameSlotType.User)! .FilterByLevelFilterSettings(user, levelFilterSettings) .FilterByGameVersion(levelFilterSettings.GameVersion), skip, count); } @@ -402,13 +402,13 @@ public DatabaseList SearchForLevels(int count, int skip, GameUser? us } [Pure] - public int GetTotalLevelCount(TokenGame game) => this.GameLevels.FilterByGameVersion(game).Count(l => l._Source == (int)GameLevelSource.User); + public int GetTotalLevelCount(TokenGame game) => this.GameLevels.FilterByGameVersion(game).Count(l => l._Source == (int)GameSlotType.User); [Pure] - public int GetTotalLevelCount() => this.GameLevels.Count(l => l._Source == (int)GameLevelSource.User); + public int GetTotalLevelCount() => this.GameLevels.Count(l => l._Source == (int)GameSlotType.User); [Pure] - public int GetModdedLevelCount() => this.GameLevels.Count(l => l._Source == (int)GameLevelSource.User && l.IsModded); + public int GetModdedLevelCount() => this.GameLevels.Count(l => l._Source == (int)GameSlotType.User && l.IsModded); public int GetTotalLevelsPublishedByUser(GameUser user) => this.GameLevels diff --git a/Refresh.GameServer/Database/GameDatabaseContext.Playlists.cs b/Refresh.GameServer/Database/GameDatabaseContext.Playlists.cs new file mode 100644 index 00000000..dc5c2772 --- /dev/null +++ b/Refresh.GameServer/Database/GameDatabaseContext.Playlists.cs @@ -0,0 +1,154 @@ +using Refresh.GameServer.Authentication; +using Refresh.GameServer.Extensions; +using Refresh.GameServer.Types.Levels; +using Refresh.GameServer.Types.Playlists; +using Refresh.GameServer.Types.UserData; + +namespace Refresh.GameServer.Database; + +public partial class GameDatabaseContext // Playlists +{ + public GamePlaylist CreatePlaylist(GameUser user, SerializedPlaylist createInfo, bool rootPlaylist) + { + GamePlaylist playlist = new() + { + Creator = user, + Name = createInfo.Name, + Description = createInfo.Description, + Icon = createInfo.Icon, + LocationX = createInfo.Location.X, + LocationY = createInfo.Location.Y, + RootPlaylist = rootPlaylist, + }; + + this.Write(() => + { + this.AddSequentialObject(playlist); + }); + + return playlist; + } + + public GamePlaylist? GetPlaylistById(int playlistId) + => this.GamePlaylists.FirstOrDefault(p => p.PlaylistId == playlistId); + + public void UpdatePlaylist(GamePlaylist playlist, SerializedPlaylist updateInfo) + { + this.Write(() => + { + playlist.Name = updateInfo.Name; + playlist.Description = updateInfo.Description; + playlist.Icon = updateInfo.Icon; + playlist.LocationX = updateInfo.Location.X; + playlist.LocationY = updateInfo.Location.Y; + }); + } + + public void DeletePlaylist(GamePlaylist playlist) + { + this.Write(() => + { + // Remove all relations relating to this playlist + this.LevelPlaylistRelations.RemoveRange(l => l.PlaylistId == playlist.PlaylistId); + this.SubPlaylistRelations.RemoveRange(l => l.PlaylistId == playlist.PlaylistId || l.SubPlaylist == playlist); + + // Remove the playlist object + this.GamePlaylists.Remove(playlist); + }); + } + + public void AddPlaylistToPlaylist(GamePlaylist child, GamePlaylist parent) + { + this.Write(() => + { + // Make sure to not create a duplicate object + if (this.SubPlaylistRelations.Any(p => p.SubPlaylist == child && p.PlaylistId == parent.PlaylistId)) + return; + + // Add the relation + this.SubPlaylistRelations.Add(new SubPlaylistRelation + { + PlaylistId = parent.PlaylistId, + SubPlaylist = child, + }); + }); + } + + public void RemovePlaylistFromPlaylist(GamePlaylist child, GamePlaylist parent) + { + this.Write(() => + { + SubPlaylistRelation? relation = + this.SubPlaylistRelations.FirstOrDefault(r => r.SubPlaylist == child && r.PlaylistId == parent.PlaylistId); + + if (relation == null) + return; + + this.SubPlaylistRelations.Remove(relation); + }); + } + + public void AddLevelToPlaylist(GameLevel level, GamePlaylist parent) + { + this.Write(() => + { + // Make sure to not create a duplicate object + if (this.LevelPlaylistRelations.Any(p => p.Level == level && p.PlaylistId == parent.PlaylistId)) + return; + + // Add the relation + this.LevelPlaylistRelations.Add(new LevelPlaylistRelation + { + Level = level, + PlaylistId = parent.PlaylistId, + }); + }); + } + + public void RemoveLevelFromPlaylist(GameLevel level, GamePlaylist parent) + { + this.Write(() => + { + LevelPlaylistRelation? relation = + this.LevelPlaylistRelations.FirstOrDefault(r => r.Level == level && r.PlaylistId == parent.PlaylistId); + + if (relation == null) + return; + + this.LevelPlaylistRelations.Remove(relation); + }); + } + + public IEnumerable GetPlaylistsContainingPlaylist(GamePlaylist playlist) + // TODO: with postgres this can be IQueryable + => this.SubPlaylistRelations.Where(p => p.SubPlaylist == playlist).AsEnumerable() + .Select(r => this.GamePlaylists.First(p => p.PlaylistId == r.PlaylistId)) + .Where(p => !p.RootPlaylist); + + public IEnumerable GetPlaylistsByAuthorContainingPlaylist(GameUser user, GamePlaylist playlist) + // TODO: with postgres this can be IQueryable + => this.SubPlaylistRelations.Where(p => p.SubPlaylist == playlist).AsEnumerable() + .Select(r => this.GamePlaylists.First(p => p.PlaylistId == r.PlaylistId)) + .Where(p => p.Creator.UserId == user.UserId) + .Where(p => !p.RootPlaylist); + + public IEnumerable GetLevelsInPlaylist(GamePlaylist playlist, TokenGame game) => + // TODO: When we have postgres, remove the `AsEnumerable` call for performance. + this.LevelPlaylistRelations.Where(l => l.PlaylistId == playlist.PlaylistId).AsEnumerable() + .Select(l => l.Level).FilterByGameVersion(game); + + public IEnumerable GetPlaylistsInPlaylist(GamePlaylist playlist) + // TODO: When we have postgres, remove the `AsEnumerable` call for performance. + => this.SubPlaylistRelations.Where(p => p.PlaylistId == playlist.PlaylistId).AsEnumerable().Select(l => l.SubPlaylist); + + public IEnumerable GetPlaylistsByAuthorContainingLevel(GameUser author, GameLevel level) + // TODO: When we have postgres, remove the `AsEnumerable` call for performance. + => this.LevelPlaylistRelations.Where(p => p.Level == level).AsEnumerable() + .Select(r => this.GamePlaylists.First(p => p.PlaylistId == r.PlaylistId)) + .Where(p => p.Creator.UserId == author.UserId); + + public IEnumerable GetPlaylistsContainingLevel(GameLevel level) + // TODO: When we have postgres, remove the `AsEnumerable` call for performance. + => this.LevelPlaylistRelations.Where(p => p.Level == level).AsEnumerable() + .Select(r => this.GamePlaylists.First(p => p.PlaylistId == r.PlaylistId)); +} \ No newline at end of file diff --git a/Refresh.GameServer/Database/GameDatabaseContext.Users.cs b/Refresh.GameServer/Database/GameDatabaseContext.Users.cs index f4ce6bac..5a8b5f0a 100644 --- a/Refresh.GameServer/Database/GameDatabaseContext.Users.cs +++ b/Refresh.GameServer/Database/GameDatabaseContext.Users.cs @@ -3,10 +3,9 @@ using Refresh.Common.Constants; using Refresh.GameServer.Authentication; using Refresh.GameServer.Endpoints.ApiV3.DataTypes.Request; -using Refresh.GameServer.Types; using Refresh.GameServer.Types.Levels; using Refresh.GameServer.Types.Photos; -using Refresh.GameServer.Types.Relations; +using Refresh.GameServer.Types.Playlists; using Refresh.GameServer.Types.Roles; using Refresh.GameServer.Types.UserData; @@ -448,4 +447,12 @@ public void MarkAllReuploads(GameUser user) } }); } + + public void SetUserRootPlaylist(GameUser user, GamePlaylist playlist) + { + this.Write(() => + { + user.RootPlaylist = playlist; + }); + } } \ No newline at end of file diff --git a/Refresh.GameServer/Database/GameDatabaseContext.cs b/Refresh.GameServer/Database/GameDatabaseContext.cs index 5bdd216b..17d0b70b 100644 --- a/Refresh.GameServer/Database/GameDatabaseContext.cs +++ b/Refresh.GameServer/Database/GameDatabaseContext.cs @@ -13,6 +13,7 @@ using Refresh.GameServer.Types.Levels; using Refresh.GameServer.Types.Notifications; using Refresh.GameServer.Types.Photos; +using Refresh.GameServer.Types.Playlists; using Refresh.GameServer.Types.Relations; using Refresh.GameServer.Types.Reviews; using Refresh.GameServer.Types.UserData; @@ -57,6 +58,9 @@ public partial class GameDatabaseContext : RealmDatabaseContext private RealmDbSet DisallowedUsers => new(this._realm); private RealmDbSet RateReviewRelations => new(this._realm); private RealmDbSet TagLevelRelations => new(this._realm); + private RealmDbSet GamePlaylists => new(this._realm); + private RealmDbSet LevelPlaylistRelations => new(this._realm); + private RealmDbSet SubPlaylistRelations => new(this._realm); internal GameDatabaseContext(IDateTimeProvider time) { diff --git a/Refresh.GameServer/Database/GameDatabaseProvider.cs b/Refresh.GameServer/Database/GameDatabaseProvider.cs index 2e4018fa..0f53fa14 100644 --- a/Refresh.GameServer/Database/GameDatabaseProvider.cs +++ b/Refresh.GameServer/Database/GameDatabaseProvider.cs @@ -16,6 +16,7 @@ using Refresh.GameServer.Types.Reviews; using Refresh.GameServer.Types.UserData.Leaderboard; using Refresh.GameServer.Types.Photos; +using Refresh.GameServer.Types.Playlists; namespace Refresh.GameServer.Database; @@ -33,45 +34,66 @@ protected GameDatabaseProvider(IDateTimeProvider time) this._time = time; } - protected override ulong SchemaVersion => 149; + protected override ulong SchemaVersion => 154; protected override string Filename => "refreshGameServer.realm"; - protected override List SchemaTypes { get; } = new() - { - typeof(GameUser), - typeof(UserPins), - typeof(Token), + protected override List SchemaTypes { get; } = + [ + typeof(RequestStatistics), + typeof(SequentialIdStorage), + + // recent activity + typeof(Event), + + // announcements + typeof(GameAnnouncement), + + // contests + typeof(GameContest), + + // photos + typeof(GamePhoto), + + // levels typeof(GameLevel), typeof(GameSkillReward), - typeof(GameProfileComment), + typeof(TagLevelRelation), typeof(GameLevelComment), - typeof(ProfileCommentRelation), typeof(LevelCommentRelation), + typeof(RateLevelRelation), typeof(FavouriteLevelRelation), - typeof(QueueLevelRelation), - typeof(FavouriteUserRelation), typeof(PlayLevelRelation), typeof(UniquePlayLevelRelation), - typeof(RateLevelRelation), - typeof(Event), + typeof(QueueLevelRelation), typeof(GameSubmittedScore), - typeof(GameAsset), + + // reviews + typeof(GameReview), + typeof(RateReviewRelation), + + // users + typeof(GameUser), + typeof(Token), + typeof(UserPins), + typeof(GameProfileComment), + typeof(FavouriteUserRelation), + typeof(DisallowedUser), typeof(GameNotification), - typeof(GamePhoto), - typeof(GameIpVerificationRequest), - typeof(GameAnnouncement), - typeof(QueuedRegistration), + typeof(ProfileCommentRelation), typeof(EmailVerificationCode), - typeof(RequestStatistics), - typeof(SequentialIdStorage), - typeof(GameContest), + typeof(QueuedRegistration), + typeof(GameIpVerificationRequest), + + // assets + typeof(GameAsset), typeof(AssetDependencyRelation), - typeof(GameReview), - typeof(DisallowedUser), - typeof(RateReviewRelation), - typeof(TagLevelRelation), - }; + + // playlists + typeof(GamePlaylist), + typeof(LevelPlaylistRelation), + typeof(SubPlaylistRelation), + ]; public override void Warmup() { @@ -86,14 +108,6 @@ protected override GameDatabaseContext CreateContext() protected override void Migrate(Migration migration, ulong oldVersion) { - // Get the current unix timestamp for when we add timestamps to objects - long timestampMilliseconds = this._time.TimestampMilliseconds; - - // DO NOT USE FOR NEW MIGRATIONS! LBP almost never actually uses seconds for timestamps. - // This is from a mistake made early in development where this was not understood by me. - // Unless you are certain second timestamps are used, use the millisecond timestamps set above. - long timestampSeconds = this._time.TimestampSeconds; - IQueryable? oldUsers = migration.OldRealm.DynamicApi.All("GameUser"); IQueryable? newUsers = migration.NewRealm.All(); @@ -277,7 +291,7 @@ protected override void Migrate(Migration migration, ulong oldVersion) // Set all existing levels to user levels, since that's what has existed up until now. if (oldVersion < 92) { - newLevel._Source = (int)GameLevelSource.User; + newLevel._Source = (int)GameSlotType.User; } // In version 129, we split locations from an embedded object out to two fields @@ -406,7 +420,7 @@ protected override void Migrate(Migration migration, ulong oldVersion) Token newToken = newTokens.ElementAt(i); if (oldVersion < 36) - newToken.LoginDate = DateTimeOffset.FromUnixTimeMilliseconds(timestampMilliseconds); + newToken.LoginDate = DateTimeOffset.UtcNow; } IQueryable? oldPhotos = migration.OldRealm.DynamicApi.All("GamePhoto"); @@ -554,7 +568,7 @@ protected override void Migrate(Migration migration, ulong oldVersion) migration.NewRealm.RemoveRange(migration.NewRealm.All().Where(s => s.Level == null)); // fuck realm. - if (oldVersion < 142) + if (oldVersion < 152) foreach (GameSubmittedScore score in migration.NewRealm.All().AsEnumerable() .Where(s => !s.Players.Any()).ToList()) migration.NewRealm.Remove(score); diff --git a/Refresh.GameServer/Endpoints/Game/DataTypes/Request/GameLevelRequest.cs b/Refresh.GameServer/Endpoints/Game/DataTypes/Request/GameLevelRequest.cs index 7ad5f26b..2cf2c86e 100644 --- a/Refresh.GameServer/Endpoints/Game/DataTypes/Request/GameLevelRequest.cs +++ b/Refresh.GameServer/Endpoints/Game/DataTypes/Request/GameLevelRequest.cs @@ -74,6 +74,6 @@ public GameLevel ToGameLevel(GameUser publisher) => IsSubLevel = this.IsSubLevel, IsCopyable = this.IsCopyable == 1, BackgroundGuid = this.BackgroundGuid, - Source = GameLevelSource.User, + Source = GameSlotType.User, }; } \ No newline at end of file diff --git a/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameLevelResponse.cs b/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameLevelResponse.cs index b0435302..69bdfb35 100644 --- a/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameLevelResponse.cs +++ b/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameLevelResponse.cs @@ -9,6 +9,7 @@ using Refresh.GameServer.Types.Levels; using Refresh.GameServer.Types.Levels.SkillRewards; using Refresh.GameServer.Types.Matching; +using Refresh.GameServer.Types.Playlists; using Refresh.GameServer.Types.Reviews; using Refresh.GameServer.Types.UserData; @@ -16,7 +17,7 @@ namespace Refresh.GameServer.Endpoints.Game.DataTypes.Response; [XmlRoot("slot")] [XmlType("slot")] -public class GameLevelResponse : IDataConvertableFrom +public class GameLevelResponse : IDataConvertableFrom, IDataConvertableFrom { [XmlElement("id")] public required int LevelId { get; set; } @@ -39,7 +40,7 @@ public class GameLevelResponse : IDataConvertableFrom t.Tag.ToLbpString())) , + Type = old.Source.ToGameType(), }; - - response.Type = "user"; + if (old is { Publisher: not null, IsReUpload: false }) { response.Handle = SerializedUserHandle.FromUser(old.Publisher, dataContext); @@ -239,4 +240,62 @@ public static GameLevelResponse FromHash(string hash, DataContext dataContext) } public static IEnumerable FromOldList(IEnumerable oldList, DataContext dataContext) => oldList.Select(old => FromOld(old, dataContext)).ToList()!; + + public static GameLevelResponse? FromOld(GamePlaylist? old, DataContext dataContext) + { + if (old == null) + return null; + + return new GameLevelResponse + { + LevelId = old.PlaylistId, + IsAdventure = false, + Title = old.Name, + IconHash = old.Icon, + Description = old.Description, + Location = new GameLocation(old.LocationX, + old.LocationY), + // Playlists are only ever serialized like this in LBP1-like builds + GameVersion = TokenGame.LittleBigPlanet1.ToSerializedGame(), + Type = GameSlotType.Playlist.ToGameType(), + Handle = SerializedUserHandle.FromUser(old.Creator, dataContext), + RootResource = "0", + PublishDate = old.CreationDate.ToUnixTimeMilliseconds(), + UpdateDate = old.LastUpdateDate.ToUnixTimeMilliseconds(), + MinPlayers = 0, + MaxPlayers = 0, + EnforceMinMaxPlayers = false, + SameScreenGame = false, + HeartCount = 0, + TotalPlayCount = 0, + CompletionCount = 0, + UniquePlayCount = 0, + YourRating = 0, + YayCount = 0, + BooCount = 0, + YourStarRating = 0, + YourLbp1PlayCount = 0, + YourLbp2PlayCount = 0, + YourLbp3PlayCount = 0, + SkillRewards = [], + TeamPicked = false, + XmlResources = [], + PlayerCount = 0, + LevelType = GameLevelType.Normal.ToGameString(), + IsLocked = false, + IsSubLevel = false, + IsCopyable = 0, + BackgroundGuid = null, + Links = null, + AverageStarRating = 0, + SizeOfResourcesInBytes = 0, + ReviewCount = 0, + ReviewsEnabled = true, + CommentCount = 0, + CommentsEnabled = true, + Tags = string.Empty, + }; + } + + public static IEnumerable FromOldList(IEnumerable oldList, DataContext dataContext) => oldList.Select(old => FromOld(old, dataContext)).ToList()!; } \ No newline at end of file diff --git a/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameUserResponse.cs b/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameUserResponse.cs index 02e2e790..899b379a 100644 --- a/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameUserResponse.cs +++ b/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameUserResponse.cs @@ -45,6 +45,7 @@ public class GameUserResponse : IDataConvertableFrom [XmlElement("lbp3UsedSlots")] public int UsedSlotsLBP3 { get; set; } [XmlElement("lbp2PurchasedSlots")] public int PurchasedSlotsLBP2 { get; set; } [XmlElement("lbp3PurchasedSlots")] public int PurchasedSlotsLBP3 { get; set; } + [XmlElement("rootPlaylist")] public string? RootPlaylist { get; set; } /// /// The levels the user has favourited, only used by LBP PSP @@ -78,6 +79,7 @@ public class GameUserResponse : IDataConvertableFrom HeartCount = old.IsManaged ? dataContext.Database.GetTotalUsersFavouritingUser(old) : 0, PhotosByMeCount = old.IsManaged ? dataContext.Database.GetTotalPhotosByUser(old) : 0, PhotosWithMeCount = old.IsManaged ? dataContext.Database.GetTotalPhotosWithUser(old) : 0, + RootPlaylist = old.RootPlaylist?.PlaylistId.ToString(), EntitledSlots = UgcLimits.MaximumLevels, EntitledSlotsLBP2 = UgcLimits.MaximumLevels, diff --git a/Refresh.GameServer/Endpoints/Game/Handshake/MetadataEndpoints.cs b/Refresh.GameServer/Endpoints/Game/Handshake/MetadataEndpoints.cs index f9c44f29..5b3d44f3 100644 --- a/Refresh.GameServer/Endpoints/Game/Handshake/MetadataEndpoints.cs +++ b/Refresh.GameServer/Endpoints/Game/Handshake/MetadataEndpoints.cs @@ -4,6 +4,7 @@ using Bunkum.Core.Responses; using Bunkum.Listener.Protocol; using Bunkum.Protocols.Http; +using Refresh.GameServer.Configuration; using Refresh.GameServer.Database; using Refresh.GameServer.Time; using Refresh.GameServer.Types.Challenges; @@ -60,6 +61,7 @@ private static readonly Lazy NetworkSettingsFile [GameEndpoint("network_settings.nws")] [MinimumRole(GameUserRole.Restricted)] public string NetworkSettings(RequestContext context) + public string NetworkSettings(RequestContext context, GameServerConfig config) { bool created = NetworkSettingsFile.IsValueCreated; @@ -72,8 +74,27 @@ public string NetworkSettings(RequestContext context) "If everything works the way you like, you can safely ignore this warning."); // EnableHackChecks being false fixes the "There was a problem with the level you were playing on that forced a return to your Pod." error that LBP3 tends to show in the pod. - // EnableDiveIn being true enables dive in for LBP3 - networkSettings ??= "ShowLevelBoos true\nAllowOnlineCreate true\nEnableDiveIn true\nEnableHackChecks false\n"; + // AlexDB enables the "Web Privacy Settings" option on LBP1, and is required for Playlists to function + // OverheatingThreshholdDisallowMidgameJoin is set to >1.0 so that it never triggers + // EnableCommunityDecorations, EnablePlayedFilter, EnableDiveIn enable various game features + // DisableDLCPublishCheck disables the game's DLC publish check. + networkSettings ??= $""" + AllowOnlineCreate true + ShowErrorNumbers true + AllowModeratedLevels false + AllowModeratedPoppetItems false + ShowLevelBoos true + CDNHostName {config.GameConfigStorageUrl} + TelemetryServer {config.GameConfigStorageUrl} + OverheatingThresholdDisallowMidgameJoin 2.0 + EnableCommunityDecorations true + EnablePlayedFilter true + EnableDiveIn true + EnableHackChecks false + DisableDLCPublishCheck true + AlexDB true + + """; return networkSettings; } @@ -87,6 +108,7 @@ private static readonly Lazy TelemetryConfigFile }); [GameEndpoint("t_conf")] + [MinimumRole(GameUserRole.Restricted)] [NullStatusCode(Gone)] [MinimumRole(GameUserRole.Restricted)] public string? TelemetryConfig(RequestContext context) @@ -175,6 +197,7 @@ private static readonly Lazy DeveloperVideosFile public string GameState(RequestContext context) => "VALID"; [GameEndpoint("ChallengeConfig.xml", ContentType.Xml)] + [MinimumRole(GameUserRole.Restricted)] public SerializedGameChallengeList ChallengeConfig(RequestContext context, IDateTimeProvider timeProvider) { //TODO: allow this to be controlled by the server owner, right now lets just send the game 0 challenges, @@ -192,5 +215,6 @@ public SerializedGameChallengeList ChallengeConfig(RequestContext context, IDate } [GameEndpoint("tags")] + [MinimumRole(GameUserRole.Restricted)] public string Tags(RequestContext context) => TagExtensions.AllTags; } \ No newline at end of file diff --git a/Refresh.GameServer/Endpoints/Game/Levels/LevelEndpoints.cs b/Refresh.GameServer/Endpoints/Game/Levels/LevelEndpoints.cs index d3baf5ae..49773a2f 100644 --- a/Refresh.GameServer/Endpoints/Game/Levels/LevelEndpoints.cs +++ b/Refresh.GameServer/Endpoints/Game/Levels/LevelEndpoints.cs @@ -13,6 +13,7 @@ using Refresh.GameServer.Types.Levels; using Refresh.GameServer.Types.Levels.Categories; using Refresh.GameServer.Types.Lists; +using Refresh.GameServer.Types.Playlists; using Refresh.GameServer.Types.Roles; using Refresh.GameServer.Types.UserData; @@ -66,9 +67,31 @@ public class LevelEndpoints : EndpointGroup if (levels == null) return null; IEnumerable category = levels.Items - .Select(l => GameMinimalLevelResponse.FromOld(l, dataContext))!; + .Select(l => GameMinimalLevelResponse.FromOld(l, dataContext)!); + + int injectedAmount = 0; - return new SerializedMinimalLevelList(category, levels.TotalItems, skip + count); + // Special case the `by` route for LBP1 requests, to inject the user's playlist info + if (route == "by" && dataContext.Game == TokenGame.LittleBigPlanet1) + { + // Get the requested user's root playlist + GamePlaylist? playlist = database.GetUserByUsername(context.QueryString.Get("u"))?.RootPlaylist; + + // If it was found, inject it into the response info + if (playlist != null) + { + // TODO: with postgres this can be IQueryable + List playlists = database.GetPlaylistsInPlaylist(playlist).ToList(); + + category = GameMinimalLevelResponse.FromOldList(playlists, dataContext).Concat(category); + // While this does technically return more slot results than the game is expecting, + // because we tell the game exactly what the "next page index" is (its not based on count sent), + // pagination still seems to work perfectly fine in LBP1! + injectedAmount += playlists.Count; + } + } + + return new SerializedMinimalLevelList(category, levels.TotalItems + injectedAmount, skip + count); } [GameEndpoint("slots/{route}/{username}", ContentType.Xml)] diff --git a/Refresh.GameServer/Endpoints/Game/Playlists/PlaylistEndpoints.cs b/Refresh.GameServer/Endpoints/Game/Playlists/PlaylistEndpoints.cs new file mode 100644 index 00000000..7508f176 --- /dev/null +++ b/Refresh.GameServer/Endpoints/Game/Playlists/PlaylistEndpoints.cs @@ -0,0 +1,338 @@ +using Bunkum.Core; +using Bunkum.Core.Endpoints; +using Bunkum.Core.Endpoints.Debugging; +using Bunkum.Core.Responses; +using Bunkum.Listener.Protocol; +using Bunkum.Protocols.Http; +using Refresh.GameServer.Database; +using Refresh.GameServer.Extensions; +using Refresh.GameServer.Types.Data; +using Refresh.GameServer.Types.Levels; +using Refresh.GameServer.Types.Lists; +using Refresh.GameServer.Types.Playlists; +using Refresh.GameServer.Types.Roles; +using Refresh.GameServer.Types.UserData; + +namespace Refresh.GameServer.Endpoints.Game.Playlists; + +public class PlaylistEndpoints : EndpointGroup +{ + // Creates a playlist, with an optional parent ID + [GameEndpoint("createPlaylist", HttpMethods.Post, ContentType.Xml)] + [RequireEmailVerified] + public Response CreatePlaylist(RequestContext context, DataContext dataContext, SerializedPlaylist body) + { + GameUser user = dataContext.User!; + + GamePlaylist? parent = null; + // If the parent ID is specified, try to parse that out + if (int.TryParse(context.QueryString["parent_id"], out int parentId)) + { + parent = dataContext.Database.GetPlaylistById(parentId); + + // Dont try to parent to a non-existent parent playlist + if (parent == null) + return BadRequest; + + // Dont let you create a sub-playlist of someone else's playlist + if (user.UserId != parent.Creator.UserId) + return Unauthorized; + + // If the user has no root playlist, but they are trying to create a sub-playlist, something has gone wrong. + if (user.RootPlaylist == null) + return BadRequest; + } + + // Create the playlist, marking it as the root playlist if the user does not have one set already + GamePlaylist playlist = dataContext.Database.CreatePlaylist(user, body, user.RootPlaylist == null); + + // If there is a parent, add the new playlist to the parent + if (parent != null) + dataContext.Database.AddPlaylistToPlaylist(playlist, parent); + + // If this new playlist is the root playlist, mark the user's root playlist as it + if (playlist.RootPlaylist) + dataContext.Database.SetUserRootPlaylist(user, playlist); + + // Create the new playlist, returning the data + return new Response(SerializedPlaylist.FromOld(playlist, dataContext), ContentType.Xml); + } + + // Gets the slots contained within a playlist + [GameEndpoint("playlist/{id}", HttpMethods.Get, ContentType.Xml)] + [NullStatusCode(NotFound)] + [MinimumRole(GameUserRole.Restricted)] + public SerializedMinimalLevelList? GetPlaylistSlots(RequestContext context, DataContext dataContext, int id) + { + GamePlaylist? playlist = dataContext.Database.GetPlaylistById(id); + + // Handle an invalid playlist ID + if (playlist == null) + return null; + + // TODO: when we get postgres, this can be IQueryable and we wont need ToList() + IList subPlaylists = dataContext.Database.GetPlaylistsInPlaylist(playlist).ToList(); + // TODO: when we get postgres, this can be IQueryable and we wont need ToList() + IList levels = dataContext.Database.GetLevelsInPlaylist(playlist, dataContext.Game).ToList(); + + (int skip, int count) = context.GetPageData(); + + int total = subPlaylists.Count + levels.Count; + + // Concat together the playlist's sub-playlists and levels + IEnumerable slots = + GameMinimalLevelResponse.FromOldList(subPlaylists, dataContext) // the sub-playlists + .Concat(GameMinimalLevelResponse.FromOldList(levels, dataContext)) // the sub-levels + .Skip(skip).Take(count); + + // Convert the GameLevelResponse list down to a GameMinimalLevelResponse + return new SerializedMinimalLevelList( + slots, + total, + skip + ); + } + + [GameEndpoint("playlistsContainingSlotByAuthor/{slotType}/{slotId}", ContentType.Xml)] + [GameEndpoint("playlistsContainingSlot/{slotType}/{slotId}", ContentType.Xml)] + [MinimumRole(GameUserRole.Restricted)] + [NullStatusCode(NotFound)] + public SerializedMinimalLevelList? PlaylistsContainingSlot(RequestContext context, DataContext dataContext, + string slotType, int slotId) + { + string? authorName = context.QueryString["author"]; + + GameUser? author = null; + // Allow the author to be unspecified + if (authorName != null) + { + // Get the user + author = dataContext.Database.GetUserByUsername(authorName); + + // Handle when the provided username does not exist on the server + if (author == null) + return null; + } + + // Get the playlists which contain the level/playlist, and if we have an author specified, filter it down to only playlists which are created by the author + // TODO: with postgres this can be IQueryable, and we dont need List + List playlists; + if (slotType == "playlist") + { + GamePlaylist? playlist = dataContext.Database.GetPlaylistById(slotId); + + if (playlist == null) + return null; + + playlists = author == null ? + dataContext.Database.GetPlaylistsContainingPlaylist(playlist).ToList() : + dataContext.Database.GetPlaylistsByAuthorContainingPlaylist(author, playlist).ToList(); + } + else + { + GameLevel? level = dataContext.Database.GetLevelByIdAndType(slotType, slotId); + + if (level == null) + return null; + + playlists = author == null ? + dataContext.Database.GetPlaylistsContainingLevel(level).ToList() : + dataContext.Database.GetPlaylistsByAuthorContainingLevel(author, level).ToList(); + } + + int total = playlists.Count; + + (int skip, int count) = context.GetPageData(); + + // Return the serialized playlists + return new SerializedMinimalLevelList( + GameMinimalLevelResponse.FromOldList(playlists.Skip(skip).Take(count), dataContext), + total, + skip + ); + } + + [GameEndpoint("setPlaylistMetaData/{id}", HttpMethods.Post, ContentType.Xml)] + [RequireEmailVerified] + public Response UpdatePlaylistMetadata(RequestContext context, GameDatabaseContext database, GameUser user, int id, SerializedPlaylist body) + { + GamePlaylist? playlist = database.GetPlaylistById(id); + + // Handle a bad playlist ID + if (playlist == null) + return NotFound; + + // Dont allow the wrong user to update playlists + if (playlist.Creator.UserId != user.UserId) + return Unauthorized; + + database.UpdatePlaylist(playlist, body); + + return OK; + } + + [GameEndpoint("deletePlaylist/{id}", HttpMethods.Post)] + [RequireEmailVerified] + public Response DeletePlaylist(RequestContext context, GameDatabaseContext database, GameUser user, int id) + { + GamePlaylist? playlist = database.GetPlaylistById(id); + + // Handle a bad playlist ID + if (playlist == null) + return NotFound; + + // Dont allow the wrong user to delete playlists + if (playlist.Creator.UserId != user.UserId) + return Unauthorized; + + database.DeletePlaylist(playlist); + + return OK; + } + + [GameEndpoint("addToPlaylist/{playlistId}", HttpMethods.Post)] + [RequireEmailVerified] + public Response AddSlotToPlaylist(RequestContext context, GameDatabaseContext database, GameUser user, int playlistId) + { + // Extract the slot type, ensuring its set + string? slotType = context.QueryString["slot_type"]; + if (slotType == null) + return BadRequest; + + // Extract the slot ID ensuring its valid + if (!int.TryParse(context.QueryString["slot_id"], out int slotId)) + return BadRequest; + + GamePlaylist? parentPlaylist = database.GetPlaylistById(playlistId); + + // If the parent doesn't exist, exit gracefully + if (parentPlaylist == null) + return NotFound; + + // Dont let people add slots to other's playlists + if (parentPlaylist.Creator.UserId != user.UserId) + return Unauthorized; + + // Special handling for playlists + if (slotType == "playlist") + { + GamePlaylist? childPlaylist = database.GetPlaylistById(slotId); + + // If the child doesn't exist, exit gracefully + if (childPlaylist == null) + return NotFound; + + // Dont allow a playlist to be a child of itself + if (childPlaylist.PlaylistId == parentPlaylist.PlaylistId) + return BadRequest; + + // If the parent contains the child in its parent tree, block the request to prevent recursive playlists + // This would be a `BadRequest`, but the game has a bug and will do this when creating sub-playlists, + // so lets not upset it and just return OK, I dont expect this to be a common problem for people to run into. + bool recursive = false; + parentPlaylist.TraverseParentsRecursively(database, delegate(GamePlaylist playlist) + { + if (playlist.PlaylistId == childPlaylist.PlaylistId) + recursive = true; + }); + if (recursive) return OK; + + // Add the playlist to the parent + database.AddPlaylistToPlaylist(childPlaylist, parentPlaylist); + + // ReSharper disable once ExtractCommonBranchingCode see like 3 lines below (line count subject to change) + return OK; + } + // ReSharper disable once RedundantIfElseBlock i am intentionally writing this code like this to prevent code + // accidentally falling outside of the branch during a possible future + // refactor and returning OK when not intended. ok, rider? + else + { + GameLevel? level = database.GetLevelByIdAndType(slotType, slotId); + + // If the level doesn't exist, exit gracefully + if (level == null) + return NotFound; + + // Add the level to the playlist + database.AddLevelToPlaylist(level, parentPlaylist); + + return OK; + } + } + + [GameEndpoint("removeFromPlaylist/{playlistId}", HttpMethods.Post)] + [RequireEmailVerified] + public Response RemoveSlotFromPlaylist(RequestContext context, GameDatabaseContext database, GameUser user, + int playlistId) + { + // Extract the slot type, ensuring its set + string? slotType = context.QueryString["slot_type"]; + if (slotType == null) + return BadRequest; + + // Extract the slot ID, ensuring its valid + if (!int.TryParse(context.QueryString["slot_id"], out int slotId)) + return BadRequest; + + GamePlaylist? parentPlaylist = database.GetPlaylistById(playlistId); + + // If the parent doesn't exist, exit gracefully + if (parentPlaylist == null) + return NotFound; + + // Dont let people remove slots from other's playlists + if (parentPlaylist.Creator.UserId != user.UserId) + return Unauthorized; + + // Special handling for playlists + if (slotType == "playlist") + { + GamePlaylist? childPlaylist = database.GetPlaylistById(slotId); + + // If the child doesn't exist, exit gracefully + if (childPlaylist == null) + return NotFound; + + // Remove the playlist from the parent + database.RemovePlaylistFromPlaylist(childPlaylist, parentPlaylist); + + // ReSharper disable once ExtractCommonBranchingCode see like 3 lines below (line count subject to change) + return OK; + } + // ReSharper disable once RedundantIfElseBlock i am intentionally writing this code like this to prevent code + // accidentally falling outside of the branch during a possible future + // refactor and returning OK when not intended. ok, rider? + else + { + GameLevel? level = database.GetLevelByIdAndType(slotType, slotId); + + // If the level doesn't exist, exit gracefully + if (level == null) + return NotFound; + + // Remove the level from the playlist + database.RemoveLevelFromPlaylist(level, parentPlaylist); + + return OK; + } + } + + [GameEndpoint("moveFromPlaylist/{from}", HttpMethods.Post, ContentType.Xml)] + [RequireEmailVerified] + public Response MoveSlotFromPlaylist(RequestContext context, GameDatabaseContext database, GameUser user, int from) + { + if (!int.TryParse(context.QueryString["to"], out int to)) + return BadRequest; + + Response ret; + + if ((ret = this.RemoveSlotFromPlaylist(context, database, user, from)).StatusCode != OK) + return ret; + + if ((ret = this.AddSlotToPlaylist(context, database, user, to)).StatusCode != OK) + return ret; + + return OK; + } +} \ No newline at end of file diff --git a/Refresh.GameServer/Endpoints/Game/ReportingEndpoints.cs b/Refresh.GameServer/Endpoints/Game/ReportingEndpoints.cs index b24d69b1..09ed7f5e 100644 --- a/Refresh.GameServer/Endpoints/Game/ReportingEndpoints.cs +++ b/Refresh.GameServer/Endpoints/Game/ReportingEndpoints.cs @@ -83,8 +83,8 @@ public Response UploadReport(RequestContext context, GameDatabaseContext databas LevelId = level.LevelId, Title = level.Title, Type = level.Source switch { - GameLevelSource.User => "user", - GameLevelSource.Story => "developer", + GameSlotType.User => "user", + GameSlotType.Story => "developer", _ => throw new ArgumentOutOfRangeException(), }, }, diff --git a/Refresh.GameServer/Extensions/GamePlaylistExtensions.cs b/Refresh.GameServer/Extensions/GamePlaylistExtensions.cs new file mode 100644 index 00000000..5116478b --- /dev/null +++ b/Refresh.GameServer/Extensions/GamePlaylistExtensions.cs @@ -0,0 +1,28 @@ +using Refresh.GameServer.Database; +using Refresh.GameServer.Types.Playlists; + +namespace Refresh.GameServer.Extensions; + +public static class GamePlaylistExtensions +{ + /// + /// Recursively traverse the parent playlists of this playlist + /// + /// The root playlist + /// The database, used to retrieve playlist info + /// Callback run on every playlist in the parent tree + public static void TraverseParentsRecursively(this GamePlaylist playlist, GameDatabaseContext database, + Action callback) + { + // Iterate over all parents + foreach (GamePlaylist parent in database.GetPlaylistsContainingPlaylist(playlist)) + { + // Call the callback for this parent + callback(playlist); + + // Traverse all of this parent's parents + parent.TraverseParentsRecursively(database, callback); + } + } + +} \ No newline at end of file diff --git a/Refresh.GameServer/Types/Levels/GameLevel.cs b/Refresh.GameServer/Types/Levels/GameLevel.cs index e28d2aec..a4ad506b 100644 --- a/Refresh.GameServer/Types/Levels/GameLevel.cs +++ b/Refresh.GameServer/Types/Levels/GameLevel.cs @@ -76,9 +76,14 @@ public GameLevelType LevelType // ReSharper disable once InconsistentNaming internal int _LevelType { get; set; } - [Ignored] public GameLevelSource Source + /// + /// The source slot type that a level has come from. + /// + /// The only valid values of this are `User` or `Story`. + /// + [Ignored] public GameSlotType Source { - get => (GameLevelSource)this._Source; + get => (GameSlotType)this._Source; set => this._Source = (int)value; } diff --git a/Refresh.GameServer/Types/Levels/GameLevelSource.cs b/Refresh.GameServer/Types/Levels/GameLevelSource.cs deleted file mode 100644 index adf34f06..00000000 --- a/Refresh.GameServer/Types/Levels/GameLevelSource.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace Refresh.GameServer.Types.Levels; - -public enum GameLevelSource -{ - /// - /// A level uploaded by a user to the server - /// - User, - /// - /// A level created by the server to represent a game story level. - /// - Story, -} - -public static class GameLevelSourceExtensions -{ - public static string ToGameType(this GameLevelSource source) - { - return source switch - { - GameLevelSource.User => "user", - GameLevelSource.Story => "developer", - _ => throw new ArgumentOutOfRangeException(nameof(source), source, null), - }; - } -} \ No newline at end of file diff --git a/Refresh.GameServer/Types/Levels/GameMinimalLevelResponse.cs b/Refresh.GameServer/Types/Levels/GameMinimalLevelResponse.cs index 70bd39fe..f96fd23c 100644 --- a/Refresh.GameServer/Types/Levels/GameMinimalLevelResponse.cs +++ b/Refresh.GameServer/Types/Levels/GameMinimalLevelResponse.cs @@ -1,18 +1,15 @@ -using System.Diagnostics; using System.Xml.Serialization; -using Bunkum.Core.Storage; using Refresh.GameServer.Authentication; -using Refresh.GameServer.Database; using Refresh.GameServer.Endpoints.ApiV3.DataTypes; using Refresh.GameServer.Endpoints.Game.DataTypes.Response; -using Refresh.GameServer.Services; using Refresh.GameServer.Types.Data; using Refresh.GameServer.Types.Matching; +using Refresh.GameServer.Types.Playlists; using Refresh.GameServer.Types.UserData; namespace Refresh.GameServer.Types.Levels; -public class GameMinimalLevelResponse : IDataConvertableFrom, IDataConvertableFrom +public class GameMinimalLevelResponse : IDataConvertableFrom, IDataConvertableFrom, IDataConvertableFrom { //NOTE: THIS MUST BE AT THE TOP OF THE XML RESPONSE OR ELSE LBP PSP WILL CRASH [XmlElement("id")] public required int LevelId { get; set; } @@ -105,9 +102,53 @@ public static GameMinimalLevelResponse FromHash(string hash, DataContext dataCon Tags = level.Tags, }; } + + public static GameMinimalLevelResponse? FromOld(GamePlaylist? old, DataContext dataContext) + { + if (old == null) + return null; + + return new GameMinimalLevelResponse + { + LevelId = old.PlaylistId, + IsAdventure = false, + Title = old.Name, + IconHash = dataContext.GetIconFromHash(old.Icon), + Description = old.Description, + Type = GameSlotType.Playlist.ToGameType(), + Location = new GameLocation(old.LocationX, old.LocationY), + // Playlists are only ever serialized like this in LBP1-like builds, so we can assume LBP1 + GameVersion = TokenGame.LittleBigPlanet1.ToSerializedGame(), + RootResource = "0", + MinPlayers = 0, + MaxPlayers = 0, + HeartCount = 0, + TotalPlayCount = 0, + UniquePlayCount = 0, + YayCount = 0, + BooCount = 0, + AverageStarRating = 0, + YourStarRating = 0, + YourRating = 0, + PlayerCount = 0, + ReviewsEnabled = true, + ReviewCount = 0, + CommentsEnabled = true, + CommentCount = 0, + IsLocked = false, + IsSubLevel = false, + IsCopyable = 0, + Tags = string.Empty, + TeamPicked = false, + Handle = SerializedUserHandle.FromUser(old.Creator, dataContext), + }; + } public static IEnumerable FromOldList(IEnumerable oldList, DataContext dataContext) => oldList.Select(old => FromOld(old, dataContext)).ToList()!; public static IEnumerable FromOldList(IEnumerable oldList, DataContext dataContext) => oldList.Select(old => FromOld(old, dataContext)).ToList()!; + public static IEnumerable FromOldList(IEnumerable oldList, + DataContext dataContext) => oldList.Select(old => FromOld(old, dataContext)).ToList()!; + } \ No newline at end of file diff --git a/Refresh.GameServer/Types/Levels/GameSlotType.cs b/Refresh.GameServer/Types/Levels/GameSlotType.cs new file mode 100644 index 00000000..032303d6 --- /dev/null +++ b/Refresh.GameServer/Types/Levels/GameSlotType.cs @@ -0,0 +1,36 @@ +using System.Xml.Serialization; + +namespace Refresh.GameServer.Types.Levels; + +public enum GameSlotType +{ + /// + /// A level uploaded by a user to the server. + /// + [XmlEnum("user")] + User, + /// + /// A level created by the original developers. + /// + [XmlEnum("developer")] + Story, + /// + /// An LBP1 playlist, displayed as a polaroid. + /// + [XmlEnum("playlist")] + Playlist, +} + +public static class GameLevelSourceExtensions +{ + public static string ToGameType(this GameSlotType source) + { + return source switch + { + GameSlotType.User => "user", + GameSlotType.Story => "developer", + GameSlotType.Playlist => "playlist", + _ => throw new ArgumentOutOfRangeException(nameof(source), source, null), + }; + } +} \ No newline at end of file diff --git a/Refresh.GameServer/Types/Matching/IRoomAccessor.cs b/Refresh.GameServer/Types/Matching/IRoomAccessor.cs index 984408c7..0c30564b 100644 --- a/Refresh.GameServer/Types/Matching/IRoomAccessor.cs +++ b/Refresh.GameServer/Types/Matching/IRoomAccessor.cs @@ -70,8 +70,8 @@ public interface IRoomAccessor public IEnumerable GetRoomsInLevel(GameLevel level) => this.GetRoomsInLevel( level.Source switch { - GameLevelSource.User => RoomSlotType.Online, - GameLevelSource.Story => RoomSlotType.Story, + GameSlotType.User => RoomSlotType.Online, + GameSlotType.Story => RoomSlotType.Story, _ => throw new ArgumentOutOfRangeException(), }, level.LevelId diff --git a/Refresh.GameServer/Types/Playlists/GamePlaylist.cs b/Refresh.GameServer/Types/Playlists/GamePlaylist.cs new file mode 100644 index 00000000..38b06d91 --- /dev/null +++ b/Refresh.GameServer/Types/Playlists/GamePlaylist.cs @@ -0,0 +1,58 @@ +using Realms; +using Refresh.GameServer.Database; +using Refresh.GameServer.Types.UserData; + +namespace Refresh.GameServer.Types.Playlists; + +/// +/// A user-curated list of levels. +/// +public partial class GamePlaylist : IRealmObject, ISequentialId +{ + /// + /// The unique ID of this playlist, must be > 0 + /// + [PrimaryKey] public int PlaylistId { get; set; } + + /// + /// The user who created the playlist + /// + public GameUser Creator { get; set; } + + /// + /// The name of the playlist + /// + public string Name { get; set; } + /// + /// The description of the playlist + /// + public string Description { get; set; } + + /// + /// The playlist's icon, either a GUID or Hashed asset + /// + public string Icon { get; set; } + + public int LocationX { get; set; } + public int LocationY { get; set; } + + /// + /// The time the playlist was created + /// + public DateTimeOffset CreationDate { get; set; } + /// + /// The last time the playlist was updated. ex. name/desc/icon change, or a level/sub-playlist was added + /// + public DateTimeOffset LastUpdateDate { get; set; } + + /// + /// Whether or not this playlist is a root playlist. This is to let us hide the root playlists when we + /// + public bool RootPlaylist { get; set; } + + public int SequentialId + { + get => this.PlaylistId; + set => this.PlaylistId = value; + } +} \ No newline at end of file diff --git a/Refresh.GameServer/Types/Playlists/LevelPlaylistRelation.cs b/Refresh.GameServer/Types/Playlists/LevelPlaylistRelation.cs new file mode 100644 index 00000000..3a9a547b --- /dev/null +++ b/Refresh.GameServer/Types/Playlists/LevelPlaylistRelation.cs @@ -0,0 +1,19 @@ +using Realms; +using Refresh.GameServer.Types.Levels; + +namespace Refresh.GameServer.Types.Playlists; + +/// +/// A mapping of playlist -> sub-level +/// +public partial class LevelPlaylistRelation : IRealmObject +{ + /// + /// The playlist the level is contained in + /// + public int PlaylistId { get; set; } + /// + /// The level contained within the playlist + /// + public GameLevel Level { get; set; } +} \ No newline at end of file diff --git a/Refresh.GameServer/Types/Playlists/SerializedPlaylist.cs b/Refresh.GameServer/Types/Playlists/SerializedPlaylist.cs new file mode 100644 index 00000000..b2ee455d --- /dev/null +++ b/Refresh.GameServer/Types/Playlists/SerializedPlaylist.cs @@ -0,0 +1,41 @@ +using System.Xml.Serialization; +using Refresh.GameServer.Endpoints.ApiV3.DataTypes; +using Refresh.GameServer.Types.Data; + +namespace Refresh.GameServer.Types.Playlists; + +[XmlType("playlist")] +[XmlRoot("playlist")] +public class SerializedPlaylist : IDataConvertableFrom +{ + [XmlElement("id")] + public int Id { get; set; } + + [XmlElement("name")] + public string Name { get; set; } + [XmlElement("description")] + public string Description { get; set; } + [XmlElement("icon")] + public string Icon { get; set; } + + [XmlElement("location")] + public GameLocation Location { get; set; } + + public static SerializedPlaylist? FromOld(GamePlaylist? old, DataContext dataContext) + { + if (old == null) + return null; + + return new SerializedPlaylist + { + Id = old.PlaylistId, + Name = old.Name, + Description = old.Description, + Icon = old.Icon, + Location = new GameLocation(old.LocationX, old.LocationY), + }; + } + + public static IEnumerable FromOldList(IEnumerable oldList, DataContext dataContext) + => oldList.Select(p => FromOld(p, dataContext)!); +} \ No newline at end of file diff --git a/Refresh.GameServer/Types/Playlists/SubPlaylistRelation.cs b/Refresh.GameServer/Types/Playlists/SubPlaylistRelation.cs new file mode 100644 index 00000000..ee92580c --- /dev/null +++ b/Refresh.GameServer/Types/Playlists/SubPlaylistRelation.cs @@ -0,0 +1,18 @@ +using Realms; + +namespace Refresh.GameServer.Types.Playlists; + +/// +/// A mapping of playlist -> sub-playlist +/// +public partial class SubPlaylistRelation : IRealmObject +{ + /// + /// The playlist the level is contained in + /// + public int PlaylistId { get; set; } + /// + /// The sub-playlist contained within the playlist + /// + public GamePlaylist SubPlaylist { get; set; } +} \ No newline at end of file diff --git a/Refresh.GameServer/Types/UserData/GameUser.cs b/Refresh.GameServer/Types/UserData/GameUser.cs index 361ca9db..0682017c 100644 --- a/Refresh.GameServer/Types/UserData/GameUser.cs +++ b/Refresh.GameServer/Types/UserData/GameUser.cs @@ -1,12 +1,8 @@ using System.Xml.Serialization; using MongoDB.Bson; using Realms; -using Refresh.GameServer.Types.Comments; using Bunkum.Core.RateLimit; -using Refresh.GameServer.Types.Levels; -using Refresh.GameServer.Types.Notifications; -using Refresh.GameServer.Types.Photos; -using Refresh.GameServer.Types.Relations; +using Refresh.GameServer.Types.Playlists; using Refresh.GameServer.Types.Roles; namespace Refresh.GameServer.Types.UserData; @@ -86,7 +82,13 @@ public partial class GameUser : IRealmObject, IRateLimitUser private int _LevelVisibility { get; set; } = (int)Visibility.All; /// - /// Whether the user's profile is displayed on the website + /// The user's root playlist. This playlist contains all the user's playlists, and optionally other slots as well, + /// although the game does not expose the ability to do this normally. + /// + public GamePlaylist? RootPlaylist { get; set; } + + /// + /// Whether the user's profile information is exposed in the public API. /// [Ignored] public Visibility ProfileVisibility @@ -96,7 +98,7 @@ public Visibility ProfileVisibility } /// - /// Whether the user's levels are displayed on the website + /// Whether the user's levels are exposed in the public API. /// [Ignored] public Visibility LevelVisibility diff --git a/RefreshTests.GameServer/TestContext.cs b/RefreshTests.GameServer/TestContext.cs index cc6f23e2..1eb37e90 100644 --- a/RefreshTests.GameServer/TestContext.cs +++ b/RefreshTests.GameServer/TestContext.cs @@ -120,7 +120,7 @@ public GameLevel CreateLevel(GameUser author, string title = "Level", TokenGame { Title = title, Publisher = author, - Source = GameLevelSource.User, + Source = GameSlotType.User, GameVersion = gameVersion, }; diff --git a/RefreshTests.GameServer/Tests/Levels/UploadTests.cs b/RefreshTests.GameServer/Tests/Levels/UploadTests.cs index e265b266..253dc0bd 100644 --- a/RefreshTests.GameServer/Tests/Levels/UploadTests.cs +++ b/RefreshTests.GameServer/Tests/Levels/UploadTests.cs @@ -16,7 +16,7 @@ public void CanCreateLevelDirectly() Title = "This is a level", Description = "incredible", Publisher = user, - Source = GameLevelSource.User, + Source = GameSlotType.User, }; context.Database.AddLevel(level); @@ -32,7 +32,7 @@ public void CantCreateLevelWithoutPublisher() { Title = "I AM AN ORPHAN!!!!", Publisher = null, - Source = GameLevelSource.User, + Source = GameSlotType.User, }; Assert.That(() => context.Database.AddLevel(level), Throws.InvalidOperationException); @@ -87,7 +87,7 @@ public void CantUpdateOtherUsersLevels() LevelId = level.LevelId, RootResource = "Malware", Publisher = baddie, - Source = GameLevelSource.User, + Source = GameSlotType.User, }; GameLevel? updatedLevel = context.Database.UpdateLevel(levelUpdate, baddie); @@ -110,7 +110,7 @@ public void CantUpdateNonExistentLevels() { LevelId = 69696969, Publisher = author, - Source = GameLevelSource.User, + Source = GameSlotType.User, }; GameLevel? updatedLevel = context.Database.UpdateLevel(levelUpdate, author); From d01a1065ac6ca1fbbd94ff29d55d55993d55f600 Mon Sep 17 00:00:00 2001 From: Beyley Thomas Date: Fri, 30 Aug 2024 18:23:32 -0700 Subject: [PATCH 02/12] MatchingEndpoints: Fix location fixup with end locations. Sometimes the locations appear at the tail end of the string, right before the `}`, so we need to extract that out. --- Refresh.GameServer/Endpoints/Game/MatchingEndpoints.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Refresh.GameServer/Endpoints/Game/MatchingEndpoints.cs b/Refresh.GameServer/Endpoints/Game/MatchingEndpoints.cs index bf1aaa53..8c61253d 100644 --- a/Refresh.GameServer/Endpoints/Game/MatchingEndpoints.cs +++ b/Refresh.GameServer/Endpoints/Game/MatchingEndpoints.cs @@ -50,8 +50,9 @@ public static string FixupLocationData(string body) jsonBodyBuilder.Append(','); i += corruptedEnd; continue; - // If this is a comma, then we know that a ']' was corrupted + // If this is a comma or end brace, then we know that a ']' was corrupted case ',': + case '}': jsonBodyBuilder.Append(slice[..corruptedEnd]); jsonBodyBuilder.Append(']'); i += corruptedEnd; From 7937125e265bd71d6b1c2ae79561f1b22a9e7ab7 Mon Sep 17 00:00:00 2001 From: Beyley Thomas Date: Fri, 30 Aug 2024 18:24:06 -0700 Subject: [PATCH 03/12] Fix errors when splitting branch out --- .../Endpoints/Game/Handshake/MetadataEndpoints.cs | 2 -- RefreshTests.GameServer/Tests/Levels/UploadTests.cs | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Refresh.GameServer/Endpoints/Game/Handshake/MetadataEndpoints.cs b/Refresh.GameServer/Endpoints/Game/Handshake/MetadataEndpoints.cs index 5b3d44f3..5fbc8e2c 100644 --- a/Refresh.GameServer/Endpoints/Game/Handshake/MetadataEndpoints.cs +++ b/Refresh.GameServer/Endpoints/Game/Handshake/MetadataEndpoints.cs @@ -60,7 +60,6 @@ private static readonly Lazy NetworkSettingsFile [GameEndpoint("network_settings.nws")] [MinimumRole(GameUserRole.Restricted)] - public string NetworkSettings(RequestContext context) public string NetworkSettings(RequestContext context, GameServerConfig config) { bool created = NetworkSettingsFile.IsValueCreated; @@ -110,7 +109,6 @@ private static readonly Lazy TelemetryConfigFile [GameEndpoint("t_conf")] [MinimumRole(GameUserRole.Restricted)] [NullStatusCode(Gone)] - [MinimumRole(GameUserRole.Restricted)] public string? TelemetryConfig(RequestContext context) { bool created = TelemetryConfigFile.IsValueCreated; diff --git a/RefreshTests.GameServer/Tests/Levels/UploadTests.cs b/RefreshTests.GameServer/Tests/Levels/UploadTests.cs index 253dc0bd..f7d7d6ea 100644 --- a/RefreshTests.GameServer/Tests/Levels/UploadTests.cs +++ b/RefreshTests.GameServer/Tests/Levels/UploadTests.cs @@ -49,7 +49,7 @@ public void CanUpdateLevel() Title = "This is a level", Description = "incredible", Publisher = user, - Source = GameLevelSource.User, + Source = GameSlotType.User, }; context.Database.AddLevel(level); @@ -60,7 +60,7 @@ public void CanUpdateLevel() Title = "This is a better level", Description = "incredible.", Publisher = user, - Source = GameLevelSource.User, + Source = GameSlotType.User, }; GameLevel? updatedLevel = context.Database.UpdateLevel(levelUpdate, user); From 44fc386c67dd725c20cc915338fb98c3937439cf Mon Sep 17 00:00:00 2001 From: Beyley Thomas Date: Fri, 30 Aug 2024 18:29:46 -0700 Subject: [PATCH 04/12] Clarify comment --- Refresh.GameServer/Endpoints/Game/Levels/LevelEndpoints.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Refresh.GameServer/Endpoints/Game/Levels/LevelEndpoints.cs b/Refresh.GameServer/Endpoints/Game/Levels/LevelEndpoints.cs index 49773a2f..ef2f277a 100644 --- a/Refresh.GameServer/Endpoints/Game/Levels/LevelEndpoints.cs +++ b/Refresh.GameServer/Endpoints/Game/Levels/LevelEndpoints.cs @@ -87,6 +87,7 @@ public class LevelEndpoints : EndpointGroup // While this does technically return more slot results than the game is expecting, // because we tell the game exactly what the "next page index" is (its not based on count sent), // pagination still seems to work perfectly fine in LBP1! + // The injected items are basically just fake slots which "follow" the current page. injectedAmount += playlists.Count; } } From 6b3caebc7f2fe259775e36dd24205443f4eb3eb0 Mon Sep 17 00:00:00 2001 From: Beyley Thomas Date: Fri, 30 Aug 2024 20:29:45 -0700 Subject: [PATCH 05/12] Apply suggestions from code review Co-authored-by: jvyden Signed-off-by: Beyley Thomas --- .../Endpoints/Game/DataTypes/Response/GameLevelResponse.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameLevelResponse.cs b/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameLevelResponse.cs index 69bdfb35..b3394943 100644 --- a/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameLevelResponse.cs +++ b/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameLevelResponse.cs @@ -253,8 +253,7 @@ public static GameLevelResponse FromHash(string hash, DataContext dataContext) Title = old.Name, IconHash = old.Icon, Description = old.Description, - Location = new GameLocation(old.LocationX, - old.LocationY), + Location = new GameLocation(old.LocationX, old.LocationY), // Playlists are only ever serialized like this in LBP1-like builds GameVersion = TokenGame.LittleBigPlanet1.ToSerializedGame(), Type = GameSlotType.Playlist.ToGameType(), From 97664cae3ee9054b4237cd968488a5695158273e Mon Sep 17 00:00:00 2001 From: Beyley Thomas Date: Fri, 30 Aug 2024 20:31:53 -0700 Subject: [PATCH 06/12] GameDatabaseProvider: Make migrations use time provider --- Refresh.GameServer/Database/GameDatabaseProvider.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Refresh.GameServer/Database/GameDatabaseProvider.cs b/Refresh.GameServer/Database/GameDatabaseProvider.cs index 0f53fa14..e77f4e80 100644 --- a/Refresh.GameServer/Database/GameDatabaseProvider.cs +++ b/Refresh.GameServer/Database/GameDatabaseProvider.cs @@ -151,7 +151,7 @@ protected override void Migrate(Migration migration, ulong oldVersion) if (oldVersion < 67) newUser.JoinDate = DateTimeOffset.FromUnixTimeMilliseconds(oldUser.JoinDate); // In version 69 (nice), users were given last login dates. For now, we'll set that to now. - if (oldVersion < 69 /*nice*/) newUser.LastLoginDate = DateTimeOffset.Now; + if (oldVersion < 69 /*nice*/) newUser.LastLoginDate = this._time.Now; // In version 72, users got settings for permissions regarding certain platforms. // To avoid breakage, we set them to true for existing users. @@ -253,8 +253,8 @@ protected override void Migrate(Migration migration, ulong oldVersion) if (oldVersion < 11) { // Since we dont have a reference point for when the level was actually uploaded, default to now - newLevel.PublishDate = DateTimeOffset.Now; - newLevel.UpdateDate = DateTimeOffset.Now; + newLevel.PublishDate = this._time.Now; + newLevel.UpdateDate = this._time.Now; } // In version 14, level timestamps were fixed @@ -377,7 +377,7 @@ protected override void Migrate(Migration migration, ulong oldVersion) // In version 30, events were given timestamps // Version 32 fixes events with broken timestamps if (oldVersion < 30 || oldVersion < 32 && newEvent.Timestamp.ToUnixTimeMilliseconds() == 0) - newEvent.Timestamp = DateTimeOffset.Now; + newEvent.Timestamp = this._time.Now; // Converts events to use millisecond timestamps if (oldVersion < 33 && newEvent.Timestamp.ToUnixTimeMilliseconds() < 1000000000000) @@ -420,7 +420,7 @@ protected override void Migrate(Migration migration, ulong oldVersion) Token newToken = newTokens.ElementAt(i); if (oldVersion < 36) - newToken.LoginDate = DateTimeOffset.UtcNow; + newToken.LoginDate = this._time.Now; } IQueryable? oldPhotos = migration.OldRealm.DynamicApi.All("GamePhoto"); From 1e686567199abe09644f5b41735a2c553cef6213 Mon Sep 17 00:00:00 2001 From: Beyley Thomas Date: Fri, 30 Aug 2024 20:33:07 -0700 Subject: [PATCH 07/12] Clean up database provider --- Refresh.GameServer/Database/GameDatabaseProvider.cs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/Refresh.GameServer/Database/GameDatabaseProvider.cs b/Refresh.GameServer/Database/GameDatabaseProvider.cs index e77f4e80..5d35cd71 100644 --- a/Refresh.GameServer/Database/GameDatabaseProvider.cs +++ b/Refresh.GameServer/Database/GameDatabaseProvider.cs @@ -43,16 +43,9 @@ protected GameDatabaseProvider(IDateTimeProvider time) typeof(RequestStatistics), typeof(SequentialIdStorage), - // recent activity typeof(Event), - - // announcements typeof(GameAnnouncement), - - // contests typeof(GameContest), - - // photos typeof(GamePhoto), // levels From a739e388b27e2905faaebaec20d072659c6ac8f3 Mon Sep 17 00:00:00 2001 From: Beyley Thomas Date: Sat, 31 Aug 2024 15:15:28 -0700 Subject: [PATCH 08/12] Apply suggestions from code review Co-authored-by: jvyden Signed-off-by: Beyley Thomas --- .../Game/Playlists/PlaylistEndpoints.cs | 35 +++---------------- .../Types/Playlists/LevelPlaylistRelation.cs | 2 +- 2 files changed, 5 insertions(+), 32 deletions(-) diff --git a/Refresh.GameServer/Endpoints/Game/Playlists/PlaylistEndpoints.cs b/Refresh.GameServer/Endpoints/Game/Playlists/PlaylistEndpoints.cs index 7508f176..08a36224 100644 --- a/Refresh.GameServer/Endpoints/Game/Playlists/PlaylistEndpoints.cs +++ b/Refresh.GameServer/Endpoints/Game/Playlists/PlaylistEndpoints.cs @@ -65,8 +65,6 @@ public Response CreatePlaylist(RequestContext context, DataContext dataContext, public SerializedMinimalLevelList? GetPlaylistSlots(RequestContext context, DataContext dataContext, int id) { GamePlaylist? playlist = dataContext.Database.GetPlaylistById(id); - - // Handle an invalid playlist ID if (playlist == null) return null; @@ -106,10 +104,7 @@ public Response CreatePlaylist(RequestContext context, DataContext dataContext, // Allow the author to be unspecified if (authorName != null) { - // Get the user author = dataContext.Database.GetUserByUsername(authorName); - - // Handle when the provided username does not exist on the server if (author == null) return null; } @@ -120,7 +115,6 @@ public Response CreatePlaylist(RequestContext context, DataContext dataContext, if (slotType == "playlist") { GamePlaylist? playlist = dataContext.Database.GetPlaylistById(slotId); - if (playlist == null) return null; @@ -131,7 +125,6 @@ public Response CreatePlaylist(RequestContext context, DataContext dataContext, else { GameLevel? level = dataContext.Database.GetLevelByIdAndType(slotType, slotId); - if (level == null) return null; @@ -157,8 +150,6 @@ public Response CreatePlaylist(RequestContext context, DataContext dataContext, public Response UpdatePlaylistMetadata(RequestContext context, GameDatabaseContext database, GameUser user, int id, SerializedPlaylist body) { GamePlaylist? playlist = database.GetPlaylistById(id); - - // Handle a bad playlist ID if (playlist == null) return NotFound; @@ -176,8 +167,6 @@ public Response UpdatePlaylistMetadata(RequestContext context, GameDatabaseConte public Response DeletePlaylist(RequestContext context, GameDatabaseContext database, GameUser user, int id) { GamePlaylist? playlist = database.GetPlaylistById(id); - - // Handle a bad playlist ID if (playlist == null) return NotFound; @@ -194,18 +183,14 @@ public Response DeletePlaylist(RequestContext context, GameDatabaseContext datab [RequireEmailVerified] public Response AddSlotToPlaylist(RequestContext context, GameDatabaseContext database, GameUser user, int playlistId) { - // Extract the slot type, ensuring its set string? slotType = context.QueryString["slot_type"]; if (slotType == null) return BadRequest; - // Extract the slot ID ensuring its valid if (!int.TryParse(context.QueryString["slot_id"], out int slotId)) return BadRequest; GamePlaylist? parentPlaylist = database.GetPlaylistById(playlistId); - - // If the parent doesn't exist, exit gracefully if (parentPlaylist == null) return NotFound; @@ -213,7 +198,7 @@ public Response AddSlotToPlaylist(RequestContext context, GameDatabaseContext da if (parentPlaylist.Creator.UserId != user.UserId) return Unauthorized; - // Special handling for playlists + // Adding a playlist to a playlist requires a special case, since we use `SubPlaylistRelation` internally to record child playlists. if (slotType == "playlist") { GamePlaylist? childPlaylist = database.GetPlaylistById(slotId); @@ -249,12 +234,9 @@ public Response AddSlotToPlaylist(RequestContext context, GameDatabaseContext da else { GameLevel? level = database.GetLevelByIdAndType(slotType, slotId); - - // If the level doesn't exist, exit gracefully if (level == null) return NotFound; - - // Add the level to the playlist + database.AddLevelToPlaylist(level, parentPlaylist); return OK; @@ -266,18 +248,14 @@ public Response AddSlotToPlaylist(RequestContext context, GameDatabaseContext da public Response RemoveSlotFromPlaylist(RequestContext context, GameDatabaseContext database, GameUser user, int playlistId) { - // Extract the slot type, ensuring its set string? slotType = context.QueryString["slot_type"]; if (slotType == null) return BadRequest; - // Extract the slot ID, ensuring its valid if (!int.TryParse(context.QueryString["slot_id"], out int slotId)) return BadRequest; GamePlaylist? parentPlaylist = database.GetPlaylistById(playlistId); - - // If the parent doesn't exist, exit gracefully if (parentPlaylist == null) return NotFound; @@ -285,16 +263,14 @@ public Response RemoveSlotFromPlaylist(RequestContext context, GameDatabaseConte if (parentPlaylist.Creator.UserId != user.UserId) return Unauthorized; - // Special handling for playlists + // Removing a playlist from a playlist requires a special case, since we use `SubPlaylistRelation` internally to record child playlists. if (slotType == "playlist") { GamePlaylist? childPlaylist = database.GetPlaylistById(slotId); - - // If the child doesn't exist, exit gracefully if (childPlaylist == null) return NotFound; - // Remove the playlist from the parent + database.RemovePlaylistFromPlaylist(childPlaylist, parentPlaylist); // ReSharper disable once ExtractCommonBranchingCode see like 3 lines below (line count subject to change) @@ -306,12 +282,9 @@ public Response RemoveSlotFromPlaylist(RequestContext context, GameDatabaseConte else { GameLevel? level = database.GetLevelByIdAndType(slotType, slotId); - - // If the level doesn't exist, exit gracefully if (level == null) return NotFound; - // Remove the level from the playlist database.RemoveLevelFromPlaylist(level, parentPlaylist); return OK; diff --git a/Refresh.GameServer/Types/Playlists/LevelPlaylistRelation.cs b/Refresh.GameServer/Types/Playlists/LevelPlaylistRelation.cs index 3a9a547b..5bfa0ad3 100644 --- a/Refresh.GameServer/Types/Playlists/LevelPlaylistRelation.cs +++ b/Refresh.GameServer/Types/Playlists/LevelPlaylistRelation.cs @@ -11,7 +11,7 @@ public partial class LevelPlaylistRelation : IRealmObject /// /// The playlist the level is contained in /// - public int PlaylistId { get; set; } + public int PlaylistId { get; set; } /// /// The level contained within the playlist /// From a43746f7506790cce58e6a33dff2e6fce04d6bb5 Mon Sep 17 00:00:00 2001 From: Beyley Thomas Date: Sat, 31 Aug 2024 15:54:46 -0700 Subject: [PATCH 09/12] Apply suggestions from code review --- .../Database/GameDatabaseContext.Playlists.cs | 44 ++++++++-------- .../Database/GameDatabaseProvider.cs | 52 ++++++++++++++++++- .../DataTypes/Response/GameLevelResponse.cs | 4 +- .../Game/Playlists/PlaylistEndpoints.cs | 12 ++--- .../Types/Levels/GameMinimalLevelResponse.cs | 4 +- .../Types/Playlists/GamePlaylist.cs | 8 +-- .../Types/Playlists/LevelPlaylistRelation.cs | 2 +- .../Types/Playlists/SerializedPlaylist.cs | 2 +- .../Types/Playlists/SubPlaylistRelation.cs | 2 +- 9 files changed, 90 insertions(+), 40 deletions(-) diff --git a/Refresh.GameServer/Database/GameDatabaseContext.Playlists.cs b/Refresh.GameServer/Database/GameDatabaseContext.Playlists.cs index dc5c2772..0ef0de18 100644 --- a/Refresh.GameServer/Database/GameDatabaseContext.Playlists.cs +++ b/Refresh.GameServer/Database/GameDatabaseContext.Playlists.cs @@ -12,13 +12,13 @@ public GamePlaylist CreatePlaylist(GameUser user, SerializedPlaylist createInfo, { GamePlaylist playlist = new() { - Creator = user, + Publisher = user, Name = createInfo.Name, Description = createInfo.Description, - Icon = createInfo.Icon, + IconHash = createInfo.Icon, LocationX = createInfo.Location.X, LocationY = createInfo.Location.Y, - RootPlaylist = rootPlaylist, + IsRoot = rootPlaylist, }; this.Write(() => @@ -38,7 +38,7 @@ public void UpdatePlaylist(GamePlaylist playlist, SerializedPlaylist updateInfo) { playlist.Name = updateInfo.Name; playlist.Description = updateInfo.Description; - playlist.Icon = updateInfo.Icon; + playlist.IconHash = updateInfo.Icon; playlist.LocationX = updateInfo.Location.X; playlist.LocationY = updateInfo.Location.Y; }); @@ -49,8 +49,8 @@ public void DeletePlaylist(GamePlaylist playlist) this.Write(() => { // Remove all relations relating to this playlist - this.LevelPlaylistRelations.RemoveRange(l => l.PlaylistId == playlist.PlaylistId); - this.SubPlaylistRelations.RemoveRange(l => l.PlaylistId == playlist.PlaylistId || l.SubPlaylist == playlist); + this.LevelPlaylistRelations.RemoveRange(l => l.Playlist == playlist); + this.SubPlaylistRelations.RemoveRange(l => l.Playlist == playlist || l.SubPlaylist == playlist); // Remove the playlist object this.GamePlaylists.Remove(playlist); @@ -62,13 +62,13 @@ public void AddPlaylistToPlaylist(GamePlaylist child, GamePlaylist parent) this.Write(() => { // Make sure to not create a duplicate object - if (this.SubPlaylistRelations.Any(p => p.SubPlaylist == child && p.PlaylistId == parent.PlaylistId)) + if (this.SubPlaylistRelations.Any(p => p.SubPlaylist == child && p.Playlist == parent)) return; // Add the relation this.SubPlaylistRelations.Add(new SubPlaylistRelation { - PlaylistId = parent.PlaylistId, + Playlist = parent, SubPlaylist = child, }); }); @@ -79,7 +79,7 @@ public void RemovePlaylistFromPlaylist(GamePlaylist child, GamePlaylist parent) this.Write(() => { SubPlaylistRelation? relation = - this.SubPlaylistRelations.FirstOrDefault(r => r.SubPlaylist == child && r.PlaylistId == parent.PlaylistId); + this.SubPlaylistRelations.FirstOrDefault(r => r.SubPlaylist == child && r.Playlist == parent); if (relation == null) return; @@ -93,14 +93,14 @@ public void AddLevelToPlaylist(GameLevel level, GamePlaylist parent) this.Write(() => { // Make sure to not create a duplicate object - if (this.LevelPlaylistRelations.Any(p => p.Level == level && p.PlaylistId == parent.PlaylistId)) + if (this.LevelPlaylistRelations.Any(p => p.Level == level && p.Playlist == parent)) return; // Add the relation this.LevelPlaylistRelations.Add(new LevelPlaylistRelation { Level = level, - PlaylistId = parent.PlaylistId, + Playlist = parent, }); }); } @@ -110,7 +110,7 @@ public void RemoveLevelFromPlaylist(GameLevel level, GamePlaylist parent) this.Write(() => { LevelPlaylistRelation? relation = - this.LevelPlaylistRelations.FirstOrDefault(r => r.Level == level && r.PlaylistId == parent.PlaylistId); + this.LevelPlaylistRelations.FirstOrDefault(r => r.Level == level && r.Playlist == parent); if (relation == null) return; @@ -122,33 +122,33 @@ public void RemoveLevelFromPlaylist(GameLevel level, GamePlaylist parent) public IEnumerable GetPlaylistsContainingPlaylist(GamePlaylist playlist) // TODO: with postgres this can be IQueryable => this.SubPlaylistRelations.Where(p => p.SubPlaylist == playlist).AsEnumerable() - .Select(r => this.GamePlaylists.First(p => p.PlaylistId == r.PlaylistId)) - .Where(p => !p.RootPlaylist); + .Select(r => this.GamePlaylists.First(p => p == r.Playlist)) + .Where(p => !p.IsRoot); public IEnumerable GetPlaylistsByAuthorContainingPlaylist(GameUser user, GamePlaylist playlist) // TODO: with postgres this can be IQueryable => this.SubPlaylistRelations.Where(p => p.SubPlaylist == playlist).AsEnumerable() - .Select(r => this.GamePlaylists.First(p => p.PlaylistId == r.PlaylistId)) - .Where(p => p.Creator.UserId == user.UserId) - .Where(p => !p.RootPlaylist); + .Select(r => this.GamePlaylists.First(p => p == r.Playlist)) + .Where(p => p.Publisher.UserId == user.UserId) + .Where(p => !p.IsRoot); public IEnumerable GetLevelsInPlaylist(GamePlaylist playlist, TokenGame game) => // TODO: When we have postgres, remove the `AsEnumerable` call for performance. - this.LevelPlaylistRelations.Where(l => l.PlaylistId == playlist.PlaylistId).AsEnumerable() + this.LevelPlaylistRelations.Where(l => l.Playlist == playlist).AsEnumerable() .Select(l => l.Level).FilterByGameVersion(game); public IEnumerable GetPlaylistsInPlaylist(GamePlaylist playlist) // TODO: When we have postgres, remove the `AsEnumerable` call for performance. - => this.SubPlaylistRelations.Where(p => p.PlaylistId == playlist.PlaylistId).AsEnumerable().Select(l => l.SubPlaylist); + => this.SubPlaylistRelations.Where(p => p.Playlist == playlist).AsEnumerable().Select(l => l.SubPlaylist); public IEnumerable GetPlaylistsByAuthorContainingLevel(GameUser author, GameLevel level) // TODO: When we have postgres, remove the `AsEnumerable` call for performance. => this.LevelPlaylistRelations.Where(p => p.Level == level).AsEnumerable() - .Select(r => this.GamePlaylists.First(p => p.PlaylistId == r.PlaylistId)) - .Where(p => p.Creator.UserId == author.UserId); + .Select(r => this.GamePlaylists.First(p => p == r.Playlist)) + .Where(p => p.Publisher.UserId == author.UserId); public IEnumerable GetPlaylistsContainingLevel(GameLevel level) // TODO: When we have postgres, remove the `AsEnumerable` call for performance. => this.LevelPlaylistRelations.Where(p => p.Level == level).AsEnumerable() - .Select(r => this.GamePlaylists.First(p => p.PlaylistId == r.PlaylistId)); + .Select(r => this.GamePlaylists.First(p => p == r.Playlist)); } \ No newline at end of file diff --git a/Refresh.GameServer/Database/GameDatabaseProvider.cs b/Refresh.GameServer/Database/GameDatabaseProvider.cs index 5d35cd71..4e95e7b6 100644 --- a/Refresh.GameServer/Database/GameDatabaseProvider.cs +++ b/Refresh.GameServer/Database/GameDatabaseProvider.cs @@ -34,7 +34,7 @@ protected GameDatabaseProvider(IDateTimeProvider time) this._time = time; } - protected override ulong SchemaVersion => 154; + protected override ulong SchemaVersion => 155; protected override string Filename => "refreshGameServer.realm"; @@ -668,5 +668,55 @@ protected override void Migrate(Migration migration, ulong oldVersion) newUniquePlayLevelRelation.Timestamp = DateTimeOffset.FromUnixTimeMilliseconds(oldUniquePlayLevelRelation.Timestamp); } + + IQueryable? oldGamePlaylists = migration.OldRealm.DynamicApi.All("GamePlaylist"); + IQueryable? newGamePlaylists = migration.NewRealm.All(); + + if (oldVersion < 155) + for (int i = 0; i < newGamePlaylists.Count(); i++) + { + dynamic oldGamePlaylist = oldGamePlaylists.ElementAt(i); + GamePlaylist newGamePlaylist = newGamePlaylists.ElementAt(i); + + // In version 155 some fields got renamed + if (oldVersion < 155) + { + newGamePlaylist.IconHash = oldGamePlaylist.Icon; + newGamePlaylist.Publisher = migration.NewRealm.Find(oldGamePlaylist.Creator.UserId); + newGamePlaylist.IsRoot = oldGamePlaylist.RootPlaylist; + } + } + + IQueryable? oldLevelPlaylistRelations = migration.OldRealm.DynamicApi.All("LevelPlaylistRelation"); + IQueryable? newLevelPlaylistRelations = migration.NewRealm.All(); + + if (oldVersion < 155) + for (int i = 0; i < newGamePlaylists.Count(); i++) + { + dynamic oldLevelPlaylistRelation = oldLevelPlaylistRelations.ElementAt(i); + LevelPlaylistRelation newLevelPlaylistRelation = newLevelPlaylistRelations.ElementAt(i); + + // In version 155, id was moved to direct reference + if (oldVersion < 155) + { + newLevelPlaylistRelation.Playlist = migration.NewRealm.Find(oldLevelPlaylistRelation.PlaylistId); + } + } + + IQueryable? oldSubPlaylistRelations = migration.OldRealm.DynamicApi.All("SubPlaylistRelation"); + IQueryable? newSubPlaylistRelations = migration.NewRealm.All(); + + if (oldVersion < 155) + for (int i = 0; i < newGamePlaylists.Count(); i++) + { + dynamic oldSubPlaylistRelation = oldSubPlaylistRelations.ElementAt(i); + SubPlaylistRelation newSubPlaylistRelation = newSubPlaylistRelations.ElementAt(i); + + // In version 155, id was moved to direct reference + if (oldVersion < 155) + { + newSubPlaylistRelation.Playlist = migration.NewRealm.Find(oldSubPlaylistRelation.PlaylistId); + } + } } } \ No newline at end of file diff --git a/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameLevelResponse.cs b/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameLevelResponse.cs index b3394943..13d5180f 100644 --- a/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameLevelResponse.cs +++ b/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameLevelResponse.cs @@ -251,13 +251,13 @@ public static GameLevelResponse FromHash(string hash, DataContext dataContext) LevelId = old.PlaylistId, IsAdventure = false, Title = old.Name, - IconHash = old.Icon, + IconHash = old.IconHash, Description = old.Description, Location = new GameLocation(old.LocationX, old.LocationY), // Playlists are only ever serialized like this in LBP1-like builds GameVersion = TokenGame.LittleBigPlanet1.ToSerializedGame(), Type = GameSlotType.Playlist.ToGameType(), - Handle = SerializedUserHandle.FromUser(old.Creator, dataContext), + Handle = SerializedUserHandle.FromUser(old.Publisher, dataContext), RootResource = "0", PublishDate = old.CreationDate.ToUnixTimeMilliseconds(), UpdateDate = old.LastUpdateDate.ToUnixTimeMilliseconds(), diff --git a/Refresh.GameServer/Endpoints/Game/Playlists/PlaylistEndpoints.cs b/Refresh.GameServer/Endpoints/Game/Playlists/PlaylistEndpoints.cs index 08a36224..c00c5226 100644 --- a/Refresh.GameServer/Endpoints/Game/Playlists/PlaylistEndpoints.cs +++ b/Refresh.GameServer/Endpoints/Game/Playlists/PlaylistEndpoints.cs @@ -35,7 +35,7 @@ public Response CreatePlaylist(RequestContext context, DataContext dataContext, return BadRequest; // Dont let you create a sub-playlist of someone else's playlist - if (user.UserId != parent.Creator.UserId) + if (user.UserId != parent.Publisher.UserId) return Unauthorized; // If the user has no root playlist, but they are trying to create a sub-playlist, something has gone wrong. @@ -51,7 +51,7 @@ public Response CreatePlaylist(RequestContext context, DataContext dataContext, dataContext.Database.AddPlaylistToPlaylist(playlist, parent); // If this new playlist is the root playlist, mark the user's root playlist as it - if (playlist.RootPlaylist) + if (playlist.IsRoot) dataContext.Database.SetUserRootPlaylist(user, playlist); // Create the new playlist, returning the data @@ -154,7 +154,7 @@ public Response UpdatePlaylistMetadata(RequestContext context, GameDatabaseConte return NotFound; // Dont allow the wrong user to update playlists - if (playlist.Creator.UserId != user.UserId) + if (playlist.Publisher.UserId != user.UserId) return Unauthorized; database.UpdatePlaylist(playlist, body); @@ -171,7 +171,7 @@ public Response DeletePlaylist(RequestContext context, GameDatabaseContext datab return NotFound; // Dont allow the wrong user to delete playlists - if (playlist.Creator.UserId != user.UserId) + if (playlist.Publisher.UserId != user.UserId) return Unauthorized; database.DeletePlaylist(playlist); @@ -195,7 +195,7 @@ public Response AddSlotToPlaylist(RequestContext context, GameDatabaseContext da return NotFound; // Dont let people add slots to other's playlists - if (parentPlaylist.Creator.UserId != user.UserId) + if (parentPlaylist.Publisher.UserId != user.UserId) return Unauthorized; // Adding a playlist to a playlist requires a special case, since we use `SubPlaylistRelation` internally to record child playlists. @@ -260,7 +260,7 @@ public Response RemoveSlotFromPlaylist(RequestContext context, GameDatabaseConte return NotFound; // Dont let people remove slots from other's playlists - if (parentPlaylist.Creator.UserId != user.UserId) + if (parentPlaylist.Publisher.UserId != user.UserId) return Unauthorized; // Removing a playlist from a playlist requires a special case, since we use `SubPlaylistRelation` internally to record child playlists. diff --git a/Refresh.GameServer/Types/Levels/GameMinimalLevelResponse.cs b/Refresh.GameServer/Types/Levels/GameMinimalLevelResponse.cs index f96fd23c..e9951c29 100644 --- a/Refresh.GameServer/Types/Levels/GameMinimalLevelResponse.cs +++ b/Refresh.GameServer/Types/Levels/GameMinimalLevelResponse.cs @@ -113,7 +113,7 @@ public static GameMinimalLevelResponse FromHash(string hash, DataContext dataCon LevelId = old.PlaylistId, IsAdventure = false, Title = old.Name, - IconHash = dataContext.GetIconFromHash(old.Icon), + IconHash = dataContext.GetIconFromHash(old.IconHash), Description = old.Description, Type = GameSlotType.Playlist.ToGameType(), Location = new GameLocation(old.LocationX, old.LocationY), @@ -140,7 +140,7 @@ public static GameMinimalLevelResponse FromHash(string hash, DataContext dataCon IsCopyable = 0, Tags = string.Empty, TeamPicked = false, - Handle = SerializedUserHandle.FromUser(old.Creator, dataContext), + Handle = SerializedUserHandle.FromUser(old.Publisher, dataContext), }; } diff --git a/Refresh.GameServer/Types/Playlists/GamePlaylist.cs b/Refresh.GameServer/Types/Playlists/GamePlaylist.cs index 38b06d91..41a1d327 100644 --- a/Refresh.GameServer/Types/Playlists/GamePlaylist.cs +++ b/Refresh.GameServer/Types/Playlists/GamePlaylist.cs @@ -15,9 +15,9 @@ public partial class GamePlaylist : IRealmObject, ISequentialId [PrimaryKey] public int PlaylistId { get; set; } /// - /// The user who created the playlist + /// The user who published the playlist /// - public GameUser Creator { get; set; } + public GameUser Publisher { get; set; } /// /// The name of the playlist @@ -31,7 +31,7 @@ public partial class GamePlaylist : IRealmObject, ISequentialId /// /// The playlist's icon, either a GUID or Hashed asset /// - public string Icon { get; set; } + public string IconHash { get; set; } public int LocationX { get; set; } public int LocationY { get; set; } @@ -48,7 +48,7 @@ public partial class GamePlaylist : IRealmObject, ISequentialId /// /// Whether or not this playlist is a root playlist. This is to let us hide the root playlists when we /// - public bool RootPlaylist { get; set; } + public bool IsRoot { get; set; } public int SequentialId { diff --git a/Refresh.GameServer/Types/Playlists/LevelPlaylistRelation.cs b/Refresh.GameServer/Types/Playlists/LevelPlaylistRelation.cs index 5bfa0ad3..c6efea35 100644 --- a/Refresh.GameServer/Types/Playlists/LevelPlaylistRelation.cs +++ b/Refresh.GameServer/Types/Playlists/LevelPlaylistRelation.cs @@ -11,7 +11,7 @@ public partial class LevelPlaylistRelation : IRealmObject /// /// The playlist the level is contained in /// - public int PlaylistId { get; set; } + public GamePlaylist Playlist { get; set; } /// /// The level contained within the playlist /// diff --git a/Refresh.GameServer/Types/Playlists/SerializedPlaylist.cs b/Refresh.GameServer/Types/Playlists/SerializedPlaylist.cs index b2ee455d..75a1c2b8 100644 --- a/Refresh.GameServer/Types/Playlists/SerializedPlaylist.cs +++ b/Refresh.GameServer/Types/Playlists/SerializedPlaylist.cs @@ -31,7 +31,7 @@ public class SerializedPlaylist : IDataConvertableFrom /// The playlist the level is contained in /// - public int PlaylistId { get; set; } + public GamePlaylist Playlist { get; set; } /// /// The sub-playlist contained within the playlist /// From 617b29d5a98138776cf8bd00f4291a756f971f61 Mon Sep 17 00:00:00 2001 From: Beyley Thomas Date: Sat, 31 Aug 2024 16:13:45 -0700 Subject: [PATCH 10/12] Remove GameLevel.Source How this was implemented in the past was strange to say the least. Slot ID 0 is invalid anyway, so let's re-use "story id == 0" for "is user level". --- .../Database/GameDatabaseContext.Levels.cs | 30 ++++++++++--------- .../Database/GameDatabaseProvider.cs | 9 +----- .../DataTypes/Request/GameLevelRequest.cs | 1 - .../DataTypes/Response/GameLevelResponse.cs | 2 +- .../Endpoints/Game/ReportingEndpoints.cs | 2 +- Refresh.GameServer/Types/Levels/GameLevel.cs | 20 ++++--------- .../Types/Matching/IRoomAccessor.cs | 2 +- .../Types/Reviews/SerializedGameReview.cs | 2 +- RefreshTests.GameServer/TestContext.cs | 1 - .../Tests/Levels/UploadTests.cs | 6 ---- 10 files changed, 26 insertions(+), 49 deletions(-) diff --git a/Refresh.GameServer/Database/GameDatabaseContext.Levels.cs b/Refresh.GameServer/Database/GameDatabaseContext.Levels.cs index 4641af1f..13f2406a 100644 --- a/Refresh.GameServer/Database/GameDatabaseContext.Levels.cs +++ b/Refresh.GameServer/Database/GameDatabaseContext.Levels.cs @@ -51,7 +51,6 @@ public GameLevel GetStoryLevelById(int id) { Title = $"Story level #{id}", Publisher = null, - Source = GameSlotType.Story, StoryId = id, }; @@ -180,8 +179,11 @@ public void DeleteLevel(GameLevel level) }); } + private IQueryable GetLevelsByGameVersion(TokenGame gameVersion) - => this.GameLevels.Where(l => l._Source == (int)GameSlotType.User).FilterByGameVersion(gameVersion); + => this.GameLevels + .Where(l => l.StoryId == 0) // Filter out any user levels + .FilterByGameVersion(gameVersion); [Pure] public DatabaseList GetLevelsByUser(GameUser user, int count, int skip, LevelFilterSettings levelFilterSettings, GameUser? accessor) @@ -193,7 +195,7 @@ public DatabaseList GetLevelsByUser(GameUser user, int count, int ski if (user.Username == SystemUsers.UnknownUserName) { - return new DatabaseList(this.GetLevelsByGameVersion(levelFilterSettings.GameVersion).FilterByLevelFilterSettings(null, levelFilterSettings).Where(l => l.IsReUpload && String.IsNullOrEmpty(l.OriginalPublisher)), skip, count); + return new DatabaseList(this.GetLevelsByGameVersion(levelFilterSettings.GameVersion).FilterByLevelFilterSettings(null, levelFilterSettings).Where(l => l.IsReUpload && string.IsNullOrEmpty(l.OriginalPublisher)), skip, count); } if (user.Username.StartsWith(SystemUsers.SystemPrefix)) @@ -209,11 +211,11 @@ public DatabaseList GetLevelsByUser(GameUser user, int count, int ski [Pure] public DatabaseList GetUserLevelsChunk(int skip, int count) - => new(this.GameLevels.Where(l => l._Source == (int)GameSlotType.User), skip, count); + => new(this.GameLevels.Where(l => l.StoryId == 0), skip, count); [Pure] public IQueryable GetAllUserLevels() - => this.GameLevels.Where(l => l._Source == (int)GameSlotType.User); + => this.GameLevels.Where(l => l.StoryId == 0); [Pure] public DatabaseList GetNewestLevels(int count, int skip, GameUser? user, LevelFilterSettings levelFilterSettings) => @@ -245,7 +247,7 @@ public DatabaseList GetMostHeartedLevels(int count, int skip, GameUse .OrderByDescending(x => x.Count) .Select(x => x.Level) .Where(l => l != null) - .Where(l => l._Source == (int)GameSlotType.User) + .Where(l => l.StoryId == 0) .FilterByLevelFilterSettings(user, levelFilterSettings) .FilterByGameVersion(levelFilterSettings.GameVersion); @@ -262,7 +264,7 @@ public DatabaseList GetLevelsByTag(int count, int skip, GameUser? use .AsEnumerable() .Select(x => x.Level) .Distinct() - .Where(l => l._Source == (int)GameSlotType.User) + .Where(l => l.StoryId == 0) .OrderByDescending(l => l.PublishDate) .FilterByLevelFilterSettings(user, levelFilterSettings) .FilterByGameVersion(levelFilterSettings.GameVersion); @@ -282,7 +284,7 @@ public DatabaseList GetMostUniquelyPlayedLevels(int count, int skip, .OrderByDescending(x => x.Count) .Select(x => x.Level) .Where(l => l != null) - .Where(l => l._Source == (int)GameSlotType.User) + .Where(l => l.StoryId == 0) .FilterByLevelFilterSettings(user, levelFilterSettings) .FilterByGameVersion(levelFilterSettings.GameVersion); @@ -319,7 +321,7 @@ public DatabaseList GetHighestRatedLevels(int count, int skip, GameUs .OrderByDescending(x => x.Karma) // reddit moment .Select(x => x.Level) .Where(l => l != null) - .Where(l => l._Source == (int)GameSlotType.User) + .Where(l => l.StoryId == 0) .FilterByLevelFilterSettings(user, levelFilterSettings) .FilterByGameVersion(levelFilterSettings.GameVersion); @@ -336,7 +338,7 @@ public DatabaseList GetTeamPickedLevels(int count, int skip, GameUser [Pure] public DatabaseList GetDeveloperLevels(int count, int skip, LevelFilterSettings levelFilterSettings) => new(this.GameLevels - .Where(l => l._Source == (int)GameSlotType.Story) + .Where(l => l.StoryId != 0) // filter to only levels with a story ID set .FilterByLevelFilterSettings(null, levelFilterSettings) .OrderByDescending(l => l.Title), skip, count); @@ -349,7 +351,7 @@ public DatabaseList GetBusiestLevels(int count, int skip, MatchServic .OrderBy(r => r.Sum(room => room.PlayerIds.Count)); return new DatabaseList(rooms.Select(r => r.Key) - .Where(l => l != null && l._Source == (int)GameSlotType.User)! + .Where(l => l != null && l.StoryId == 0)! .FilterByLevelFilterSettings(user, levelFilterSettings) .FilterByGameVersion(levelFilterSettings.GameVersion), skip, count); } @@ -402,13 +404,13 @@ public DatabaseList SearchForLevels(int count, int skip, GameUser? us } [Pure] - public int GetTotalLevelCount(TokenGame game) => this.GameLevels.FilterByGameVersion(game).Count(l => l._Source == (int)GameSlotType.User); + public int GetTotalLevelCount(TokenGame game) => this.GameLevels.FilterByGameVersion(game).Count(l => l.StoryId == 0); [Pure] - public int GetTotalLevelCount() => this.GameLevels.Count(l => l._Source == (int)GameSlotType.User); + public int GetTotalLevelCount() => this.GameLevels.Count(l => l.StoryId == 0); [Pure] - public int GetModdedLevelCount() => this.GameLevels.Count(l => l._Source == (int)GameSlotType.User && l.IsModded); + public int GetModdedLevelCount() => this.GameLevels.Count(l => l.StoryId == 0 && l.IsModded); public int GetTotalLevelsPublishedByUser(GameUser user) => this.GameLevels diff --git a/Refresh.GameServer/Database/GameDatabaseProvider.cs b/Refresh.GameServer/Database/GameDatabaseProvider.cs index 4e95e7b6..d65b8e7d 100644 --- a/Refresh.GameServer/Database/GameDatabaseProvider.cs +++ b/Refresh.GameServer/Database/GameDatabaseProvider.cs @@ -34,7 +34,7 @@ protected GameDatabaseProvider(IDateTimeProvider time) this._time = time; } - protected override ulong SchemaVersion => 155; + protected override ulong SchemaVersion => 156; protected override string Filename => "refreshGameServer.realm"; @@ -280,13 +280,6 @@ protected override void Migrate(Migration migration, ulong oldVersion) newLevel._GameVersion = (int)TokenGame.LittleBigPlanet2; } - // In version 92, we started storing both user and story levels. - // Set all existing levels to user levels, since that's what has existed up until now. - if (oldVersion < 92) - { - newLevel._Source = (int)GameSlotType.User; - } - // In version 129, we split locations from an embedded object out to two fields if (oldVersion < 129) { diff --git a/Refresh.GameServer/Endpoints/Game/DataTypes/Request/GameLevelRequest.cs b/Refresh.GameServer/Endpoints/Game/DataTypes/Request/GameLevelRequest.cs index 2cf2c86e..48502585 100644 --- a/Refresh.GameServer/Endpoints/Game/DataTypes/Request/GameLevelRequest.cs +++ b/Refresh.GameServer/Endpoints/Game/DataTypes/Request/GameLevelRequest.cs @@ -74,6 +74,5 @@ public GameLevel ToGameLevel(GameUser publisher) => IsSubLevel = this.IsSubLevel, IsCopyable = this.IsCopyable == 1, BackgroundGuid = this.BackgroundGuid, - Source = GameSlotType.User, }; } \ No newline at end of file diff --git a/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameLevelResponse.cs b/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameLevelResponse.cs index 13d5180f..e874dd94 100644 --- a/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameLevelResponse.cs +++ b/Refresh.GameServer/Endpoints/Game/DataTypes/Response/GameLevelResponse.cs @@ -181,7 +181,7 @@ public static GameLevelResponse FromHash(string hash, DataContext dataContext) ReviewCount = old.Reviews.Count, CommentCount = dataContext.Database.GetTotalCommentsForLevel(old), Tags = string.Join(',', dataContext.Database.GetTagsForLevel(old).Select(t => t.Tag.ToLbpString())) , - Type = old.Source.ToGameType(), + Type = old.SlotType.ToGameType(), }; if (old is { Publisher: not null, IsReUpload: false }) diff --git a/Refresh.GameServer/Endpoints/Game/ReportingEndpoints.cs b/Refresh.GameServer/Endpoints/Game/ReportingEndpoints.cs index 09ed7f5e..a46251f1 100644 --- a/Refresh.GameServer/Endpoints/Game/ReportingEndpoints.cs +++ b/Refresh.GameServer/Endpoints/Game/ReportingEndpoints.cs @@ -82,7 +82,7 @@ public Response UploadReport(RequestContext context, GameDatabaseContext databas { LevelId = level.LevelId, Title = level.Title, - Type = level.Source switch { + Type = level.SlotType switch { GameSlotType.User => "user", GameSlotType.Story => "developer", _ => throw new ArgumentOutOfRangeException(), diff --git a/Refresh.GameServer/Types/Levels/GameLevel.cs b/Refresh.GameServer/Types/Levels/GameLevel.cs index a4ad506b..61b36c6d 100644 --- a/Refresh.GameServer/Types/Levels/GameLevel.cs +++ b/Refresh.GameServer/Types/Levels/GameLevel.cs @@ -77,23 +77,13 @@ public GameLevelType LevelType internal int _LevelType { get; set; } /// - /// The source slot type that a level has come from. - /// - /// The only valid values of this are `User` or `Story`. - /// - [Ignored] public GameSlotType Source - { - get => (GameSlotType)this._Source; - set => this._Source = (int)value; - } - - // ReSharper disable once InconsistentNaming - internal int _Source { get; set; } - - /// - /// The associated ID for the developer level, this is only relevant if Source == Story + /// The associated ID for the developer level. + /// Set to 0 for user generated levels, since slot IDs of zero are invalid ingame. /// [Indexed] public int StoryId { get; set; } + + public GameSlotType SlotType + => this.StoryId == 0 ? GameSlotType.User : GameSlotType.Story; public bool IsLocked { get; set; } public bool IsSubLevel { get; set; } diff --git a/Refresh.GameServer/Types/Matching/IRoomAccessor.cs b/Refresh.GameServer/Types/Matching/IRoomAccessor.cs index 0c30564b..733efe27 100644 --- a/Refresh.GameServer/Types/Matching/IRoomAccessor.cs +++ b/Refresh.GameServer/Types/Matching/IRoomAccessor.cs @@ -68,7 +68,7 @@ public interface IRoomAccessor /// The level to check /// The found rooms public IEnumerable GetRoomsInLevel(GameLevel level) => this.GetRoomsInLevel( - level.Source switch + level.SlotType switch { GameSlotType.User => RoomSlotType.Online, GameSlotType.Story => RoomSlotType.Story, diff --git a/Refresh.GameServer/Types/Reviews/SerializedGameReview.cs b/Refresh.GameServer/Types/Reviews/SerializedGameReview.cs index 8667d881..18d25f30 100644 --- a/Refresh.GameServer/Types/Reviews/SerializedGameReview.cs +++ b/Refresh.GameServer/Types/Reviews/SerializedGameReview.cs @@ -65,7 +65,7 @@ public class SerializedGameReview : IDataConvertableFrom context.Database.AddLevel(level), Throws.InvalidOperationException); @@ -49,7 +47,6 @@ public void CanUpdateLevel() Title = "This is a level", Description = "incredible", Publisher = user, - Source = GameSlotType.User, }; context.Database.AddLevel(level); @@ -60,7 +57,6 @@ public void CanUpdateLevel() Title = "This is a better level", Description = "incredible.", Publisher = user, - Source = GameSlotType.User, }; GameLevel? updatedLevel = context.Database.UpdateLevel(levelUpdate, user); @@ -87,7 +83,6 @@ public void CantUpdateOtherUsersLevels() LevelId = level.LevelId, RootResource = "Malware", Publisher = baddie, - Source = GameSlotType.User, }; GameLevel? updatedLevel = context.Database.UpdateLevel(levelUpdate, baddie); @@ -110,7 +105,6 @@ public void CantUpdateNonExistentLevels() { LevelId = 69696969, Publisher = author, - Source = GameSlotType.User, }; GameLevel? updatedLevel = context.Database.UpdateLevel(levelUpdate, author); From 7f6a901a1edda230a3e1f09dd26f4cf2e1b96d74 Mon Sep 17 00:00:00 2001 From: jvyden Date: Thu, 5 Sep 2024 19:02:57 -0400 Subject: [PATCH 11/12] Remove existing GamePlaylist migrations --- .../Database/GameDatabaseProvider.cs | 72 +++++++------------ 1 file changed, 25 insertions(+), 47 deletions(-) diff --git a/Refresh.GameServer/Database/GameDatabaseProvider.cs b/Refresh.GameServer/Database/GameDatabaseProvider.cs index d65b8e7d..6b9918e6 100644 --- a/Refresh.GameServer/Database/GameDatabaseProvider.cs +++ b/Refresh.GameServer/Database/GameDatabaseProvider.cs @@ -662,54 +662,32 @@ protected override void Migrate(Migration migration, ulong oldVersion) DateTimeOffset.FromUnixTimeMilliseconds(oldUniquePlayLevelRelation.Timestamp); } - IQueryable? oldGamePlaylists = migration.OldRealm.DynamicApi.All("GamePlaylist"); - IQueryable? newGamePlaylists = migration.NewRealm.All(); + // IQueryable? oldGamePlaylists = migration.OldRealm.DynamicApi.All("GamePlaylist"); + // IQueryable? newGamePlaylists = migration.NewRealm.All(); - if (oldVersion < 155) - for (int i = 0; i < newGamePlaylists.Count(); i++) - { - dynamic oldGamePlaylist = oldGamePlaylists.ElementAt(i); - GamePlaylist newGamePlaylist = newGamePlaylists.ElementAt(i); - - // In version 155 some fields got renamed - if (oldVersion < 155) - { - newGamePlaylist.IconHash = oldGamePlaylist.Icon; - newGamePlaylist.Publisher = migration.NewRealm.Find(oldGamePlaylist.Creator.UserId); - newGamePlaylist.IsRoot = oldGamePlaylist.RootPlaylist; - } - } - - IQueryable? oldLevelPlaylistRelations = migration.OldRealm.DynamicApi.All("LevelPlaylistRelation"); - IQueryable? newLevelPlaylistRelations = migration.NewRealm.All(); - - if (oldVersion < 155) - for (int i = 0; i < newGamePlaylists.Count(); i++) - { - dynamic oldLevelPlaylistRelation = oldLevelPlaylistRelations.ElementAt(i); - LevelPlaylistRelation newLevelPlaylistRelation = newLevelPlaylistRelations.ElementAt(i); - - // In version 155, id was moved to direct reference - if (oldVersion < 155) - { - newLevelPlaylistRelation.Playlist = migration.NewRealm.Find(oldLevelPlaylistRelation.PlaylistId); - } - } + // if (oldVersion < 155) + // for (int i = 0; i < newGamePlaylists.Count(); i++) + // { + // dynamic oldGamePlaylist = oldGamePlaylists.ElementAt(i); + // GamePlaylist newGamePlaylist = newGamePlaylists.ElementAt(i); + // } - IQueryable? oldSubPlaylistRelations = migration.OldRealm.DynamicApi.All("SubPlaylistRelation"); - IQueryable? newSubPlaylistRelations = migration.NewRealm.All(); - - if (oldVersion < 155) - for (int i = 0; i < newGamePlaylists.Count(); i++) - { - dynamic oldSubPlaylistRelation = oldSubPlaylistRelations.ElementAt(i); - SubPlaylistRelation newSubPlaylistRelation = newSubPlaylistRelations.ElementAt(i); - - // In version 155, id was moved to direct reference - if (oldVersion < 155) - { - newSubPlaylistRelation.Playlist = migration.NewRealm.Find(oldSubPlaylistRelation.PlaylistId); - } - } + // IQueryable? oldLevelPlaylistRelations = migration.OldRealm.DynamicApi.All("LevelPlaylistRelation"); + // IQueryable? newLevelPlaylistRelations = migration.NewRealm.All(); + // if (oldVersion < 155) + // for (int i = 0; i < newGamePlaylists.Count(); i++) + // { + // dynamic oldLevelPlaylistRelation = oldLevelPlaylistRelations.ElementAt(i); + // LevelPlaylistRelation newLevelPlaylistRelation = newLevelPlaylistRelations.ElementAt(i); + // } + + // IQueryable? oldSubPlaylistRelations = migration.OldRealm.DynamicApi.All("SubPlaylistRelation"); + // IQueryable? newSubPlaylistRelations = migration.NewRealm.All(); + // if (oldVersion < 155) + // for (int i = 0; i < newGamePlaylists.Count(); i++) + // { + // dynamic oldSubPlaylistRelation = oldSubPlaylistRelations.ElementAt(i); + // SubPlaylistRelation newSubPlaylistRelation = newSubPlaylistRelations.ElementAt(i); + // } } } \ No newline at end of file From 0659b7c9b02bf04d09c1b063ad504fc26cce657a Mon Sep 17 00:00:00 2001 From: Beyley Thomas Date: Thu, 5 Sep 2024 16:42:08 -0700 Subject: [PATCH 12/12] Fix realm errors --- .../Database/GameDatabaseContext.Playlists.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Refresh.GameServer/Database/GameDatabaseContext.Playlists.cs b/Refresh.GameServer/Database/GameDatabaseContext.Playlists.cs index 0ef0de18..8ad53009 100644 --- a/Refresh.GameServer/Database/GameDatabaseContext.Playlists.cs +++ b/Refresh.GameServer/Database/GameDatabaseContext.Playlists.cs @@ -122,33 +122,35 @@ public void RemoveLevelFromPlaylist(GameLevel level, GamePlaylist parent) public IEnumerable GetPlaylistsContainingPlaylist(GamePlaylist playlist) // TODO: with postgres this can be IQueryable => this.SubPlaylistRelations.Where(p => p.SubPlaylist == playlist).AsEnumerable() - .Select(r => this.GamePlaylists.First(p => p == r.Playlist)) + .Select(r => this.GamePlaylists.First(p => p.PlaylistId == r.Playlist.PlaylistId)) .Where(p => !p.IsRoot); public IEnumerable GetPlaylistsByAuthorContainingPlaylist(GameUser user, GamePlaylist playlist) // TODO: with postgres this can be IQueryable => this.SubPlaylistRelations.Where(p => p.SubPlaylist == playlist).AsEnumerable() - .Select(r => this.GamePlaylists.First(p => p == r.Playlist)) + .Select(r => this.GamePlaylists.First(p => p.PlaylistId == r.Playlist.PlaylistId)) .Where(p => p.Publisher.UserId == user.UserId) .Where(p => !p.IsRoot); public IEnumerable GetLevelsInPlaylist(GamePlaylist playlist, TokenGame game) => // TODO: When we have postgres, remove the `AsEnumerable` call for performance. this.LevelPlaylistRelations.Where(l => l.Playlist == playlist).AsEnumerable() - .Select(l => l.Level).FilterByGameVersion(game); + .Select(l => l.Level) + .FilterByGameVersion(game); public IEnumerable GetPlaylistsInPlaylist(GamePlaylist playlist) // TODO: When we have postgres, remove the `AsEnumerable` call for performance. - => this.SubPlaylistRelations.Where(p => p.Playlist == playlist).AsEnumerable().Select(l => l.SubPlaylist); + => this.SubPlaylistRelations.Where(p => p.Playlist == playlist).AsEnumerable() + .Select(l => l.SubPlaylist); public IEnumerable GetPlaylistsByAuthorContainingLevel(GameUser author, GameLevel level) // TODO: When we have postgres, remove the `AsEnumerable` call for performance. => this.LevelPlaylistRelations.Where(p => p.Level == level).AsEnumerable() - .Select(r => this.GamePlaylists.First(p => p == r.Playlist)) + .Select(r => this.GamePlaylists.First(p => p.PlaylistId == r.Playlist.PlaylistId)) .Where(p => p.Publisher.UserId == author.UserId); public IEnumerable GetPlaylistsContainingLevel(GameLevel level) // TODO: When we have postgres, remove the `AsEnumerable` call for performance. => this.LevelPlaylistRelations.Where(p => p.Level == level).AsEnumerable() - .Select(r => this.GamePlaylists.First(p => p == r.Playlist)); + .Select(r => this.GamePlaylists.First(p => p.PlaylistId == r.Playlist.PlaylistId)); } \ No newline at end of file