Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 49 additions & 18 deletions Refresh.Database/GameDatabaseContext.Leaderboard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,30 @@ namespace Refresh.Database;
public partial class GameDatabaseContext // Leaderboard
{
private IQueryable<GameScore> GameScoresIncluded => this.GameScores
.Include(s => s.Publisher)
.Include(s => s.Level)
.Include(s => s.Level.Publisher);

public GameScore SubmitScore(ISerializedScore score, Token token, GameLevel level)
=> this.SubmitScore(score, token.User, level, token.TokenGame, token.TokenPlatform);
public GameScore SubmitScore(ISerializedScore score, Token token, GameLevel level, IList<GameUser> players)
=> this.SubmitScore(score, token.User, level, token.TokenGame, token.TokenPlatform, players);

public GameScore SubmitScore(ISerializedScore score, GameUser user, GameLevel level, TokenGame game, TokenPlatform platform)
public GameScore SubmitScore(ISerializedScore score, GameUser user, GameLevel level, TokenGame game, TokenPlatform platform, IList<GameUser> players)
{
// Throw incase the method directly gets called like this in a test
if (players.Count <= 0)
{
throw new ArgumentException("Player list is empty!", nameof(players));
}

IEnumerable<ObjectId> playerIds = players.Select(u => u.UserId);

GameScore newScore = new()
{
Score = score.Score,
ScoreType = score.ScoreType,
Level = level,
PlayerIdsRaw = [ user.UserId.ToString() ],
PlayerIdsRaw = playerIds.Select(p => p.ToString()).ToList(),
Publisher = user,
ScoreSubmitted = this._time.Now,
Game = game,
Platform = platform,
Expand All @@ -34,16 +44,14 @@ public GameScore SubmitScore(ISerializedScore score, GameUser user, GameLevel le
GameScore? currentFirstPlace = this.GameScores
.Where(s => s.LevelId == level.LevelId && s.ScoreType == score.ScoreType)
.OrderByDescending(s => s.Score)
.ToArray()
.DistinctBy(s => s.PlayerIdsRaw[0])
.FirstOrDefault();

// If the current first score is not 0, is lower than the new score and by a different first player,
// show the overtake notification. This way the #1 player will not spam the #2 player by repeatedly improving their own score.
bool showOvertakeNotification = currentFirstPlace != null
&& currentFirstPlace.Score > 0
&& currentFirstPlace.Score < score.Score
&& currentFirstPlace.PlayerIds[0] != user.UserId;
&& currentFirstPlace.PublisherId != user.UserId;

this.Write(() =>
{
Expand All @@ -52,29 +60,48 @@ public GameScore SubmitScore(ISerializedScore score, GameUser user, GameLevel le

this.CreateLevelScoreEvent(user, newScore);

// Notify the last #1 users that they've been overtaken
// Only do this part of notifying after actually adding the new score to the database incase that fails
// NOTE: If you want to change the notif text, make sure to adjust the respective Assert in
// ScoreLeaderboardTests.OnlySendOvertakeNotifsToRelevantPlayers() aswell!
if (showOvertakeNotification)
{
// Notify the last #1 users that they've been overtaken
foreach (GameUser player in this.GetPlayersFromScore(currentFirstPlace!).ToArray())
// Below lines format the shown usernames to look like this: "UserA, UserB, UserC and UserD"
IEnumerable<string> usernames = players.Select(u => u.Username);

// players.Count is guaranteed to be equal to usernames.Count(), both are guaranteed to be > 0
string usernamesToShow = players.Count > 1
? $"{string.Join(", ", usernames.SkipLast(1))} and {usernames.Last()}"
: usernames.First();

// Don't notify users who have participated in both the overtaken and the new score
IEnumerable<GameUser> usersToNotify = this.GetPlayersFromScore(currentFirstPlace!)
.Where(p => !playerIds.Contains(p.UserId))
.ToArray();

foreach (GameUser player in usersToNotify)
{
this.AddNotification("Score overtaken",
$"Your #1 score on {level.Title} has been overtaken by {user.Username}!",
$"Your #1 score on {level.Title} has been overtaken by {usernamesToShow} in {score.ScoreType}-player-mode!",
player, "medal");
}
}

return newScore;
}

public DatabaseScoreList GetTopScoresForLevel(GameLevel level, int count, int skip, byte type, bool showDuplicates = false, DateTimeOffset? minAge = null, GameUser? user = null)
/// <param name="scoreType">0 = don't filter by type</param>
Comment thread
jvyden marked this conversation as resolved.
public DatabaseScoreList GetTopScoresForLevel(GameLevel level, int count, int skip, byte scoreType, bool showDuplicates = false, DateTimeOffset? minAge = null, GameUser? user = null)
{
IEnumerable<GameScore> scores = this.GameScoresIncluded
.Where(s => s.ScoreType == type && s.LevelId == level.LevelId)
.Where(s => s.LevelId == level.LevelId)
.OrderByDescending(s => s.Score);

if (scoreType != 0)
scores = scores.Where(s => s.ScoreType == scoreType);

if (!showDuplicates)
scores = scores.DistinctBy(s => s.PlayerIds[0]);
scores = scores.DistinctBy(s => s.PublisherId);

if (minAge != null)
scores = scores.Where(s => s.ScoreSubmitted >= minAge);
Expand All @@ -92,7 +119,7 @@ public DatabaseScoreList GetRankedScoresAroundScore(GameScore score, int count,
.Where(s => s.ScoreType == score.ScoreType && s.LevelId == score.LevelId)
.OrderByDescending(s => s.Score)
.ToArray()
.DistinctBy(s => s.PlayerIds[0])
.DistinctBy(s => s.PublisherId)
.ToList();

return new
Expand All @@ -103,24 +130,28 @@ public DatabaseScoreList GetRankedScoresAroundScore(GameScore score, int count,
);
}

public DatabaseScoreList GetLevelTopScoresByFriends(GameUser user, GameLevel level, int count, byte scoreType, DateTimeOffset? minAge = null)
/// <param name="scoreType">0 = don't filter by type</param>
public DatabaseScoreList GetLevelTopScoresByFriends(GameUser user, GameLevel level, int skip, int count, byte scoreType, DateTimeOffset? minAge = null)
{
IEnumerable<ObjectId> mutuals = this.GetUsersMutuals(user)
.Select(u => u.UserId)
.Append(user.UserId);

IEnumerable<GameScore> scores = this.GameScoresIncluded
.Where(s => s.ScoreType == scoreType && s.LevelId == level.LevelId)
.Where(s => s.LevelId == level.LevelId)
.OrderByDescending(s => s.Score)
.ToArray()
.DistinctBy(s => s.PlayerIds[0])
.DistinctBy(s => s.PublisherId)
//TODO: THIS CALL IS EXTREMELY INEFFECIENT!!! once we are in postgres land, figure out a way to do this effeciently
.Where(s => s.PlayerIds.Any(p => mutuals.Contains(p)));

if (scoreType != 0)
scores = scores.Where(s => s.ScoreType == scoreType);

if (minAge != null)
scores = scores.Where(s => s.ScoreSubmitted >= minAge);

return new(scores.Select((s, i) => new ScoreWithRank(s, i + 1)), 0, count, user);
return new(scores.Select((s, i) => new ScoreWithRank(s, i + 1)), skip, count, user);
}

[Pure]
Expand Down
2 changes: 1 addition & 1 deletion Refresh.Database/GameDatabaseContext.Users.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public partial class GameDatabaseContext // Users
[ContractAnnotation("username:null => null; username:notnull => canbenull")]
public GameUser? GetUserByUsername(string? username, bool caseSensitive = true)
{
if (username == null)
if (string.IsNullOrWhiteSpace(username))
return null;

// Try the first pass to get the user
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace Refresh.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(GameDatabaseContext))]
[Migration("20251007182051_ProperlyAddScorePlayerList")]
public partial class ProperlyAddScorePlayerList : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PublisherId",
table: "GameScores",
type: "text",
nullable: false,
defaultValue: "");

// SQL to copy publisher ID from player list to publisher ID attribute
migrationBuilder.Sql("UPDATE \"GameScores\" SET \"PublisherId\" = \"PlayerIdsRaw\"[1] WHERE \"PlayerIdsRaw\"[1] IS NOT NULL");

migrationBuilder.CreateIndex(
name: "IX_GameScores_PublisherId",
table: "GameScores",
column: "PublisherId");

migrationBuilder.AddForeignKey(
name: "FK_GameScores_GameUsers_PublisherId",
table: "GameScores",
column: "PublisherId",
principalTable: "GameUsers",
principalColumn: "UserId",
onDelete: ReferentialAction.Cascade);
}

/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_GameScores_GameUsers_PublisherId",
table: "GameScores");

migrationBuilder.DropIndex(
name: "IX_GameScores_PublisherId",
table: "GameScores");

migrationBuilder.DropColumn(
name: "PublisherId",
table: "GameScores");
}
}
}
27 changes: 27 additions & 0 deletions Refresh.Database/Migrations/20251008175307_AnnihilateScoreType7.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace Refresh.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(GameDatabaseContext))]
[Migration("20251008175307_AnnihilateScoreType7")]
public partial class AnnihilateScoreType7 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Just set all type 7 scores' type to 1 since we haven't tracked more than 1 user
// in the player list before anyway
migrationBuilder.Sql("UPDATE \"GameScores\" SET \"ScoreType\" = 1 WHERE \"ScoreType\" = 7");
}

