Skip to content

Commit 70a66e6

Browse files
authored
Migrate scores to use proper relationships (#830)
* Initial work for score migration * Finish score migration * Implement suggested changes from code review * Make Score Timestamp default the current time * Chunk insertions to reduce packet size and give all scores the same Timestamp * Fix serialization of GameScore * Break score ties by time then scoreId * Make lighthouse score migration not dependent on current score implementation
1 parent a7d5095 commit 70a66e6

File tree

17 files changed

+548
-209
lines changed

17 files changed

+548
-209
lines changed

ProjectLighthouse.Servers.GameServer/Controllers/Slots/ScoreController.cs

Lines changed: 50 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
#nullable enable
2-
using System.Diagnostics.CodeAnalysis;
32
using LBPUnion.ProjectLighthouse.Database;
43
using LBPUnion.ProjectLighthouse.Extensions;
54
using LBPUnion.ProjectLighthouse.Helpers;
@@ -30,26 +29,14 @@ public ScoreController(DatabaseContext database)
3029
this.database = database;
3130
}
3231

33-
private string[] getFriendUsernames(int userId, string username)
32+
private static int[] GetFriendIds(int userId)
3433
{
3534
UserFriendData? store = UserFriendStore.GetUserFriendData(userId);
36-
if (store == null) return new[] { username, };
35+
List<int>? friendIds = store?.FriendIds;
36+
friendIds ??= new List<int>();
37+
friendIds.Add(userId);
3738

38-
List<string> friendNames = new()
39-
{
40-
username,
41-
};
42-
43-
// ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator
44-
foreach (int friendId in store.FriendIds)
45-
{
46-
string? friendUsername = this.database.Users.Where(u => u.UserId == friendId)
47-
.Select(u => u.Username)
48-
.FirstOrDefault();
49-
if (friendUsername != null) friendNames.Add(friendUsername);
50-
}
51-
52-
return friendNames.ToArray();
39+
return friendIds.Distinct().ToArray();
5340
}
5441

