Skip to content

Commit

Permalink
LBP1 Playlists (#649)
Browse files Browse the repository at this point in the history
You can only create playlists in-game when latest retail LBP1 has the
memory address `0x0074c2c0` patched to be `0x01` instead of `0x00`. In
the EBOOT this is located at address `0x0073c2c0`. Updates to Refresher
and the OFW patchers will need to be made to enable this feature. For
testing you also need to make sure that the `/lbp/network_settings.nws`
endpoint is being accessed correctly, since playlists require the
`AlexDB` option to be set to function.

Luckily the game is perfectly happy viewing and playing other's
playlists even if you don't have the feature enabled yourself. So
there's no worry about un-patched users having issues.

I would like to tackle APIv3 support for playlists in a future PR, but
aside from that, this is a complete implementation of the feature for
LBP1.

It should be possible to represent this (at least close enough) in LBP3
by just not showing sub-playlists, and showing all playlists by a user,
instead of just playlists inside of your root playlist. For
LBP2/PSP/Vita we can just display playlists as a slot results override
you trigger from the API, or as a special cased `playlist:name` in text
search, although we won't be able to display sub-playlists there either.

As a basic technical summary of how this is layed out, all players
contain a "Root Playlist". In LBP1, when you view your "My Playlists"
view, it will create a root playlist for you, if you don't have one. All
playlists you create in the future are then considered "sub-playlists"
of that root playlist, or of some other playlist.

The game allows you to add any playlist (eg. other's playlists aswell)
as a sub-playlist to any of your playlists (except your root playlist).
And it also allows you to add any `user` slot to any of your playlists
(except your root playlist), as a sub-level of the playlist.

The original developer's intention for viewing a user's playlists is
that there is a button on the profile labeled "Playlists", and it would
show the contents of the root playlist, however I found this to be
rather uncreative, so in LBP1 I've injected the contents of your root
playlist into your user level list as "sticky" slot results, so it
always appears regardless of the page. This seems to work fairly well,
and doesn't impact pagination from what I can see.
  • Loading branch information
jvyden authored Sep 5, 2024
2 parents d039a7b + 0659b7c commit 9b2cd0f
Show file tree
Hide file tree
Showing 27 changed files with 956 additions and 137 deletions.
30 changes: 16 additions & 14 deletions Refresh.GameServer/Database/GameDatabaseContext.Levels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ public GameLevel GetStoryLevelById(int id)
{
Title = $"Story level #{id}",
Publisher = null,
Source = GameLevelSource.Story,
StoryId = id,
};

Expand Down Expand Up @@ -180,8 +179,11 @@ public void DeleteLevel(GameLevel level)
});
}


private IQueryable<GameLevel> GetLevelsByGameVersion(TokenGame gameVersion)
=> this.GameLevels.Where(l => l._Source == (int)GameLevelSource.User).FilterByGameVersion(gameVersion);
=> this.GameLevels
.Where(l => l.StoryId == 0) // Filter out any user levels
.FilterByGameVersion(gameVersion);

[Pure]
public DatabaseList<GameLevel> GetLevelsByUser(GameUser user, int count, int skip, LevelFilterSettings levelFilterSettings, GameUser? accessor)
Expand All @@ -193,7 +195,7 @@ public DatabaseList<GameLevel> GetLevelsByUser(GameUser user, int count, int ski

if (user.Username == SystemUsers.UnknownUserName)
{
return new DatabaseList<GameLevel>(this.GetLevelsByGameVersion(levelFilterSettings.GameVersion).FilterByLevelFilterSettings(null, levelFilterSettings).Where(l => l.IsReUpload && String.IsNullOrEmpty(l.OriginalPublisher)), skip, count);
return new DatabaseList<GameLevel>(this.GetLevelsByGameVersion(levelFilterSettings.GameVersion).FilterByLevelFilterSettings(null, levelFilterSettings).Where(l => l.IsReUpload && string.IsNullOrEmpty(l.OriginalPublisher)), skip, count);
}

if (user.Username.StartsWith(SystemUsers.SystemPrefix))
Expand All @@ -209,11 +211,11 @@ public DatabaseList<GameLevel> GetLevelsByUser(GameUser user, int count, int ski

[Pure]
public DatabaseList<GameLevel> GetUserLevelsChunk(int skip, int count)
=> new(this.GameLevels.Where(l => l._Source == (int)GameLevelSource.User), skip, count);
=> new(this.GameLevels.Where(l => l.StoryId == 0), skip, count);

[Pure]
public IQueryable<GameLevel> GetAllUserLevels()
=> this.GameLevels.Where(l => l._Source == (int)GameLevelSource.User);
=> this.GameLevels.Where(l => l.StoryId == 0);

[Pure]
public DatabaseList<GameLevel> GetNewestLevels(int count, int skip, GameUser? user, LevelFilterSettings levelFilterSettings) =>
Expand Down Expand Up @@ -245,7 +247,7 @@ public DatabaseList<GameLevel> 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.StoryId == 0)
.FilterByLevelFilterSettings(user, levelFilterSettings)
.FilterByGameVersion(levelFilterSettings.GameVersion);

Expand All @@ -262,7 +264,7 @@ public DatabaseList<GameLevel> GetLevelsByTag(int count, int skip, GameUser? use
.AsEnumerable()
.Select(x => x.Level)
.Distinct()
.Where(l => l._Source == (int)GameLevelSource.User)
.Where(l => l.StoryId == 0)
.OrderByDescending(l => l.PublishDate)
.FilterByLevelFilterSettings(user, levelFilterSettings)
.FilterByGameVersion(levelFilterSettings.GameVersion);
Expand All @@ -282,7 +284,7 @@ public DatabaseList<GameLevel> 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.StoryId == 0)
.FilterByLevelFilterSettings(user, levelFilterSettings)
.FilterByGameVersion(levelFilterSettings.GameVersion);

Expand Down Expand Up @@ -319,7 +321,7 @@ public DatabaseList<GameLevel> 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.StoryId == 0)
.FilterByLevelFilterSettings(user, levelFilterSettings)
.FilterByGameVersion(levelFilterSettings.GameVersion);