/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Do nothing
}
}
}
16 changes: 15 additions & 1 deletion Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.8")
.HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);

NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
Expand Down Expand Up @@ -571,6 +571,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.PrimitiveCollection<List<string>>("PlayerIdsRaw")
.HasColumnType("text[]");

b.Property<string>("PublisherId")
.IsRequired()
.HasColumnType("text");

b.Property<int>("Score")
.HasColumnType("integer");

Expand All @@ -584,6 +588,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)

b.HasIndex("LevelId");

b.HasIndex("PublisherId");

b.HasIndex("Game", "Score", "ScoreType");

b.ToTable("GameScores");
Expand Down Expand Up @@ -1877,7 +1883,15 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();

b.HasOne("Refresh.Database.Models.Users.GameUser", "Publisher")
.WithMany()
.HasForeignKey("PublisherId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();

b.Navigation("Level");

b.Navigation("Publisher");
});

modelBuilder.Entity("Refresh.Database.Models.Notifications.GameNotification", b =>
Expand Down
6 changes: 6 additions & 0 deletions Refresh.Database/Models/Levels/Scores/GameScore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,10 @@ public partial class GameScore
public List<string> PlayerIdsRaw { get; set; } = [];
[NotMapped] public List<ObjectId> PlayerIds => PlayerIdsRaw.Select(ObjectId.Parse).ToList();
// set => PlayerIdsRaw = value.Select(v => v.ToString()).ToList();

/// <summary>
/// The actual publisher of this particular score.
/// </summary>
[ForeignKey(nameof(PublisherId)), Required] public GameUser Publisher { get; set; }
[Required] public ObjectId PublisherId { get; set; }
}
3 changes: 1 addition & 2 deletions Refresh.Database/Models/Levels/Scores/MultiLeaderboard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,11 @@ public MultiLeaderboard(GameDatabaseContext database, GameLevel level, TokenGame
[1] = database.GetTopScoresForLevel(level, 10, 0, 1),
};