5542
[HttpPost("scoreboard/{slotType}/{id:int}")]
@@ -144,41 +131,41 @@ public async Task<IActionResult> SubmitScore(string slotType, int id, int childI
144131

145132
await this.database.SaveChangesAsync();
146133

147-
string playerIdCollection = string.Join(',', score.PlayerIds);
148-
149134
ScoreEntity? existingScore = await this.database.Scores.Where(s => s.SlotId == slot.SlotId)
150135
.Where(s => s.ChildSlotId == 0 || s.ChildSlotId == childId)
151-
.Where(s => s.PlayerIdCollection == playerIdCollection)
136+
.Where(s => s.UserId == token.UserId)
152137
.Where(s => s.Type == score.Type)
153138
.FirstOrDefaultAsync();
154139
if (existingScore != null)
155140
{
156141
existingScore.Points = Math.Max(existingScore.Points, score.Points);
142+
existingScore.Timestamp = TimeHelper.TimestampMillis;
157143
}
158144
else
159145
{
160146
ScoreEntity playerScore = new()
161147
{
162-
PlayerIdCollection = playerIdCollection,
148+
UserId = token.UserId,
163149
Type = score.Type,
164150
Points = score.Points,
165151
SlotId = slotId,
166152
ChildSlotId = childId,
153+
Timestamp = TimeHelper.TimestampMillis,
167154
};
168155
this.database.Scores.Add(playerScore);
169156
}
170157

171158
await this.database.SaveChangesAsync();
172159

173-
return this.Ok(this.getScores(new LeaderboardOptions
160+
return this.Ok(await this.GetScores(new LeaderboardOptions
174161
{
175162
RootName = "scoreboardSegment",
176163
PageSize = 5,
177164
PageStart = -1,
178165
SlotId = slotId,
179166
ChildSlotId = childId,
180167
ScoreType = score.Type,
181-
TargetUsername = username,
168+
TargetUser = token.UserId,
182169
TargetPlayerIds = null,
183170
}));
184171
}
@@ -189,8 +176,6 @@ public async Task<IActionResult> Lbp1Leaderboards(string slotType, int id)
189176
{
190177
GameTokenEntity token = this.GetToken();
191178

192-
string username = await this.database.UsernameFromGameToken(token);
193-
194179
if (slotType == "developer") id = await SlotHelper.GetPlaceholderSlotId(this.database, id, SlotType.Developer);
195180

196181
LeaderboardOptions options = new()
@@ -199,7 +184,7 @@ public async Task<IActionResult> Lbp1Leaderboards(string slotType, int id)
199184
PageStart = 1,
200185
ScoreType = -1,
201186
SlotId = id,
202-
TargetUsername = username,
187+
TargetUser = token.UserId,
203188
RootName = "scoreboardSegment",
204189
};
205190
if (!HttpMethods.IsPost(this.Request.Method))
@@ -208,7 +193,7 @@ public async Task<IActionResult> Lbp1Leaderboards(string slotType, int id)
208193
for (int i = 1; i <= 4; i++)
209194
{
210195
options.ScoreType = i;
211-
ScoreboardResponse response = this.getScores(options);
196+
ScoreboardResponse response = await this.GetScores(options);
212197
scoreboardResponses.Add(new PlayerScoreboardResponse(response.Scores, i));
213198
}
214199
return this.Ok(new MultiScoreboardResponse(scoreboardResponses));
@@ -217,9 +202,9 @@ public async Task<IActionResult> Lbp1Leaderboards(string slotType, int id)
217202
GameScore? score = await this.DeserializeBody<GameScore>();
218203
if (score == null) return this.BadRequest();
219204
options.ScoreType = score.Type;
220-
options.TargetPlayerIds = this.getFriendUsernames(token.UserId, username);
205+
options.TargetPlayerIds = GetFriendIds(token.UserId);
221206

222-
return this.Ok(this.getScores(options));
207+
return this.Ok(await this.GetScores(options));
223208
}
224209

225210
[HttpGet("friendscores/{slotType}/{slotId:int}/{type:int}")]
@@ -230,96 +215,95 @@ public async Task<IActionResult> FriendScores(string slotType, int slotId, int?
230215

231216
if (pageSize <= 0) return this.BadRequest();
232217

233-
string username = await this.database.UsernameFromGameToken(token);
234-
235218
if (SlotHelper.IsTypeInvalid(slotType)) return this.BadRequest();
236219

237220
if (slotType == "developer") slotId = await SlotHelper.GetPlaceholderSlotId(this.database, slotId, SlotType.Developer);
238221

239-
string[] friendIds = this.getFriendUsernames(token.UserId, username);
222+
int[] friendIds = GetFriendIds(token.UserId);
240223

241-
return this.Ok(this.getScores(new LeaderboardOptions
224+
return this.Ok(await this.GetScores(new LeaderboardOptions
242225
{
243226
RootName = "scores",
244227
PageSize = pageSize,
245228
PageStart = pageStart,
246229
SlotId = slotId,
247230
ChildSlotId = childId,
248231
ScoreType = type,
249-
TargetUsername = username,
232+
TargetUser = token.UserId,
250233
TargetPlayerIds = friendIds,
251234
}));
252235
}
253236

254237
[HttpGet("topscores/{slotType}/{slotId:int}/{type:int}")]
255238
[HttpGet("topscores/{slotType}/{slotId:int}/{childId:int}/{type:int}")]
256-
[SuppressMessage("ReSharper", "PossibleMultipleEnumeration")]
257239
public async Task<IActionResult> TopScores(string slotType, int slotId, int? childId, int type, [FromQuery] int pageStart = -1, [FromQuery] int pageSize = 5)
258240
{
259241
GameTokenEntity token = this.GetToken();
260242

261243
if (pageSize <= 0) return this.BadRequest();
262244

263-
string username = await this.database.UsernameFromGameToken(token);
264-
265245
if (SlotHelper.IsTypeInvalid(slotType)) return this.BadRequest();
266246

267247
if (slotType == "developer") slotId = await SlotHelper.GetPlaceholderSlotId(this.database, slotId, SlotType.Developer);
268248

269-
return this.Ok(this.getScores(new LeaderboardOptions
249+
return this.Ok(await this.GetScores(new LeaderboardOptions
270250
{
271251
RootName = "scores",
272252
PageSize = pageSize,
273253
PageStart = pageStart,
274254
SlotId = slotId,
275255
ChildSlotId = childId,
276256
ScoreType = type,
277-
TargetUsername = username,
257+
TargetUser = token.UserId,
278258
TargetPlayerIds = null,
279259
}));
280260
}
281261

282262
private class LeaderboardOptions
283263
{
284-
public int SlotId { get; set; }
264+
public int SlotId { get; init; }
285265
public int ScoreType { get; set; }
286-
public string TargetUsername { get; set; } = "";
287-
public int PageStart { get; set; } = -1;
288-
public int PageSize { get; set; } = 5;
289-
public string RootName { get; set; } = "scores";
290-
public string[]? TargetPlayerIds;
266+
public int TargetUser { get; init; }
267+
public int PageStart { get; init; } = -1;
268+
public int PageSize { get; init; } = 5;
269+
public string RootName { get; init; } = "scores";
270+
public int[]? TargetPlayerIds;
291271
public int? ChildSlotId;
292272
}
293273

294-
private ScoreboardResponse getScores(LeaderboardOptions options)
274+
private async Task<ScoreboardResponse> GetScores(LeaderboardOptions options)
295275
{
296-
// This is hella ugly but it technically assigns the proper rank to a score
297-
// var needed for Anonymous type returned from SELECT
298-
var rankedScores = this.database.Scores.Where(s => s.SlotId == options.SlotId)
276+
IQueryable<ScoreEntity> scoreQuery = this.database.Scores.Where(s => s.SlotId == options.SlotId)
299277
.Where(s => options.ScoreType == -1 || s.Type == options.ScoreType)
300278
.Where(s => s.ChildSlotId == 0 || s.ChildSlotId == options.ChildSlotId)
301-
.AsEnumerable()
302-
.Where(s => options.TargetPlayerIds == null ||
303-
options.TargetPlayerIds.Any(id => s.PlayerIdCollection.Split(",").Contains(id)))
304-
.OrderByDescending(s => s.Points)
305-
.ThenBy(s => s.ScoreId)
306-
.ToList()
307-
.Select((s, rank) => new
279+
.Where(s => options.TargetPlayerIds == null || options.TargetPlayerIds.Contains(s.UserId));
280+
281+
// First find if you have a score on a level to find scores around it
282+
var myScore = await scoreQuery.Where(s => s.UserId == options.TargetUser)
283+
.Select(s => new
308284
{
309285
Score = s,
310-
Rank = rank + 1,
311-
})
312-
.ToList();
286+
Rank = scoreQuery.Count(s2 => s2.Points > s.Points) + 1,
287+
}).FirstOrDefaultAsync();
313288

289+
int skipAmt = options.PageStart != -1 || myScore == null ? options.PageStart - 1 : myScore.Rank - 3;
314290

315-
// Find your score, since even if you aren't in the top list your score is pinned
316-
var myScore = rankedScores.Where(rs => rs.Score.PlayerIdCollection.Split(",").Contains(options.TargetUsername)).MaxBy(rs => rs.Score.Points);
291+
var rankedScores = scoreQuery.OrderByDescending(s => s.Points)
292+
.ThenBy(s => s.Timestamp)
293+
.ThenBy(s => s.ScoreId)
294+
.Skip(Math.Max(0, skipAmt))
295+
.Take(Math.Min(options.PageSize, 30))
296+
.Select(s => new
297+
{
298+
Score = s,
299+
Rank = scoreQuery.Count(s2 => s2.Points > s.Points) + 1,
300+
})
301+
.ToList();
317302

318-
// Paginated viewing: if not requesting pageStart, get results around user
319-
var pagedScores = rankedScores.Skip(options.PageStart != -1 || myScore == null ? options.PageStart - 1 : myScore.Rank - 3).Take(Math.Min(options.PageSize, 30));
303+
int totalScores = scoreQuery.Count();
320304

321-
List<GameScore> gameScores = pagedScores.ToSerializableList(ps => GameScore.CreateFromEntity(ps.Score, ps.Rank));
305+
List<GameScore> gameScores = rankedScores.ToSerializableList(ps => GameScore.CreateFromEntity(ps.Score, ps.Rank));
322306

323-
return new ScoreboardResponse(options.RootName, gameScores, rankedScores.Count, myScore?.Score.Points ?? 0, myScore?.Rank ?? 0);
307+
return new ScoreboardResponse(options.RootName, gameScores, totalScores, myScore?.Score.Points ?? 0, myScore?.Rank ?? 0);
324308
}
325309
}

ProjectLighthouse.Servers.Website/Pages/Partials/LeaderboardPartial.cshtml

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
@for(int i = 0; i < Model.Scores.Count; i++)
2525
{
2626
ScoreEntity score = Model.Scores[i];
27-
string[] playerIds = score.PlayerIds;
2827
DatabaseContext database = Model.Database;
2928
<div class="item">
3029
<span class="ui large text">
@@ -39,23 +38,18 @@
3938
</span>
4039
<div class="content">
4140
<div class="list" style="padding-top: 0">
42-
@for (int j = 0; j < playerIds.Length; j++)
43-
{
44-
UserEntity? user = await database.Users.FirstOrDefaultAsync(u => u.Username == playerIds[j]);
41+
@{
42+
UserEntity? user = await database.Users.FindAsync(score.UserId);
43+
}
4544
<div class="item">
4645
<i class="minus icon" style="padding-top: 9px"></i>
4746
<div class="content" style="padding-left: 0">
4847
@if (user != null)
4948
{
5049
@await user.ToLink(Html, ViewData, language, timeZone)
5150
}
52-
else
53-
{
54-
<p style="margin-top: 5px;">@playerIds[j]</p>
55-
}
5651
</div>
5752
</div>
58-
}
5953
</div>
6054
</div>
6155
</div>

ProjectLighthouse/Administration/Maintenance/MaintenanceHelper.cs

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@ public static class MaintenanceHelper
1919
{
2020
static MaintenanceHelper()
2121
{
22-
Commands = getListOfInterfaceObjects<ICommand>();
23-
MaintenanceJobs = getListOfInterfaceObjects<IMaintenanceJob>();
24-
MigrationTasks = getListOfInterfaceObjects<IMigrationTask>();
25-
RepeatingTasks = getListOfInterfaceObjects<IRepeatingTask>();
22+
Commands = GetListOfInterfaceObjects<ICommand>();
23+
MaintenanceJobs = GetListOfInterfaceObjects<IMaintenanceJob>();
24+
MigrationTasks = GetListOfInterfaceObjects<MigrationTask>();
25+
RepeatingTasks = GetListOfInterfaceObjects<IRepeatingTask>();
2626
}
2727

2828
public static List<ICommand> Commands { get; }
2929
public static List<IMaintenanceJob> MaintenanceJobs { get; }
30-
public static List<IMigrationTask> MigrationTasks { get; }
30+
public static List<MigrationTask> MigrationTasks { get; }
3131
public static List<IRepeatingTask> RepeatingTasks { get; }
3232

3333
public static async Task<List<LogLine>> RunCommand(IServiceProvider provider, string[] args)
@@ -80,16 +80,18 @@ public static async Task RunMaintenanceJob(string jobName)
8080
await job.Run();
8181
}
8282

83-
public static async Task RunMigration(DatabaseContext database, IMigrationTask migrationTask)
83+
public static async Task<bool> RunMigration(DatabaseContext database, MigrationTask migrationTask)
8484
{
85-
8685
// Migrations should never be run twice.
87-
Debug.Assert(!await database.CompletedMigrations.Has(m => m.MigrationName == migrationTask.GetType().Name));
86+
Debug.Assert(!await database.CompletedMigrations.Has(m => m.MigrationName == migrationTask.GetType().Name),
87+
$"Tried to run migration {migrationTask.GetType().Name} twice");
8888

89-
Logger.Info($"Running migration task {migrationTask.Name()}", LogArea.Database);
89+
Logger.Info($"Running LH migration task {migrationTask.Name()}", LogArea.Database);
9090

9191
bool success;
9292
Exception? exception = null;
93+
94+
Stopwatch stopwatch = Stopwatch.StartNew();
9395

9496
try
9597
{
@@ -103,13 +105,14 @@ public static async Task RunMigration(DatabaseContext database, IMigrationTask m
103105

104106
if (!success)
105107
{
106-
Logger.Error($"Could not run migration {migrationTask.Name()}", LogArea.Database);
108+
Logger.Error($"Could not run LH migration {migrationTask.Name()}", LogArea.Database);
107109
if (exception != null) Logger.Error(exception.ToDetailedException(), LogArea.Database);
108110

109-
return;
111+
return false;
110112
}
111-
112-
Logger.Success($"Successfully completed migration {migrationTask.Name()}", LogArea.Database);
113+
stopwatch.Stop();
114+
115+
Logger.Success($"Successfully completed LH migration {migrationTask.Name()} in {stopwatch.ElapsedMilliseconds}ms", LogArea.Database);
113116

114117
CompletedMigrationEntity completedMigration = new()
115118
{
@@ -119,13 +122,14 @@ public static async Task RunMigration(DatabaseContext database, IMigrationTask m
119122

120123
database.CompletedMigrations.Add(completedMigration);
121124
await database.SaveChangesAsync();
125+
return true;
122126
}
123127

124-
private static List<T> getListOfInterfaceObjects<T>() where T : class
128+
private static List<T> GetListOfInterfaceObjects<T>() where T : class
125129
{
126130
return Assembly.GetExecutingAssembly()
127131
.GetTypes()
128-
.Where(t => t.GetInterfaces().Contains(typeof(T)) && t.GetConstructor(Type.EmptyTypes) != null)
132+
.Where(t => (t.IsSubclassOf(typeof(T)) || t.GetInterfaces().Contains(typeof(T))) && t.GetConstructor(Type.EmptyTypes) != null)
129133
.Select(t => Activator.CreateInstance(t) as T)
130134
.ToList()!;
131135
}

ProjectLighthouse/Administration/Maintenance/MigrationTasks/CleanupBrokenVersusScoresMigration.cs

Lines changed: 0 additions & 24 deletions
This file was deleted.

0 commit comments

Comments
 (0)