Expand All @@ -336,7 +338,7 @@ public DatabaseList<GameLevel> GetTeamPickedLevels(int count, int skip, GameUser
[Pure]
public DatabaseList<GameLevel> GetDeveloperLevels(int count, int skip, LevelFilterSettings levelFilterSettings) =>
new(this.GameLevels
.Where(l => l._Source == (int)GameLevelSource.Story)
.Where(l => l.StoryId != 0) // filter to only levels with a story ID set
.FilterByLevelFilterSettings(null, levelFilterSettings)
.OrderByDescending(l => l.Title), skip, count);

Expand All @@ -349,7 +351,7 @@ public DatabaseList<GameLevel> GetBusiestLevels(int count, int skip, MatchServic
.OrderBy(r => r.Sum(room => room.PlayerIds.Count));

return new DatabaseList<GameLevel>(rooms.Select(r => r.Key)
.Where(l => l != null && l._Source == (int)GameLevelSource.User)!
.Where(l => l != null && l.StoryId == 0)!
.FilterByLevelFilterSettings(user, levelFilterSettings)
.FilterByGameVersion(levelFilterSettings.GameVersion), skip, count);
}
Expand Down Expand Up @@ -402,13 +404,13 @@ public DatabaseList<GameLevel> 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.StoryId == 0);

[Pure]
public int GetTotalLevelCount() => this.GameLevels.Count(l => l._Source == (int)GameLevelSource.User);
public int GetTotalLevelCount() => this.GameLevels.Count(l => l.StoryId == 0);

[Pure]
public int GetModdedLevelCount() => this.GameLevels.Count(l => l._Source == (int)GameLevelSource.User && l.IsModded);
public int GetModdedLevelCount() => this.GameLevels.Count(l => l.StoryId == 0 && l.IsModded);

public int GetTotalLevelsPublishedByUser(GameUser user)
=> this.GameLevels
Expand Down
156 changes: 156 additions & 0 deletions Refresh.GameServer/Database/GameDatabaseContext.Playlists.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
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()
{
Publisher = user,
Name = createInfo.Name,
Description = createInfo.Description,
IconHash = createInfo.Icon,
LocationX = createInfo.Location.X,
LocationY = createInfo.Location.Y,
IsRoot = 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.IconHash = 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.Playlist == playlist);
this.SubPlaylistRelations.RemoveRange(l => l.Playlist == playlist || 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.Playlist == parent))
return;

// Add the relation
this.SubPlaylistRelations.Add(new SubPlaylistRelation
{
Playlist = parent,
SubPlaylist = child,
});
});
}

public void RemovePlaylistFromPlaylist(GamePlaylist child, GamePlaylist parent)
{
this.Write(() =>
{
SubPlaylistRelation? relation =
this.SubPlaylistRelations.FirstOrDefault(r => r.SubPlaylist == child && r.Playlist == parent);

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.Playlist == parent))
return;

// Add the relation
this.LevelPlaylistRelations.Add(new LevelPlaylistRelation
{
Level = level,
Playlist = parent,
});
});
}

public void RemoveLevelFromPlaylist(GameLevel level, GamePlaylist parent)
{
this.Write(() =>
{
LevelPlaylistRelation? relation =
this.LevelPlaylistRelations.FirstOrDefault(r => r.Level == level && r.Playlist == parent);

if (relation == null)
return;

this.LevelPlaylistRelations.Remove(relation);
});
}

public IEnumerable<GamePlaylist> 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.Playlist.PlaylistId))
.Where(p => !p.IsRoot);

public IEnumerable<GamePlaylist> 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.Playlist.PlaylistId))
.Where(p => p.Publisher.UserId == user.UserId)
.Where(p => !p.IsRoot);

public IEnumerable<GameLevel> 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);

public IEnumerable<GamePlaylist> 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);

public IEnumerable<GamePlaylist> 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.Playlist.PlaylistId))
.Where(p => p.Publisher.UserId == author.UserId);

public IEnumerable<GamePlaylist> 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.Playlist.PlaylistId));
}
11 changes: 9 additions & 2 deletions Refresh.GameServer/Database/GameDatabaseContext.Users.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -448,4 +447,12 @@ public void MarkAllReuploads(GameUser user)
}
});
}

public void SetUserRootPlaylist(GameUser user, GamePlaylist playlist)
{
this.Write(() =>
{
user.RootPlaylist = playlist;
});
}
}
4 changes: 4 additions & 0 deletions Refresh.GameServer/Database/GameDatabaseContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -57,6 +58,9 @@ public partial class GameDatabaseContext : RealmDatabaseContext
private RealmDbSet<DisallowedUser> DisallowedUsers => new(this._realm);
private RealmDbSet<RateReviewRelation> RateReviewRelations => new(this._realm);
private RealmDbSet<TagLevelRelation> TagLevelRelations => new(this._realm);
private RealmDbSet<GamePlaylist> GamePlaylists => new(this._realm);
private RealmDbSet<LevelPlaylistRelation> LevelPlaylistRelations => new(this._realm);
private RealmDbSet<SubPlaylistRelation> SubPlaylistRelations => new(this._realm);

internal GameDatabaseContext(IDateTimeProvider time)
{
Expand Down
Loading

0 comments on commit 9b2cd0f

Please sign in to comment.