//On PSP, theres no multiplayer, so lets skip all the multiplayer/vs scoreboards
//On PSP, theres no multiplayer, so lets skip all the multiplayer scoreboards
if (game == TokenGame.LittleBigPlanetPSP) return;

this.Leaderboards[2] = database.GetTopScoresForLevel(level, 10, 0, 2);
this.Leaderboards[3] = database.GetTopScoresForLevel(level, 10, 0, 3);
this.Leaderboards[4] = database.GetTopScoresForLevel(level, 10, 0, 4);
this.Leaderboards[7] = database.GetTopScoresForLevel(level, 10, 0, 7);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels;
public class ApiGameScoreResponse : IApiResponse, IDataConvertableFrom<ApiGameScoreResponse, GameScore>, IDataConvertableFrom<ApiGameScoreResponse, ScoreWithRank>
{
public required string ScoreId { get; set; }
public required ApiGameLevelResponse Level { get; set; }
public required IEnumerable<ApiGameUserResponse> Players { get; set; }
public required ApiGameLevelResponse Level { get; set; } // TODO: use ApiMinimalLevelResponse in APIv4
public required IEnumerable<ApiGameUserResponse> Players { get; set; } // TODO: use ApiMinimalUserResponses in APIv4
public required ApiMinimalUserResponse Publisher { get; set; }
public required DateTimeOffset ScoreSubmitted { get; set; }
public required int Score { get; set; }
public required byte ScoreType { get; set; }
Expand All @@ -27,6 +28,7 @@ public class ApiGameScoreResponse : IApiResponse, IDataConvertableFrom<ApiGameSc
ScoreId = old.ScoreId.ToString()!,
Level = ApiGameLevelResponse.FromOld(old.Level, dataContext)!,
Players = ApiGameUserResponse.FromOldList(dataContext.Database.GetPlayersFromScore(old).ToArray(), dataContext),
Publisher = ApiMinimalUserResponse.FromOld(old.Publisher, dataContext)!,
ScoreSubmitted = old.ScoreSubmitted,
Score = old.Score,
ScoreType = old.ScoreType,
Expand Down
7 changes: 5 additions & 2 deletions Refresh.Interfaces.APIv3/Endpoints/LeaderboardApiEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public class LeaderboardApiEndpoints : EndpointGroup
public ApiListResponse<ApiGameScoreResponse> GetTopScoresForLevel(RequestContext context,
GameDatabaseContext database, IDataStore dataStore,
[DocSummary("The ID of the level")] int id,
[DocSummary("The leaderboard more (aka the number of players, e.g. 2 for 2-player mode)")]
[DocSummary("The leaderboard mode (aka the number of players, e.g. 2 for 2-player mode)")]
int mode, DataContext dataContext)
{
GameLevel? level = database.GetLevelById(id);
Expand All @@ -36,7 +36,10 @@ public ApiListResponse<ApiGameScoreResponse> GetTopScoresForLevel(RequestContext
bool result = bool.TryParse(context.QueryString.Get("showAll") ?? "false", out bool showAll);
if (!result) return ApiValidationError.BooleanParseError;

DatabaseList<ScoreWithRank> scores = database.GetTopScoresForLevel(level, count, skip, (byte)mode, showAll);
// Don't have type 7 break on APIv3 clients which happen to already use it
byte scoreType = (byte)(mode == 7 ? 0 : mode);

DatabaseList<ScoreWithRank> scores = database.GetTopScoresForLevel(level, count, skip, scoreType, showAll);
DatabaseList<ApiGameScoreResponse> ret = DatabaseListExtensions.FromOldList<ApiGameScoreResponse, ScoreWithRank>(scores, dataContext);
return ret;
}
Expand Down
Loading