Skip to content

Commit 8db9f7a

Browse files
authored
Track players who have participated in a score, remove score type 7 (#963)
This PR adds the ability to sanitize and actually track the player data included in the `playerIds` list in the game's score submissions, while also tracking the user who actually published the score (since in online multiplayer, every player submits the score, not just the host). Also the server now uses that data to fix the "7-player mode" recent activity/score API bug by falling back to counting the usernames in `playerIds` and setting the type to that (guaranteed to be inbetween 1 and 4). Requesting a level leaderboard of type 7 just returns all scores for both the game and APIv3 regardless of their type now. Also adjusts and adds new score tests, improves overtake notifications to include the usernames of all participants of the new score aswell as the score type, and sends these notifications to all overtaken players who have not participated in the new score aswell. Also quickly made the LBP2+ friends leaderboard endpoint properly use the skip param since I was already at it. Closes #201
2 parents e46f0dd + c55e5f2 commit 8db9f7a

File tree

18 files changed

+531
-48
lines changed

18 files changed

+531
-48
lines changed

Refresh.Database/GameDatabaseContext.Leaderboard.cs

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,30 @@ namespace Refresh.Database;
1212
public partial class GameDatabaseContext // Leaderboard
1313
{
1414
private IQueryable<GameScore> GameScoresIncluded => this.GameScores
15+
.Include(s => s.Publisher)
1516
.Include(s => s.Level)
1617
.Include(s => s.Level.Publisher);
1718

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

21-
public GameScore SubmitScore(ISerializedScore score, GameUser user, GameLevel level, TokenGame game, TokenPlatform platform)
22+
public GameScore SubmitScore(ISerializedScore score, GameUser user, GameLevel level, TokenGame game, TokenPlatform platform, IList<GameUser> players)
2223
{
24+
// Throw incase the method directly gets called like this in a test
25+
if (players.Count <= 0)
26+
{
27+
throw new ArgumentException("Player list is empty!", nameof(players));
28+
}
29+
30+
IEnumerable<ObjectId> playerIds = players.Select(u => u.UserId);
31+
2332
GameScore newScore = new()
2433
{
2534
Score = score.Score,
2635
ScoreType = score.ScoreType,
2736
Level = level,
28-
PlayerIdsRaw = [ user.UserId.ToString() ],
37+
PlayerIdsRaw = playerIds.Select(p => p.ToString()).ToList(),
38+
Publisher = user,
2939
ScoreSubmitted = this._time.Now,
3040
Game = game,
3141
Platform = platform,
@@ -34,16 +44,14 @@ public GameScore SubmitScore(ISerializedScore score, GameUser user, GameLevel le
3444
GameScore? currentFirstPlace = this.GameScores
3545
.Where(s => s.LevelId == level.LevelId && s.ScoreType == score.ScoreType)
3646
.OrderByDescending(s => s.Score)
37-
.ToArray()
38-
.DistinctBy(s => s.PlayerIdsRaw[0])
3947
.FirstOrDefault();
4048

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

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

5361
this.CreateLevelScoreEvent(user, newScore);
5462

63+
// Notify the last #1 users that they've been overtaken
5564
// Only do this part of notifying after actually adding the new score to the database incase that fails
65+
// NOTE: If you want to change the notif text, make sure to adjust the respective Assert in
66+
// ScoreLeaderboardTests.OnlySendOvertakeNotifsToRelevantPlayers() aswell!
5667
if (showOvertakeNotification)
5768
{
58-
// Notify the last #1 users that they've been overtaken
59-
foreach (GameUser player in this.GetPlayersFromScore(currentFirstPlace!).ToArray())
69+
// Below lines format the shown usernames to look like this: "UserA, UserB, UserC and UserD"
70+
IEnumerable<string> usernames = players.Select(u => u.Username);
71+
72+
// players.Count is guaranteed to be equal to usernames.Count(), both are guaranteed to be > 0
73+
string usernamesToShow = players.Count > 1
74+
? $"{string.Join(", ", usernames.SkipLast(1))} and {usernames.Last()}"
75+
: usernames.First();
76+
77+
// Don't notify users who have participated in both the overtaken and the new score
78+
IEnumerable<GameUser> usersToNotify = this.GetPlayersFromScore(currentFirstPlace!)
79+
.Where(p => !playerIds.Contains(p.UserId))
80+
.ToArray();
81+
82+
foreach (GameUser player in usersToNotify)
6083
{
6184
this.AddNotification("Score overtaken",
62-
$"Your #1 score on {level.Title} has been overtaken by {user.Username}!",
85+
$"Your #1 score on {level.Title} has been overtaken by {usernamesToShow} in {score.ScoreType}-player-mode!",
6386
player, "medal");
6487
}
6588
}
6689

6790
return newScore;
6891
}
6992

70-
public DatabaseScoreList GetTopScoresForLevel(GameLevel level, int count, int skip, byte type, bool showDuplicates = false, DateTimeOffset? minAge = null, GameUser? user = null)
93+
/// <param name="scoreType">0 = don't filter by type</param>
94+
public DatabaseScoreList GetTopScoresForLevel(GameLevel level, int count, int skip, byte scoreType, bool showDuplicates = false, DateTimeOffset? minAge = null, GameUser? user = null)
7195
{
7296
IEnumerable<GameScore> scores = this.GameScoresIncluded
73-
.Where(s => s.ScoreType == type && s.LevelId == level.LevelId)
97+
.Where(s => s.LevelId == level.LevelId)
7498
.OrderByDescending(s => s.Score);
99+
100+
if (scoreType != 0)
101+
scores = scores.Where(s => s.ScoreType == scoreType);
75102

76103
if (!showDuplicates)
77-
scores = scores.DistinctBy(s => s.PlayerIds[0]);
104+
scores = scores.DistinctBy(s => s.PublisherId);
78105

79106
if (minAge != null)
80107
scores = scores.Where(s => s.ScoreSubmitted >= minAge);
@@ -92,7 +119,7 @@ public DatabaseScoreList GetRankedScoresAroundScore(GameScore score, int count,
92119
.Where(s => s.ScoreType == score.ScoreType && s.LevelId == score.LevelId)
93120
.OrderByDescending(s => s.Score)
94121
.ToArray()
95-
.DistinctBy(s => s.PlayerIds[0])
122+
.DistinctBy(s => s.PublisherId)
96123
.ToList();
97124

98125
return new
@@ -103,24 +130,28 @@ public DatabaseScoreList GetRankedScoresAroundScore(GameScore score, int count,
103130
);
104131
}
105132

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

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

148+
if (scoreType != 0)
149+
scores = scores.Where(s => s.ScoreType == scoreType);
150+
120151
if (minAge != null)
121152
scores = scores.Where(s => s.ScoreSubmitted >= minAge);
122153

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

126157
[Pure]

Refresh.Database/GameDatabaseContext.Users.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public partial class GameDatabaseContext // Users
2020
[ContractAnnotation("username:null => null; username:notnull => canbenull")]
2121
public GameUser? GetUserByUsername(string? username, bool caseSensitive = true)
2222
{
23-
if (username == null)
23+
if (string.IsNullOrWhiteSpace(username))
2424
return null;
2525

2626
// Try the first pass to get the user
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using Microsoft.EntityFrameworkCore.Infrastructure;
2+
using Microsoft.EntityFrameworkCore.Migrations;
3+
4+
#nullable disable
5+
6+
namespace Refresh.Database.Migrations
7+
{
8+
/// <inheritdoc />
9+
[DbContext(typeof(GameDatabaseContext))]
10+
[Migration("20251007182051_ProperlyAddScorePlayerList")]
11+
public partial class ProperlyAddScorePlayerList : Migration
12+
{
13+
/// <inheritdoc />
14+
protected override void Up(MigrationBuilder migrationBuilder)
15+
{
16+
migrationBuilder.AddColumn<string>(
17+
name: "PublisherId",
18+
table: "GameScores",
19+
type: "text",
20+
nullable: false,
21+
defaultValue: "");
22+
23+
// SQL to copy publisher ID from player list to publisher ID attribute
24+
migrationBuilder.Sql("UPDATE \"GameScores\" SET \"PublisherId\" = \"PlayerIdsRaw\"[1] WHERE \"PlayerIdsRaw\"[1] IS NOT NULL");
25+
26+
migrationBuilder.CreateIndex(
27+
name: "IX_GameScores_PublisherId",
28+
table: "GameScores",
29+
column: "PublisherId");
30+
31+
migrationBuilder.AddForeignKey(
32+
name: "FK_GameScores_GameUsers_PublisherId",
33+
table: "GameScores",
34+
column: "PublisherId",
35+
principalTable: "GameUsers",
36+
principalColumn: "UserId",
37+
onDelete: ReferentialAction.Cascade);
38+
}
39+
40+
/// <inheritdoc />
41+
protected override void Down(MigrationBuilder migrationBuilder)
42+
{
43+
migrationBuilder.DropForeignKey(
44+
name: "FK_GameScores_GameUsers_PublisherId",
45+
table: "GameScores");
46+
47+
migrationBuilder.DropIndex(
48+
name: "IX_GameScores_PublisherId",
49+
table: "GameScores");
50+
51+
migrationBuilder.DropColumn(
52+
name: "PublisherId",
53+
table: "GameScores");
54+
}
55+
}
56+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using Microsoft.EntityFrameworkCore.Infrastructure;
2+
using Microsoft.EntityFrameworkCore.Migrations;
3+
4+
#nullable disable
5+
6+
namespace Refresh.Database.Migrations
7+
{
8+
/// <inheritdoc />
9+
[DbContext(typeof(GameDatabaseContext))]
10+
[Migration("20251008175307_AnnihilateScoreType7")]
11+
public partial class AnnihilateScoreType7 : Migration
12+
{
13+
/// <inheritdoc />
14+
protected override void Up(MigrationBuilder migrationBuilder)
15+
{
16+
// Just set all type 7 scores' type to 1 since we haven't tracked more than 1 user
17+
// in the player list before anyway
18+
migrationBuilder.Sql("UPDATE \"GameScores\" SET \"ScoreType\" = 1 WHERE \"ScoreType\" = 7");
19+
}
20+
21+
/// <inheritdoc />
22+
protected override void Down(MigrationBuilder migrationBuilder)
23+
{
24+
// Do nothing
25+
}
26+
}
27+
}

Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
1818
{
1919
#pragma warning disable 612, 618
2020
modelBuilder
21-
.HasAnnotation("ProductVersion", "9.0.8")
21+
.HasAnnotation("ProductVersion", "9.0.9")
2222
.HasAnnotation("Relational:MaxIdentifierLength", 63);
2323

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

574+
b.Property<string>("PublisherId")
575+
.IsRequired()
576+
.HasColumnType("text");
577+
574578
b.Property<int>("Score")
575579
.HasColumnType("integer");
576580

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

585589
b.HasIndex("LevelId");
586590

591+
b.HasIndex("PublisherId");
592+
587593
b.HasIndex("Game", "Score", "ScoreType");
588594

589595
b.ToTable("GameScores");
@@ -1877,7 +1883,15 @@ protected override void BuildModel(ModelBuilder modelBuilder)
18771883
.OnDelete(DeleteBehavior.Cascade)
18781884
.IsRequired();
18791885

1886+
b.HasOne("Refresh.Database.Models.Users.GameUser", "Publisher")
1887+
.WithMany()
1888+
.HasForeignKey("PublisherId")
1889+
.OnDelete(DeleteBehavior.Cascade)
1890+
.IsRequired();
1891+
18801892
b.Navigation("Level");
1893+
1894+
b.Navigation("Publisher");
18811895
});
18821896

18831897
modelBuilder.Entity("Refresh.Database.Models.Notifications.GameNotification", b =>

Refresh.Database/Models/Levels/Scores/GameScore.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,10 @@ public partial class GameScore
2424
public List<string> PlayerIdsRaw { get; set; } = [];
2525
[NotMapped] public List<ObjectId> PlayerIds => PlayerIdsRaw.Select(ObjectId.Parse).ToList();
2626
// set => PlayerIdsRaw = value.Select(v => v.ToString()).ToList();
27+
28+
/// <summary>
29+
/// The actual publisher of this particular score.
30+
/// </summary>
31+
[ForeignKey(nameof(PublisherId)), Required] public GameUser Publisher { get; set; }
32+
[Required] public ObjectId PublisherId { get; set; }
2733
}

Refresh.Database/Models/Levels/Scores/MultiLeaderboard.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,11 @@ public MultiLeaderboard(GameDatabaseContext database, GameLevel level, TokenGame
1414
[1] = database.GetTopScoresForLevel(level, 10, 0, 1),
1515
};
1616

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

2020
this.Leaderboards[2] = database.GetTopScoresForLevel(level, 10, 0, 2);
2121
this.Leaderboards[3] = database.GetTopScoresForLevel(level, 10, 0, 3);
2222
this.Leaderboards[4] = database.GetTopScoresForLevel(level, 10, 0, 4);
23-
this.Leaderboards[7] = database.GetTopScoresForLevel(level, 10, 0, 7);
2423
}
2524
}

Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiGameScoreResponse.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels;
99
public class ApiGameScoreResponse : IApiResponse, IDataConvertableFrom<ApiGameScoreResponse, GameScore>, IDataConvertableFrom<ApiGameScoreResponse, ScoreWithRank>
1010
{
1111
public required string ScoreId { get; set; }
12-
public required ApiGameLevelResponse Level { get; set; }
13-
public required IEnumerable<ApiGameUserResponse> Players { get; set; }
12+
public required ApiGameLevelResponse Level { get; set; } // TODO: use ApiMinimalLevelResponse in APIv4
13+
public required IEnumerable<ApiGameUserResponse> Players { get; set; } // TODO: use ApiMinimalUserResponses in APIv4
14+
public required ApiMinimalUserResponse Publisher { get; set; }
1415
public required DateTimeOffset ScoreSubmitted { get; set; }
1516
public required int Score { get; set; }
1617
public required byte ScoreType { get; set; }
@@ -27,6 +28,7 @@ public class ApiGameScoreResponse : IApiResponse, IDataConvertableFrom<ApiGameSc
2728
ScoreId = old.ScoreId.ToString()!,
2829
Level = ApiGameLevelResponse.FromOld(old.Level, dataContext)!,
2930
Players = ApiGameUserResponse.FromOldList(dataContext.Database.GetPlayersFromScore(old).ToArray(), dataContext),
31+
Publisher = ApiMinimalUserResponse.FromOld(old.Publisher, dataContext)!,
3032
ScoreSubmitted = old.ScoreSubmitted,
3133
Score = old.Score,
3234
ScoreType = old.ScoreType,

Refresh.Interfaces.APIv3/Endpoints/LeaderboardApiEndpoints.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public class LeaderboardApiEndpoints : EndpointGroup
2525
public ApiListResponse<ApiGameScoreResponse> GetTopScoresForLevel(RequestContext context,
2626
GameDatabaseContext database, IDataStore dataStore,
2727
[DocSummary("The ID of the level")] int id,
28-
[DocSummary("The leaderboard more (aka the number of players, e.g. 2 for 2-player mode)")]
28+
[DocSummary("The leaderboard mode (aka the number of players, e.g. 2 for 2-player mode)")]
2929
int mode, DataContext dataContext)
3030
{
3131
GameLevel? level = database.GetLevelById(id);
@@ -36,7 +36,10 @@ public ApiListResponse<ApiGameScoreResponse> GetTopScoresForLevel(RequestContext
3636
bool result = bool.TryParse(context.QueryString.Get("showAll") ?? "false", out bool showAll);
3737
if (!result) return ApiValidationError.BooleanParseError;
3838

39-
DatabaseList<ScoreWithRank> scores = database.GetTopScoresForLevel(level, count, skip, (byte)mode, showAll);
39+
// Don't have type 7 break on APIv3 clients which happen to already use it
40+
byte scoreType = (byte)(mode == 7 ? 0 : mode);
41+
42+
DatabaseList<ScoreWithRank> scores = database.GetTopScoresForLevel(level, count, skip, scoreType, showAll);
4043
DatabaseList<ApiGameScoreResponse> ret = DatabaseListExtensions.FromOldList<ApiGameScoreResponse, ScoreWithRank>(scores, dataContext);
4144
return ret;
4245
}

0 commit comments

Comments
 (0)