Skip to content

Commit 867a89a

Browse files
authored
Score rank caching (#1041)
Introduces caching the ranks of level high-scores in the database using `GameScore.Rank`. This way the ranks won't have to be recalculated every time a leaderboard page is requested, instead they are only recalculated every time a new score is added or deleted. This is mostly to prepare a future implementation of #45, in order to avoid various issues there. Scores which are no longer the high-score of their publisher have their rank set to 0, which allows filtering such scores to be more optimal now (only need to use `Where()` instead of `DistinctBy()`). Also, `ApiGameScoreResponse` now contains a `Rank` attribute, `GameScore`s with the same exact score will now share a rank, and `RefreshContext` was moved to `Refresh.Common` so logging methods could use its values from anywhere.
2 parents 941058e + 3f53054 commit 867a89a

29 files changed

Lines changed: 330 additions & 49 deletions
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
namespace Refresh.Core;
1+
namespace Refresh.Common;
22

33
public enum RefreshContext
44
{

Refresh.Core/Services/AipiService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Bunkum.Core.Services;
44
using JetBrains.Annotations;
55
using NotEnoughLogs;
6+
using Refresh.Common;
67
using Refresh.Core.Configuration;
78
using Refresh.Core.Importing;
89
using Refresh.Core.Types.Data;

Refresh.Core/Services/DiscordStaffService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using Discord;
33
using Discord.Webhook;
44
using NotEnoughLogs;
5+
using Refresh.Common;
56
using Refresh.Core.Configuration;
67
using Refresh.Database.Models.Users;
78
using GameAsset = Refresh.Database.Models.Assets.GameAsset;

Refresh.Core/Services/PlayNowService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Bunkum.Core.Services;
22
using MongoDB.Bson;
33
using NotEnoughLogs;
4+
using Refresh.Common;
45
using Refresh.Database;
56
using Refresh.Database.Models.Authentication;
67
using Refresh.Database.Models.Levels;

Refresh.Core/Services/PresenceService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Net.Http.Headers;
22
using Bunkum.Core.Services;
33
using NotEnoughLogs;
4+
using Refresh.Common;
45
using Refresh.Core.Configuration;
56
using Refresh.Database.Models.Users;
67

Refresh.Database/GameDatabaseContext.Activity.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,4 +152,7 @@ public DatabaseActivityPage GetRecentActivityFromUser(ActivityQueryParameters pa
152152
}
153153

154154
public int GetTotalEventCount() => this.Events.Count();
155+
156+
public int GetTotalEventsByUserForDataType(GameUser user, EventDataType dataType)
157+
=> this.Events.Count(e => e.UserId == user.UserId && e.StoredDataType == dataType);
155158
}

Refresh.Database/GameDatabaseContext.Leaderboard.cs

Lines changed: 33 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,13 @@ public GameScore SubmitScore(ISerializedScore score, GameUser user, GameLevel le
5353
&& currentFirstPlace.Score < score.Score
5454
&& currentFirstPlace.PublisherId != user.UserId;
5555

56-
this.Write(() =>
56+
this.WriteEnsuringStatistics(level, () =>
5757
{
5858
this.GameScores.Add(newScore);
59+
level.Statistics!.CompletionCount++;
5960
});
6061

62+
this.RecalculateScoreStatistics(level.LevelId, score.ScoreType);
6163
this.CreateLevelScoreEvent(user, newScore);
6264

6365
// Notify the last #1 users that they've been overtaken
@@ -101,12 +103,12 @@ public DatabaseScoreList GetTopScoresForLevel(GameLevel level, int count, int sk
101103
scores = scores.Where(s => s.ScoreType == scoreType);
102104

103105
if (!showDuplicates)
104-
scores = scores.DistinctBy(s => s.PublisherId);
106+
scores = scores.Where(s => s.Rank != 0);
105107

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

109-
return new(scores.ToArray().Select((s, i) => new ScoreWithRank(s, i + 1)), skip, count, user);
111+
return new(scores.ToArray().Select(s => new ScoreWithRank(s, s.Rank)), skip, count, user);
110112
}
111113

112114
public DatabaseScoreList GetRankedScoresAroundScore(GameScore score, int count, GameUser? user = null)
@@ -118,13 +120,12 @@ public DatabaseScoreList GetRankedScoresAroundScore(GameScore score, int count,
118120
List<GameScore> scores = this.GameScoresIncluded
119121
.Where(s => s.ScoreType == score.ScoreType && s.LevelId == score.LevelId)
120122
.OrderByDescending(s => s.Score)
121-
.ToArray()
122-
.DistinctBy(s => s.PublisherId)
123+
.Where(s => s.Rank != 0)
123124
.ToList();
124125

125126
return new
126127
(
127-
scores.Select((s, i) => new ScoreWithRank(s, i + 1)),
128+
scores.Select(s => new ScoreWithRank(s, s.Rank)),
128129
Math.Min(scores.Count, scores.IndexOf(score) - count / 2), // center user's score around other scores
129130
count, user
130131
);
@@ -140,18 +141,16 @@ public DatabaseScoreList GetLevelTopScoresByFriends(GameUser user, GameLevel lev
140141
IEnumerable<GameScore> scores = this.GameScoresIncluded
141142
.Where(s => s.LevelId == level.LevelId)
142143
.OrderByDescending(s => s.Score)
143-
.ToArray()
144-
.DistinctBy(s => s.PublisherId)
145-
//TODO: THIS CALL IS EXTREMELY INEFFECIENT!!! once we are in postgres land, figure out a way to do this effeciently
146-
.Where(s => s.PlayerIds.Any(p => mutuals.Contains(p)));
144+
.Where(s => s.Rank != 0)
145+
.Where(s => mutuals.Contains(s.PublisherId));
147146

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

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

154-
return new(scores.Select((s, i) => new ScoreWithRank(s, i + 1)), skip, count, user);
153+
return new(scores.ToArray().Select(s => new ScoreWithRank(s, s.Rank)), skip, count, user);
155154
}
156155

157156
[Pure]
@@ -178,35 +177,35 @@ public void DeleteScore(GameScore score)
178177
IQueryable<Event> scoreEvents = this.Events
179178
.Where(e => e.StoredDataType == EventDataType.Score && e.StoredObjectId == score.ScoreId);
180179

181-
this.Write(() =>
182-
{
183-
this.Events.RemoveRange(scoreEvents);
184-
this.GameScores.Remove(score);
185-
});
180+
this.Events.RemoveRange(scoreEvents);
181+
this.GameScores.Remove(score);
182+
this.SaveChanges();
183+
184+
// Only recalculate ranks if the score was a high-score.
185+
// Also, separate write transaction because otherwise recalculation would still include the unwanted scores.
186+
if (score.Rank != 0)
187+
this.RecalculateScoreStatistics(score.LevelId, score.ScoreType, true);
186188
}
187189

188190
public void DeleteScoresSetByUser(GameUser user)
189191
{
192+
// Find out which leaderboards we will have to recalculate the ranks of.
193+
// Only need high-scores for this, as the other, overtaken scores should not affect ranking at all,
194+
// and additionally, this way we will always have not more than 1 score per level/score type,
195+
// avoiding recalculation of the same leaderboards multiple times.
190196
IEnumerable<GameScore> scores = this.GameScores
191-
// FIXME: Realm (ahem, I mean the atlas device sdk *rolls eyes*) is a fucking joke.
192-
// Realm doesn't support .Contains on IList<T>. Yes, really.
193-
// This means we are forced to iterate over EVERY SCORE.
194-
// I can't wait for Postgres.
195-
.AsEnumerableIfRealm()
196-
.Where(s => s.PlayerIdsRaw.Contains(user.UserId.ToString()))
197-
.ToArrayIfPostgres();
198-
199-
this.Write(() =>
197+
.Where(s => s.PublisherId == user.UserId && s.Rank != 0)
198+
.ToArray();
199+
200+
this.GameScores.RemoveRange(s => s.PublisherId == user.UserId);
201+
this.Events.RemoveRange(s => s.StoredDataType == EventDataType.Score && s.UserId == user.UserId);
202+
this.SaveChanges();
203+
204+
foreach (GameScore score in scores)
200205
{
201-
foreach (GameScore score in scores)
202-
{
203-
IQueryable<Event> scoreEvents = this.Events
204-
.Where(e => e.StoredDataType == EventDataType.Score && e.StoredObjectId == score.ScoreId);
205-
206-
this.Events.RemoveRange(scoreEvents);
207-
this.GameScores.Remove(score);
208-
}
209-
});
206+
this.RecalculateScoreStatistics(score.LevelId, score.ScoreType, false);
207+
}
208+
this.SaveChanges();
210209
}
211210

212211
public IEnumerable<GameUser> GetPlayersFromScore(GameScore score)

Refresh.Database/GameDatabaseContext.Statistics.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
using System.Diagnostics;
22
using System.Diagnostics.CodeAnalysis;
3+
using Refresh.Common;
34
using Refresh.Common.Constants;
45
using Refresh.Database.Models.Comments;
56
using Refresh.Database.Models.Levels;
7+
using Refresh.Database.Models.Levels.Scores;
68
using Refresh.Database.Models.Playlists;
79
using Refresh.Database.Models.Statistics;
810
using Refresh.Database.Models.Users;
@@ -214,6 +216,50 @@ private void MarkLevelStatisticsDirty(GameLevel level)
214216
}
215217
#endregion
216218

219+
#region Level Scores
220+
221+
public void RecalculateScoreStatistics(int levelId, byte scoreType, bool saveChanges = true)
222+
{
223+
List<GameScore> scores = this.GameScores
224+
.Where(s => s.LevelId == levelId && s.ScoreType == scoreType)
225+
.OrderByDescending(s => s.Score)
226+
.ToList();
227+
228+
// Step 1: Reset all ranks
229+
foreach (GameScore score in scores)
230+
{
231+
score.Rank = 0;
232+
}
233+
234+
// Step 2: Assign ranks to high scores
235+
// Exclude all scores which are not high scores now
236+
int rank = 1;
237+
int? previousScore = null;
238+
foreach (GameScore score in scores.DistinctBy(s => s.PublisherId).ToList())
239+
{
240+
// Scores with the same value should have the same rank.
241+
// Increment rank if there was a previous score and it was higher.
242+
if (previousScore != null && previousScore > score.Score)
243+
rank++;
244+
245+
score.Rank = rank;
246+
previousScore = score.Score;
247+
}
248+
249+
if (saveChanges) this.SaveChanges();
250+
}
251+
252+
public void RecalculateScoreStatistics(GameLevel level, bool saveChanges = true)
253+
{
254+
this.RecalculateScoreStatistics(level.LevelId, 1, false);
255+
this.RecalculateScoreStatistics(level.LevelId, 2, false);
256+
this.RecalculateScoreStatistics(level.LevelId, 3, false);
257+
this.RecalculateScoreStatistics(level.LevelId, 4, false);
258+
if (saveChanges) this.SaveChanges();
259+
}
260+
261+
#endregion
262+
217263
#region Users
218264

219265
internal const int UserStatisticsVersion = 2;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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("20260306091826_CacheScoreRanks")]
11+
public partial class CacheScoreRanks : Migration
12+
{
13+
/// <inheritdoc />
14+
protected override void Up(MigrationBuilder migrationBuilder)
15+
{
16+
migrationBuilder.AddColumn<int>(
17+
name: "Rank",
18+
table: "GameScores",
19+
type: "integer",
20+
nullable: false,
21+
defaultValue: 0);
22+
}
23+
24+
/// <inheritdoc />
25+
protected override void Down(MigrationBuilder migrationBuilder)
26+
{
27+
migrationBuilder.DropColumn(
28+
name: "Rank",
29+
table: "GameScores");
30+
}
31+
}
32+
}

Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
578578
.IsRequired()
579579
.HasColumnType("text");
580580

581+
b.Property<int>("Rank")
582+
.HasColumnType("integer");
583+
581584
b.Property<int>("Score")
582585
.HasColumnType("integer");
583586

0 commit comments

Comments
 (0)