From 155b62f8522b463a659e53a3f1856ab25ab63d0e Mon Sep 17 00:00:00 2001 From: Toaster Date: Sat, 19 Jul 2025 14:43:53 +0200 Subject: [PATCH 01/10] Implement (manual) pin awarding, award some pins manually already --- Refresh.Database/GameDatabaseContext.Pins.cs | 42 +++++++++++++++++++ .../Models/Pins/ManuallyAwardedPins.cs | 22 ++++++++++ .../Endpoints/AuthenticationApiEndpoints.cs | 9 ++++ .../Endpoints/LevelApiEndpoints.cs | 10 +++++ 4 files changed, 83 insertions(+) create mode 100644 Refresh.Database/Models/Pins/ManuallyAwardedPins.cs diff --git a/Refresh.Database/GameDatabaseContext.Pins.cs b/Refresh.Database/GameDatabaseContext.Pins.cs index ef1de3eb5..62eee56af 100644 --- a/Refresh.Database/GameDatabaseContext.Pins.cs +++ b/Refresh.Database/GameDatabaseContext.Pins.cs @@ -83,6 +83,48 @@ public void UpdateUserProfilePins(List pinUpdates, GameUser user, TokenGam }); } + /// + /// Takes the existing PinProgressRelation's progress value, aswell as newProgressValue, and uses them to create the final progress value to overwrite with, + /// whether the callback adds them together, compares them etc. If there is no PinProgressRelation, its progress value gets set to newProgressValue by default, + /// ignoring finalProgressValueCallback. + /// + public PinProgressRelation UpdateUserPinProgress(long pinId, int newProgressValue, Func finalProgressValueCallback, GameUser user, bool isBeta) + { + // Get pin progress if it exists already + PinProgressRelation? progressToUpdate = this.PinProgressRelations.FirstOrDefault(p => p.PinId == pinId && p.PublisherId == user.UserId && p.IsBeta == isBeta); + DateTimeOffset now = this._time.Now; + + this.Write(() => + { + if (progressToUpdate == null) + { + progressToUpdate = new() + { + PinId = pinId, + Progress = newProgressValue, + Publisher = user, + FirstPublished = now, + LastUpdated = now, + IsBeta = isBeta, + }; + this.PinProgressRelations.Add(progressToUpdate); + } + else + { + int finalProgressValue = finalProgressValueCallback(progressToUpdate.Progress, newProgressValue); + + // Only update if the final progress value is actually different to the one already set + if (newProgressValue != finalProgressValue) + { + progressToUpdate.Progress = finalProgressValue; + progressToUpdate.LastUpdated = now; + } + } + }); + + return progressToUpdate!; + } + private IEnumerable GetPinProgressesByUser(GameUser user, bool isBeta) => this.PinProgressRelations .Where(p => p.Publisher == user && p.IsBeta == isBeta) diff --git a/Refresh.Database/Models/Pins/ManuallyAwardedPins.cs b/Refresh.Database/Models/Pins/ManuallyAwardedPins.cs new file mode 100644 index 000000000..a348e8df6 --- /dev/null +++ b/Refresh.Database/Models/Pins/ManuallyAwardedPins.cs @@ -0,0 +1,22 @@ +namespace Refresh.Database.Models.Pins; + +/// +/// The progress types of pins which either can or have to be awarded manually by the server. +/// +public enum ManuallyAwardedPins : long +{ + // Level Leaderboards + TopFourthOfXStoryLevelsWithOver50Scores = 3394094772, + TopFourthOfXCommunityLevelsWithOver50Scores = 1700253570, + TopXOfAnyStoryLevelWithOver50Scores = 191183438, + TopXOfAnyCommunityLevelWithOver50Scores = 2033315234, + + // Level Rating + YayXCommunityLevelsWithUnder10Plays = 2778528358, + YayXCommunityLevels = 1333342859, + + // Website + SignIntoWebsite = 2691148325, + HeartPlayerOnWebsite = 1965011384, + QueueLevelOnWebsite = 2833810997, +} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs index fc02fbe2a..26d01c848 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs @@ -13,6 +13,7 @@ using Refresh.Core.Types.Data; using Refresh.Database; using Refresh.Database.Models.Authentication; +using Refresh.Database.Models.Pins; using Refresh.Database.Models.Relations; using Refresh.Database.Models.Users; using Refresh.Interfaces.APIv3.Endpoints.ApiTypes; @@ -107,6 +108,14 @@ public ApiResponse Authenticate(RequestContext conte context.Logger.LogInfo(BunkumCategory.Authentication, $"{user} successfully logged in through the API"); + // Update pin progress for signing into the API + Func pinProgressUpdateCallback = delegate (int existingProgress, int progressToAdd) + { + return existingProgress + progressToAdd; + }; + database.UpdateUserPinProgress((long)ManuallyAwardedPins.SignIntoWebsite, 1, pinProgressUpdateCallback, user, false); + database.UpdateUserPinProgress((long)ManuallyAwardedPins.SignIntoWebsite, 1, pinProgressUpdateCallback, user, true); + return new ApiAuthenticationResponse { RefreshTokenData = refreshToken.TokenData, diff --git a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs index 764ed24f3..d256a6858 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs @@ -11,6 +11,7 @@ using Refresh.Database; using Refresh.Database.Models.Authentication; using Refresh.Database.Models.Levels; +using Refresh.Database.Models.Pins; using Refresh.Database.Models.Users; using Refresh.Database.Query; using Refresh.Interfaces.APIv3.Documentation.Attributes; @@ -231,6 +232,15 @@ public ApiOkResponse QueueLevel(RequestContext context, GameDatabaseContext data if (level == null) return ApiNotFoundError.LevelMissingError; database.QueueLevel(level, user); + + // Update pin progress for queueing a level through the API + Func pinProgressUpdateCallback = delegate (int existingProgress, int progressToAdd) + { + return existingProgress + progressToAdd; + }; + database.UpdateUserPinProgress((long)ManuallyAwardedPins.QueueLevelOnWebsite, 1, pinProgressUpdateCallback, user, false); + database.UpdateUserPinProgress((long)ManuallyAwardedPins.QueueLevelOnWebsite, 1, pinProgressUpdateCallback, user, true); + return new ApiOkResponse(); } From 98abb62ab16913a786894f1cfe0d6849ae87aacf Mon Sep 17 00:00:00 2001 From: Toaster Date: Wed, 30 Jul 2025 18:16:07 +0200 Subject: [PATCH 02/10] Rework pin awarding --- Refresh.Database/GameDatabaseContext.Pins.cs | 53 ++++++++++++++----- .../Models/Pins/ManuallyAwardedPins.cs | 6 +-- .../Endpoints/AuthenticationApiEndpoints.cs | 8 +-- .../Endpoints/LevelApiEndpoints.cs | 8 +-- .../Endpoints/Levels/LeaderboardEndpoints.cs | 53 ++++++++++++++++++- 5 files changed, 96 insertions(+), 32 deletions(-) diff --git a/Refresh.Database/GameDatabaseContext.Pins.cs b/Refresh.Database/GameDatabaseContext.Pins.cs index 62eee56af..36fb8febb 100644 --- a/Refresh.Database/GameDatabaseContext.Pins.cs +++ b/Refresh.Database/GameDatabaseContext.Pins.cs @@ -83,12 +83,42 @@ public void UpdateUserProfilePins(List pinUpdates, GameUser user, TokenGam }); } - /// - /// Takes the existing PinProgressRelation's progress value, aswell as newProgressValue, and uses them to create the final progress value to overwrite with, - /// whether the callback adds them together, compares them etc. If there is no PinProgressRelation, its progress value gets set to newProgressValue by default, - /// ignoring finalProgressValueCallback. - /// - public PinProgressRelation UpdateUserPinProgress(long pinId, int newProgressValue, Func finalProgressValueCallback, GameUser user, bool isBeta) + public PinProgressRelation UpdateUserPinProgressToLowest(long pinId, int newProgressValue, GameUser user, bool isBeta) + { + // Get pin progress if it exists already + PinProgressRelation? progressToUpdate = this.PinProgressRelations.FirstOrDefault(p => p.PinId == pinId && p.PublisherId == user.UserId && p.IsBeta == isBeta); + DateTimeOffset now = this._time.Now; + + if (progressToUpdate == null) + { + this.Write(() => + { + progressToUpdate = new() + { + PinId = pinId, + Progress = newProgressValue, + Publisher = user, + FirstPublished = now, + LastUpdated = now, + IsBeta = isBeta, + }; + this.PinProgressRelations.Add(progressToUpdate); + }); + } + else if (newProgressValue < progressToUpdate.Progress) + { + // Only update if the final progress value is actually different to the one already set + this.Write(() => + { + progressToUpdate.Progress = newProgressValue; + progressToUpdate.LastUpdated = now; + }); + } + + return progressToUpdate!; + } + + public PinProgressRelation IncrementUserPinProgress(long pinId, int progressToAdd, GameUser user, bool isBeta) { // Get pin progress if it exists already PinProgressRelation? progressToUpdate = this.PinProgressRelations.FirstOrDefault(p => p.PinId == pinId && p.PublisherId == user.UserId && p.IsBeta == isBeta); @@ -101,7 +131,7 @@ public PinProgressRelation UpdateUserPinProgress(long pinId, int newProgressValu progressToUpdate = new() { PinId = pinId, - Progress = newProgressValue, + Progress = progressToAdd, Publisher = user, FirstPublished = now, LastUpdated = now, @@ -111,14 +141,9 @@ public PinProgressRelation UpdateUserPinProgress(long pinId, int newProgressValu } else { - int finalProgressValue = finalProgressValueCallback(progressToUpdate.Progress, newProgressValue); - // Only update if the final progress value is actually different to the one already set - if (newProgressValue != finalProgressValue) - { - progressToUpdate.Progress = finalProgressValue; - progressToUpdate.LastUpdated = now; - } + progressToUpdate.Progress =+ progressToAdd; + progressToUpdate.LastUpdated = now; } }); diff --git a/Refresh.Database/Models/Pins/ManuallyAwardedPins.cs b/Refresh.Database/Models/Pins/ManuallyAwardedPins.cs index a348e8df6..1468101b5 100644 --- a/Refresh.Database/Models/Pins/ManuallyAwardedPins.cs +++ b/Refresh.Database/Models/Pins/ManuallyAwardedPins.cs @@ -1,7 +1,7 @@ namespace Refresh.Database.Models.Pins; /// -/// The progress types of pins which either can or have to be awarded manually by the server. +/// The progress types of pins which have to be awarded manually by the server. /// public enum ManuallyAwardedPins : long { @@ -11,10 +11,6 @@ public enum ManuallyAwardedPins : long TopXOfAnyStoryLevelWithOver50Scores = 191183438, TopXOfAnyCommunityLevelWithOver50Scores = 2033315234, - // Level Rating - YayXCommunityLevelsWithUnder10Plays = 2778528358, - YayXCommunityLevels = 1333342859, - // Website SignIntoWebsite = 2691148325, HeartPlayerOnWebsite = 1965011384, diff --git a/Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs index 26d01c848..11678808d 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs @@ -109,12 +109,8 @@ public ApiResponse Authenticate(RequestContext conte context.Logger.LogInfo(BunkumCategory.Authentication, $"{user} successfully logged in through the API"); // Update pin progress for signing into the API - Func pinProgressUpdateCallback = delegate (int existingProgress, int progressToAdd) - { - return existingProgress + progressToAdd; - }; - database.UpdateUserPinProgress((long)ManuallyAwardedPins.SignIntoWebsite, 1, pinProgressUpdateCallback, user, false); - database.UpdateUserPinProgress((long)ManuallyAwardedPins.SignIntoWebsite, 1, pinProgressUpdateCallback, user, true); + database.IncrementUserPinProgress((long)ManuallyAwardedPins.SignIntoWebsite, 1, user, false); + database.IncrementUserPinProgress((long)ManuallyAwardedPins.SignIntoWebsite, 1, user, true); return new ApiAuthenticationResponse { diff --git a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs index 16f0670c8..86daff746 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs @@ -234,12 +234,8 @@ public ApiOkResponse QueueLevel(RequestContext context, GameDatabaseContext data database.QueueLevel(level, user); // Update pin progress for queueing a level through the API - Func pinProgressUpdateCallback = delegate (int existingProgress, int progressToAdd) - { - return existingProgress + progressToAdd; - }; - database.UpdateUserPinProgress((long)ManuallyAwardedPins.QueueLevelOnWebsite, 1, pinProgressUpdateCallback, user, false); - database.UpdateUserPinProgress((long)ManuallyAwardedPins.QueueLevelOnWebsite, 1, pinProgressUpdateCallback, user, true); + database.IncrementUserPinProgress((long)ManuallyAwardedPins.QueueLevelOnWebsite, 1, user, false); + database.IncrementUserPinProgress((long)ManuallyAwardedPins.QueueLevelOnWebsite, 1, user, true); return new ApiOkResponse(); } diff --git a/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs index 07bedc609..f74211f35 100644 --- a/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs @@ -13,6 +13,7 @@ using Refresh.Database.Models.Authentication; using Refresh.Database.Models.Levels; using Refresh.Database.Models.Levels.Scores; +using Refresh.Database.Models.Pins; using Refresh.Database.Models.Users; using Refresh.Interfaces.Game.Types.Lists; using Refresh.Interfaces.Game.Types.Scores; @@ -129,10 +130,60 @@ public Response SubmitScore(RequestContext context, GameUser user, GameServerCon DatabaseList? scores = database.GetRankedScoresAroundScore(score, 5); Debug.Assert(scores != null); - + + this.AwardScoreboardPins(scores, dataContext, user, level); + return new Response(SerializedScoreLeaderboardList.FromDatabaseList(scores, dataContext), ContentType.Xml); } + /// + /// Awards certain score submission-related pins which the game expects the server to award + /// + private void AwardScoreboardPins(DatabaseList scores, DataContext dataContext, GameUser user, GameLevel level) + { + dataContext.Database.EnsureLevelStatisticsCreated(level); + int uniqueScoreCount = scores.TotalItems; + + // All pins below are only expected to be awarded if the level's leaderboard has atleast 50 scores + if (uniqueScoreCount < 50) return; + + ScoreWithRank? ownScore = scores.Items.FirstOrDefault(s => s.score.PlayerIds.Contains(user.UserId)); + if (ownScore == null) return; // Should never happen, incase it somehow does, skip this part + + // Examples: + // - rank 20 out of 40 = 50% + // - rank 5 out of 40 = 12.5% + float rankingInPercent = ownScore.rank / uniqueScoreCount * 100; + bool isStoryLevel = level.SlotType == GameSlotType.Story; + bool isGameBetaBuild = dataContext.Game == TokenGame.BetaBuild; + + // Update lowest rankingInPercent of any story/user level leaderboard + if (isStoryLevel) + { + dataContext.Database.UpdateUserPinProgressToLowest((long)ManuallyAwardedPins.TopXOfAnyStoryLevelWithOver50Scores, + (int)rankingInPercent, user, isGameBetaBuild); + } + else + { + dataContext.Database.UpdateUserPinProgressToLowest((long)ManuallyAwardedPins.TopXOfAnyCommunityLevelWithOver50Scores, + (int)rankingInPercent, user, isGameBetaBuild); + } + + // Update on how many story/user levels the user's ranking is 25% (top 1/4th of the leaderboard) or below + if (rankingInPercent <= 25) return; + + if (isStoryLevel) + { + dataContext.Database.IncrementUserPinProgress((long)ManuallyAwardedPins.TopFourthOfXStoryLevelsWithOver50Scores, + 1, user, isGameBetaBuild); + } + else + { + dataContext.Database.IncrementUserPinProgress((long)ManuallyAwardedPins.TopFourthOfXCommunityLevelsWithOver50Scores, + 1, user, isGameBetaBuild); + } + } + private (byte, DateTimeOffset?) GetScoreTypeAndMinAge(int originalType, DateTimeOffset now) { return originalType switch From 30a38dd443ab46b246b90f79dd96aeb91cf0a6f9 Mon Sep 17 00:00:00 2001 From: Toaster Date: Sat, 2 Aug 2025 20:20:36 +0200 Subject: [PATCH 03/10] Add pin tests, fix pin awarding bugs --- Refresh.Database/GameDatabaseContext.Pins.cs | 102 +++--- .../Endpoints/Levels/LeaderboardEndpoints.cs | 4 +- .../Tests/Levels/ScorePinTests.cs | 311 ++++++++++++++++++ 3 files changed, 377 insertions(+), 40 deletions(-) create mode 100644 RefreshTests.GameServer/Tests/Levels/ScorePinTests.cs diff --git a/Refresh.Database/GameDatabaseContext.Pins.cs b/Refresh.Database/GameDatabaseContext.Pins.cs index 36fb8febb..2047bcd16 100644 --- a/Refresh.Database/GameDatabaseContext.Pins.cs +++ b/Refresh.Database/GameDatabaseContext.Pins.cs @@ -1,6 +1,8 @@ using Refresh.Database.Models.Authentication; using Refresh.Database.Models.Users; using Refresh.Database.Models.Relations; +using Refresh.Database.Models.Pins; +using System.Collections.Frozen; namespace Refresh.Database; @@ -11,19 +13,26 @@ public void UpdateUserPinProgress(Dictionary pinProgressUpdates, Game DateTimeOffset now = this._time.Now; bool isBeta = game == TokenGame.BetaBuild; IEnumerable existingProgresses = this.GetPinProgressesByUser(user, isBeta); - + FrozenSet specialTreatmentPins = + [ + (long)ManuallyAwardedPins.TopXOfAnyStoryLevelWithOver50Scores, + (long)ManuallyAwardedPins.TopXOfAnyCommunityLevelWithOver50Scores, + ]; + this.Write(() => { foreach (KeyValuePair pinProgressUpdate in pinProgressUpdates) { - PinProgressRelation? existingProgress = existingProgresses.FirstOrDefault(p => p.PinId == pinProgressUpdate.Key); + long pinId = pinProgressUpdate.Key; + int newProgress = pinProgressUpdate.Value; + PinProgressRelation? existingProgress = existingProgresses.FirstOrDefault(p => p.PinId == pinId); if (existingProgress == null) { PinProgressRelation newRelation = new() { - PinId = pinProgressUpdate.Key, - Progress = pinProgressUpdate.Value, + PinId = pinId, + Progress = newProgress, Publisher = user, FirstPublished = now, LastUpdated = now, @@ -31,12 +40,16 @@ public void UpdateUserPinProgress(Dictionary pinProgressUpdates, Game }; this.PinProgressRelations.Add(newRelation); } - // Only update if the new progress is actually better - else if (pinProgressUpdate.Value > existingProgress.Progress) + // Only update progress if it's better. For most pins it's better the greater it is, but for the pins in + // specialTreatmentPins, it's better the smaller it is. + else if ((specialTreatmentPins.Contains(pinId) + && newProgress < existingProgress.Progress) + || newProgress > existingProgress.Progress) { - existingProgress.Progress = pinProgressUpdate.Value; + existingProgress.Progress = newProgress; existingProgress.LastUpdated = now; } + // Only update if the new progress is actually better } }); } @@ -91,23 +104,27 @@ public PinProgressRelation UpdateUserPinProgressToLowest(long pinId, int newProg if (progressToUpdate == null) { + PinProgressRelation newRelation = new() + { + PinId = pinId, + Progress = newProgressValue, + Publisher = user, + PublisherId = user.UserId, + FirstPublished = now, + LastUpdated = now, + IsBeta = isBeta, + }; + this.Write(() => { - progressToUpdate = new() - { - PinId = pinId, - Progress = newProgressValue, - Publisher = user, - FirstPublished = now, - LastUpdated = now, - IsBeta = isBeta, - }; - this.PinProgressRelations.Add(progressToUpdate); + this.PinProgressRelations.Add(newRelation); }); + + return newRelation; } + // Only update if the final progress value is actually lower to the one already set else if (newProgressValue < progressToUpdate.Progress) { - // Only update if the final progress value is actually different to the one already set this.Write(() => { progressToUpdate.Progress = newProgressValue; @@ -115,7 +132,7 @@ public PinProgressRelation UpdateUserPinProgressToLowest(long pinId, int newProg }); } - return progressToUpdate!; + return progressToUpdate; } public PinProgressRelation IncrementUserPinProgress(long pinId, int progressToAdd, GameUser user, bool isBeta) @@ -124,30 +141,36 @@ public PinProgressRelation IncrementUserPinProgress(long pinId, int progressToAd PinProgressRelation? progressToUpdate = this.PinProgressRelations.FirstOrDefault(p => p.PinId == pinId && p.PublisherId == user.UserId && p.IsBeta == isBeta); DateTimeOffset now = this._time.Now; - this.Write(() => + if (progressToUpdate == null) { - if (progressToUpdate == null) + PinProgressRelation newRelation = new() { - progressToUpdate = new() - { - PinId = pinId, - Progress = progressToAdd, - Publisher = user, - FirstPublished = now, - LastUpdated = now, - IsBeta = isBeta, - }; - this.PinProgressRelations.Add(progressToUpdate); - } - else + PinId = pinId, + Progress = progressToAdd, + Publisher = user, + PublisherId = user.UserId, + FirstPublished = now, + LastUpdated = now, + IsBeta = isBeta, + }; + + this.Write(() => + { + this.PinProgressRelations.Add(newRelation); + }); + + return newRelation; + } + else + { + this.Write(() => { - // Only update if the final progress value is actually different to the one already set progressToUpdate.Progress =+ progressToAdd; progressToUpdate.LastUpdated = now; - } - }); - - return progressToUpdate!; + }); + } + + return progressToUpdate; } private IEnumerable GetPinProgressesByUser(GameUser user, bool isBeta) @@ -158,6 +181,9 @@ private IEnumerable GetPinProgressesByUser(GameUser user, b public DatabaseList GetPinProgressesByUser(GameUser user, TokenGame game, int skip, int count) => new(this.GetPinProgressesByUser(user, game == TokenGame.BetaBuild), skip, count); + public PinProgressRelation? GetUserPinProgress(long pinId, GameUser user, bool isBeta) + => this.PinProgressRelations.FirstOrDefault(p => p.PinId == pinId && p.PublisherId == user.UserId && p.IsBeta == isBeta); + private IEnumerable GetProfilePinsByUser(GameUser user, TokenGame game) => this.ProfilePinRelations .Where(p => p.Publisher == user && p.Game == game) diff --git a/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs index f74211f35..ac65a409b 100644 --- a/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs @@ -153,7 +153,7 @@ private void AwardScoreboardPins(DatabaseList scores, DataContext // Examples: // - rank 20 out of 40 = 50% // - rank 5 out of 40 = 12.5% - float rankingInPercent = ownScore.rank / uniqueScoreCount * 100; + int rankingInPercent = (int)((float)ownScore.rank / uniqueScoreCount * 100); bool isStoryLevel = level.SlotType == GameSlotType.Story; bool isGameBetaBuild = dataContext.Game == TokenGame.BetaBuild; @@ -170,7 +170,7 @@ private void AwardScoreboardPins(DatabaseList scores, DataContext } // Update on how many story/user levels the user's ranking is 25% (top 1/4th of the leaderboard) or below - if (rankingInPercent <= 25) return; + if (rankingInPercent > 25) return; if (isStoryLevel) { diff --git a/RefreshTests.GameServer/Tests/Levels/ScorePinTests.cs b/RefreshTests.GameServer/Tests/Levels/ScorePinTests.cs new file mode 100644 index 000000000..41188745f --- /dev/null +++ b/RefreshTests.GameServer/Tests/Levels/ScorePinTests.cs @@ -0,0 +1,311 @@ +using Refresh.Database; +using Refresh.Database.Models.Authentication; +using Refresh.Database.Models.Levels; +using Refresh.Database.Models.Levels.Scores; +using Refresh.Database.Models.Pins; +using Refresh.Database.Models.Relations; +using Refresh.Database.Models.Users; +using Refresh.Interfaces.Game.Types.UserData.Leaderboard; +using RefreshTests.GameServer.Extensions; + +namespace RefreshTests.GameServer.Tests.Levels; + +public class ScorePinTests : GameServerTest +{ + [Test] + [TestCase(1)] + [TestCase(2)] + public void AchieveTopXOfCommunityLeaderboardsPin(byte scoreType) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + GameLevel level = context.CreateLevel(user); + + // Prepare by posting 100 scores by other people + context.FillLeaderboard(level, 100, scoreType); + + // Ensure the level now has 100 unique scores + DatabaseList scores = context.Database.GetTopScoresForLevel(level, 100, 0, scoreType); + Assert.That(scores.TotalItems, Is.EqualTo(100)); + + // Now post our score + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + SerializedScore score = new() + { + Host = true, + ScoreType = scoreType, + Score = 10, + }; + + context.Database.PlayLevel(level, user, 1); + + HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/user/{level.LevelId}", new StringContent(score.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + // Ensure we now have the pin + PinProgressRelation? relation = context.Database.GetUserPinProgress((long)ManuallyAwardedPins.TopXOfAnyCommunityLevelWithOver50Scores, user, false); + Assert.That(relation, Is.Not.Null); + int progress = relation!.Progress; + + // Now post a better score to try and update our pin progress + score = new() + { + Host = true, + ScoreType = scoreType, + Score = 50, + }; + + message = client.PostAsync($"/lbp/scoreboard/user/{level.LevelId}", new StringContent(score.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + // Ensure the pin has a better progress value + relation = context.Database.GetUserPinProgress((long)ManuallyAwardedPins.TopXOfAnyCommunityLevelWithOver50Scores, user, false); + Assert.That(relation, Is.Not.Null); + Assert.That(relation!.Progress, Is.LessThan(progress)); + } + + [Test] + [TestCase(1)] + [TestCase(2)] + public void RejectTopXOfCommunityLeaderboardsPinIfTooFewScores(byte scoreType) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + GameLevel level = context.CreateLevel(user); + + // Prepare by posting only 10 scores by other people + context.FillLeaderboard(level, 10, scoreType); + + // Ensure the level now has 10 unique scores + DatabaseList scores = context.Database.GetTopScoresForLevel(level, 100, 0, scoreType); + Assert.That(scores.TotalItems, Is.EqualTo(10)); + + // Now post our score + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + SerializedScore score = new() + { + Host = true, + ScoreType = scoreType, + Score = 10, + }; + + context.Database.PlayLevel(level, user, 1); + + HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/user/{level.LevelId}", new StringContent(score.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + // Ensure we don't have the pin + PinProgressRelation? relation = context.Database.GetUserPinProgress((long)ManuallyAwardedPins.TopXOfAnyCommunityLevelWithOver50Scores, user, false); + Assert.That(relation, Is.Null); + } + + [Test] + [TestCase(1)] + [TestCase(2)] + public void AchieveTopXOfStoryLeaderboardsPin(byte scoreType) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + GameLevel level = context.Database.GetStoryLevelById(1); + + // Prepare by posting 100 scores by other people + context.FillLeaderboard(level, 100, scoreType); + + // Ensure the level now has 100 unique scores + DatabaseList scores = context.Database.GetTopScoresForLevel(level, 100, 0, scoreType); + Assert.That(scores.TotalItems, Is.EqualTo(100)); + + // Now post our score + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + SerializedScore score = new() + { + Host = true, + ScoreType = scoreType, + Score = 10, + }; + + context.Database.PlayLevel(level, user, 1); + + HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/developer/{level.StoryId}", new StringContent(score.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + // Ensure we now have the pin + PinProgressRelation? relation = context.Database.GetUserPinProgress((long)ManuallyAwardedPins.TopXOfAnyStoryLevelWithOver50Scores, user, false); + Assert.That(relation, Is.Not.Null); + } + + [Test] + [TestCase(1)] + [TestCase(2)] + public void RejectTopXOfStoryLeaderboardsPinIfTooFewScores(byte scoreType) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + GameLevel level = context.Database.GetStoryLevelById(1); + + // Prepare by posting only 10 scores by other people + context.FillLeaderboard(level, 10, scoreType); + + // Ensure the level now has 10 unique scores + DatabaseList scores = context.Database.GetTopScoresForLevel(level, 100, 0, scoreType); + Assert.That(scores.TotalItems, Is.EqualTo(10)); + + // Now post our score + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + SerializedScore score = new() + { + Host = true, + ScoreType = scoreType, + Score = 10, + }; + + context.Database.PlayLevel(level, user, 1); + + HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/developer/{level.StoryId}", new StringContent(score.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + // Ensure we don't have the pin + PinProgressRelation? relation = context.Database.GetUserPinProgress((long)ManuallyAwardedPins.TopXOfAnyStoryLevelWithOver50Scores, user, false); + Assert.That(relation, Is.Null); + } + + [Test] + [TestCase(1)] + [TestCase(2)] + public void AchieveTopFourthOfXCommunityLeaderboardsPin(byte scoreType) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + GameLevel level = context.CreateLevel(user); + + // Prepare by posting 100 scores by other people + context.FillLeaderboard(level, 100, scoreType); + + // Ensure the level now has 100 unique scores + DatabaseList scores = context.Database.GetTopScoresForLevel(level, 100, 0, scoreType); + Assert.That(scores.TotalItems, Is.EqualTo(100)); + + // Now post our score, which will beat most other scores + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + SerializedScore score = new() + { + Host = true, + ScoreType = scoreType, + Score = 420, + }; + + context.Database.PlayLevel(level, user, 1); + + HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/user/{level.LevelId}", new StringContent(score.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + // Ensure we have the pin + PinProgressRelation? relation = context.Database.GetUserPinProgress((long)ManuallyAwardedPins.TopFourthOfXCommunityLevelsWithOver50Scores, user, false); + Assert.That(relation, Is.Not.Null); + } + + [Test] + [TestCase(1)] + [TestCase(2)] + public void RejectTopFourthOfXCommunityLeaderboardsPinIfSkillIssue(byte scoreType) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + GameLevel level = context.CreateLevel(user); + + // Prepare by posting 100 scores by other people + context.FillLeaderboard(level, 100, scoreType); + + // Ensure the level now has 100 unique scores + DatabaseList scores = context.Database.GetTopScoresForLevel(level, 100, 0, scoreType); + Assert.That(scores.TotalItems, Is.EqualTo(100)); + + // Now post our score, which definitely won't make it to the top 25% + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + SerializedScore score = new() + { + Host = true, + ScoreType = scoreType, + Score = 5, + }; + + context.Database.PlayLevel(level, user, 1); + + HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/user/{level.LevelId}", new StringContent(score.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + // Ensure we don't have the pin + PinProgressRelation? relation = context.Database.GetUserPinProgress((long)ManuallyAwardedPins.TopFourthOfXCommunityLevelsWithOver50Scores, user, false); + Assert.That(relation, Is.Null); + } + + [Test] + [TestCase(1)] + [TestCase(2)] + public void AchieveTopFourthOfXStoryLeaderboardsPin(byte scoreType) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + GameLevel level = context.Database.GetStoryLevelById(1); + + // Prepare by posting 100 scores by other people + context.FillLeaderboard(level, 100, scoreType); + + // Ensure the level now has 100 unique scores + DatabaseList scores = context.Database.GetTopScoresForLevel(level, 100, 0, scoreType); + Assert.That(scores.TotalItems, Is.EqualTo(100)); + + // Now post our score, which will beat most other scores + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + SerializedScore score = new() + { + Host = true, + ScoreType = scoreType, + Score = 420, + }; + + context.Database.PlayLevel(level, user, 1); + + HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/developer/{level.StoryId}", new StringContent(score.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + // Ensure we have the pin + PinProgressRelation? relation = context.Database.GetUserPinProgress((long)ManuallyAwardedPins.TopFourthOfXStoryLevelsWithOver50Scores, user, false); + Assert.That(relation, Is.Not.Null); + } + + [Test] + [TestCase(1)] + [TestCase(2)] + public void RejectTopFourthOfXStoryLeaderboardsPinIfSkillIssue(byte scoreType) + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + GameLevel level = context.Database.GetStoryLevelById(1); + + // Prepare by posting 100 scores by other people + context.FillLeaderboard(level, 100, scoreType); + + // Ensure the level now has 100 unique scores + DatabaseList scores = context.Database.GetTopScoresForLevel(level, 100, 0, scoreType); + Assert.That(scores.TotalItems, Is.EqualTo(100)); + + // Now post our score, which definitely won't make it to the top 25% + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + SerializedScore score = new() + { + Host = true, + ScoreType = scoreType, + Score = 5, + }; + + context.Database.PlayLevel(level, user, 1); + + HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/developer/{level.StoryId}", new StringContent(score.AsXML())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + // Ensure we don't have the pin + PinProgressRelation? relation = context.Database.GetUserPinProgress((long)ManuallyAwardedPins.TopFourthOfXStoryLevelsWithOver50Scores, user, false); + Assert.That(relation, Is.Null); + } +} \ No newline at end of file From ec7d4962bf0a8cec96025b84396a2d74e02bc6d1 Mon Sep 17 00:00:00 2001 From: Toaster Date: Sun, 3 Aug 2025 14:37:26 +0200 Subject: [PATCH 04/10] Rework and finish score pin tests, fix more bugs with awarding --- Refresh.Database/GameDatabaseContext.Pins.cs | 29 +- .../Endpoints/Levels/LeaderboardEndpoints.cs | 14 +- .../Tests/Levels/ScorePinTests.cs | 258 ++++++------------ 3 files changed, 105 insertions(+), 196 deletions(-) diff --git a/Refresh.Database/GameDatabaseContext.Pins.cs b/Refresh.Database/GameDatabaseContext.Pins.cs index 2047bcd16..9bda8906b 100644 --- a/Refresh.Database/GameDatabaseContext.Pins.cs +++ b/Refresh.Database/GameDatabaseContext.Pins.cs @@ -104,23 +104,22 @@ public PinProgressRelation UpdateUserPinProgressToLowest(long pinId, int newProg if (progressToUpdate == null) { - PinProgressRelation newRelation = new() - { - PinId = pinId, - Progress = newProgressValue, - Publisher = user, - PublisherId = user.UserId, - FirstPublished = now, - LastUpdated = now, - IsBeta = isBeta, - }; - this.Write(() => { - this.PinProgressRelations.Add(newRelation); + //PinProgressRelation newRelation = new() + progressToUpdate = new() + { + PinId = pinId, + Progress = newProgressValue, + Publisher = user, + PublisherId = user.UserId, + FirstPublished = now, + LastUpdated = now, + IsBeta = isBeta, + }; + + this.PinProgressRelations.Add(progressToUpdate); }); - - return newRelation; } // Only update if the final progress value is actually lower to the one already set else if (newProgressValue < progressToUpdate.Progress) @@ -132,7 +131,7 @@ public PinProgressRelation UpdateUserPinProgressToLowest(long pinId, int newProg }); } - return progressToUpdate; + return progressToUpdate!; } public PinProgressRelation IncrementUserPinProgress(long pinId, int progressToAdd, GameUser user, bool isBeta) diff --git a/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs index ac65a409b..2fde1c0bf 100644 --- a/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs @@ -145,15 +145,19 @@ private void AwardScoreboardPins(DatabaseList scores, DataContext int uniqueScoreCount = scores.TotalItems; // All pins below are only expected to be awarded if the level's leaderboard has atleast 50 scores + // This also prevents dividing by 0 below. if (uniqueScoreCount < 50) return; ScoreWithRank? ownScore = scores.Items.FirstOrDefault(s => s.score.PlayerIds.Contains(user.UserId)); if (ownScore == null) return; // Should never happen, incase it somehow does, skip this part - // Examples: + // Examples for rankingInPercent: // - rank 20 out of 40 = 50% // - rank 5 out of 40 = 12.5% - int rankingInPercent = (int)((float)ownScore.rank / uniqueScoreCount * 100); + // Always rounding up will prevent users from being top 0% of a leaderboard (since 1% is the maximum) + // after the int cast; and for the top 25% pins, being in the top 25.001% for example technically + // doesn't count as completing the pins' objective, since that's still greater than 25%. + int rankingInPercent = (int)Math.Ceiling((float)ownScore.rank / uniqueScoreCount * 100); bool isStoryLevel = level.SlotType == GameSlotType.Story; bool isGameBetaBuild = dataContext.Game == TokenGame.BetaBuild; @@ -161,15 +165,15 @@ private void AwardScoreboardPins(DatabaseList scores, DataContext if (isStoryLevel) { dataContext.Database.UpdateUserPinProgressToLowest((long)ManuallyAwardedPins.TopXOfAnyStoryLevelWithOver50Scores, - (int)rankingInPercent, user, isGameBetaBuild); + rankingInPercent, user, isGameBetaBuild); } else { dataContext.Database.UpdateUserPinProgressToLowest((long)ManuallyAwardedPins.TopXOfAnyCommunityLevelWithOver50Scores, - (int)rankingInPercent, user, isGameBetaBuild); + rankingInPercent, user, isGameBetaBuild); } - // Update on how many story/user levels the user's ranking is 25% (top 1/4th of the leaderboard) or below + // Update on how many story/user levels the user's ranking is in the top 25% (top 1/4th) of the leaderboard or below if (rankingInPercent > 25) return; if (isStoryLevel) diff --git a/RefreshTests.GameServer/Tests/Levels/ScorePinTests.cs b/RefreshTests.GameServer/Tests/Levels/ScorePinTests.cs index 41188745f..dec229d01 100644 --- a/RefreshTests.GameServer/Tests/Levels/ScorePinTests.cs +++ b/RefreshTests.GameServer/Tests/Levels/ScorePinTests.cs @@ -13,13 +13,19 @@ namespace RefreshTests.GameServer.Tests.Levels; public class ScorePinTests : GameServerTest { [Test] - [TestCase(1)] - [TestCase(2)] - public void AchieveTopXOfCommunityLeaderboardsPin(byte scoreType) + [TestCase(1, false)] + [TestCase(2, false)] + [TestCase(1, true)] + [TestCase(2, true)] + public void AchieveTopXOfLeaderboardsPin(byte scoreType, bool isStoryLevel) { using TestContext context = this.GetServer(); GameUser user = context.CreateUser(); - GameLevel level = context.CreateLevel(user); + GameLevel level = isStoryLevel ? context.Database.GetStoryLevelById(1) : context.CreateLevel(user); + int levelId = isStoryLevel ? level.StoryId : level.LevelId; + string slotType = level.SlotType.ToGameType(); + long pinIdToCheck = isStoryLevel ? (long)ManuallyAwardedPins.TopXOfAnyStoryLevelWithOver50Scores + : (long)ManuallyAwardedPins.TopXOfAnyCommunityLevelWithOver50Scores; // Prepare by posting 100 scores by other people context.FillLeaderboard(level, 100, scoreType); @@ -28,9 +34,9 @@ public void AchieveTopXOfCommunityLeaderboardsPin(byte scoreType) DatabaseList scores = context.Database.GetTopScoresForLevel(level, 100, 0, scoreType); Assert.That(scores.TotalItems, Is.EqualTo(100)); - // Now post our score + // ROUND 1: Post our initial score to create the pin relation using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); - SerializedScore score = new() + SerializedScore score1 = new() { Host = true, ScoreType = scoreType, @@ -38,75 +44,47 @@ public void AchieveTopXOfCommunityLeaderboardsPin(byte scoreType) }; context.Database.PlayLevel(level, user, 1); - - HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/user/{level.LevelId}", new StringContent(score.AsXML())).Result; + + HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/{slotType}/{levelId}", new StringContent(score1.AsXML())).Result; Assert.That(message.StatusCode, Is.EqualTo(OK)); // Ensure we now have the pin - PinProgressRelation? relation = context.Database.GetUserPinProgress((long)ManuallyAwardedPins.TopXOfAnyCommunityLevelWithOver50Scores, user, false); + PinProgressRelation? relation = context.Database.GetUserPinProgress(pinIdToCheck, user, false); Assert.That(relation, Is.Not.Null); int progress = relation!.Progress; - // Now post a better score to try and update our pin progress - score = new() + // ROUND 2: Now post a better score to update our pin relation + SerializedScore score2 = new() { Host = true, ScoreType = scoreType, Score = 50, }; - message = client.PostAsync($"/lbp/scoreboard/user/{level.LevelId}", new StringContent(score.AsXML())).Result; + message = client.PostAsync($"/lbp/scoreboard/{slotType}/{levelId}", new StringContent(score2.AsXML())).Result; Assert.That(message.StatusCode, Is.EqualTo(OK)); - // Ensure the pin has a better progress value - relation = context.Database.GetUserPinProgress((long)ManuallyAwardedPins.TopXOfAnyCommunityLevelWithOver50Scores, user, false); + // Ensure the pin now has a better progress value (is smaller) + context.Database.Refresh(); + relation = context.Database.GetUserPinProgress(pinIdToCheck, user, false); Assert.That(relation, Is.Not.Null); Assert.That(relation!.Progress, Is.LessThan(progress)); } [Test] - [TestCase(1)] - [TestCase(2)] - public void RejectTopXOfCommunityLeaderboardsPinIfTooFewScores(byte scoreType) - { - using TestContext context = this.GetServer(); - GameUser user = context.CreateUser(); - GameLevel level = context.CreateLevel(user); - - // Prepare by posting only 10 scores by other people - context.FillLeaderboard(level, 10, scoreType); - - // Ensure the level now has 10 unique scores - DatabaseList scores = context.Database.GetTopScoresForLevel(level, 100, 0, scoreType); - Assert.That(scores.TotalItems, Is.EqualTo(10)); - - // Now post our score - using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); - SerializedScore score = new() - { - Host = true, - ScoreType = scoreType, - Score = 10, - }; - - context.Database.PlayLevel(level, user, 1); - - HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/user/{level.LevelId}", new StringContent(score.AsXML())).Result; - Assert.That(message.StatusCode, Is.EqualTo(OK)); - - // Ensure we don't have the pin - PinProgressRelation? relation = context.Database.GetUserPinProgress((long)ManuallyAwardedPins.TopXOfAnyCommunityLevelWithOver50Scores, user, false); - Assert.That(relation, Is.Null); - } - - [Test] - [TestCase(1)] - [TestCase(2)] - public void AchieveTopXOfStoryLeaderboardsPin(byte scoreType) + [TestCase(1, false)] + [TestCase(2, false)] + [TestCase(1, true)] + [TestCase(2, true)] + public void AchieveTopFourthOfXLeaderboardsPin(byte scoreType, bool isStoryLevel) { using TestContext context = this.GetServer(); GameUser user = context.CreateUser(); - GameLevel level = context.Database.GetStoryLevelById(1); + GameLevel level = isStoryLevel ? context.Database.GetStoryLevelById(1) : context.CreateLevel(user); + int levelId = isStoryLevel ? level.StoryId : level.LevelId; + string slotType = level.SlotType.ToGameType(); + long pinIdToCheck = isStoryLevel ? (long)ManuallyAwardedPins.TopXOfAnyStoryLevelWithOver50Scores + : (long)ManuallyAwardedPins.TopXOfAnyCommunityLevelWithOver50Scores; // Prepare by posting 100 scores by other people context.FillLeaderboard(level, 100, scoreType); @@ -115,197 +93,125 @@ public void AchieveTopXOfStoryLeaderboardsPin(byte scoreType) DatabaseList scores = context.Database.GetTopScoresForLevel(level, 100, 0, scoreType); Assert.That(scores.TotalItems, Is.EqualTo(100)); - // Now post our score + // ROUND 1: Post our score which will definitely make it to the top 25% using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); - SerializedScore score = new() + SerializedScore score1 = new() { Host = true, ScoreType = scoreType, - Score = 10, + Score = 80, }; context.Database.PlayLevel(level, user, 1); - HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/developer/{level.StoryId}", new StringContent(score.AsXML())).Result; + HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/{slotType}/{levelId}", new StringContent(score1.AsXML())).Result; Assert.That(message.StatusCode, Is.EqualTo(OK)); // Ensure we now have the pin - PinProgressRelation? relation = context.Database.GetUserPinProgress((long)ManuallyAwardedPins.TopXOfAnyStoryLevelWithOver50Scores, user, false); + PinProgressRelation? relation = context.Database.GetUserPinProgress(pinIdToCheck, user, false); Assert.That(relation, Is.Not.Null); - } - - [Test] - [TestCase(1)] - [TestCase(2)] - public void RejectTopXOfStoryLeaderboardsPinIfTooFewScores(byte scoreType) - { - using TestContext context = this.GetServer(); - GameUser user = context.CreateUser(); - GameLevel level = context.Database.GetStoryLevelById(1); - - // Prepare by posting only 10 scores by other people - context.FillLeaderboard(level, 10, scoreType); - - // Ensure the level now has 10 unique scores - DatabaseList scores = context.Database.GetTopScoresForLevel(level, 100, 0, scoreType); - Assert.That(scores.TotalItems, Is.EqualTo(10)); - - // Now post our score - using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); - SerializedScore score = new() - { - Host = true, - ScoreType = scoreType, - Score = 10, - }; - - context.Database.PlayLevel(level, user, 1); - - HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/developer/{level.StoryId}", new StringContent(score.AsXML())).Result; - Assert.That(message.StatusCode, Is.EqualTo(OK)); - - // Ensure we don't have the pin - PinProgressRelation? relation = context.Database.GetUserPinProgress((long)ManuallyAwardedPins.TopXOfAnyStoryLevelWithOver50Scores, user, false); - Assert.That(relation, Is.Null); - } - - [Test] - [TestCase(1)] - [TestCase(2)] - public void AchieveTopFourthOfXCommunityLeaderboardsPin(byte scoreType) - { - using TestContext context = this.GetServer(); - GameUser user = context.CreateUser(); - GameLevel level = context.CreateLevel(user); - - // Prepare by posting 100 scores by other people - context.FillLeaderboard(level, 100, scoreType); - - // Ensure the level now has 100 unique scores - DatabaseList scores = context.Database.GetTopScoresForLevel(level, 100, 0, scoreType); - Assert.That(scores.TotalItems, Is.EqualTo(100)); + int progress = relation!.Progress; - // Now post our score, which will beat most other scores - using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); - SerializedScore score = new() + // ROUND 2: Now post a better score to update our pin relation + SerializedScore score2 = new() { Host = true, ScoreType = scoreType, - Score = 420, + Score = 1000, }; - context.Database.PlayLevel(level, user, 1); - - HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/user/{level.LevelId}", new StringContent(score.AsXML())).Result; + message = client.PostAsync($"/lbp/scoreboard/{slotType}/{levelId}", new StringContent(score2.AsXML())).Result; Assert.That(message.StatusCode, Is.EqualTo(OK)); - // Ensure we have the pin - PinProgressRelation? relation = context.Database.GetUserPinProgress((long)ManuallyAwardedPins.TopFourthOfXCommunityLevelsWithOver50Scores, user, false); + // Ensure the pin now has a better progress value (is smaller) + context.Database.Refresh(); + relation = context.Database.GetUserPinProgress(pinIdToCheck, user, false); Assert.That(relation, Is.Not.Null); + Assert.That(relation!.Progress, Is.LessThan(progress)); + + // Ensure that progress is higher than 0 + Assert.That(relation!.Progress, Is.GreaterThan(0)); } [Test] - [TestCase(1)] - [TestCase(2)] - public void RejectTopFourthOfXCommunityLeaderboardsPinIfSkillIssue(byte scoreType) + [TestCase(1, false)] + [TestCase(2, false)] + [TestCase(1, true)] + [TestCase(2, true)] + public void RejectTopXOfLeaderboardsPinIfTooFewScores(byte scoreType, bool isStoryLevel) { using TestContext context = this.GetServer(); GameUser user = context.CreateUser(); - GameLevel level = context.CreateLevel(user); + GameLevel level = isStoryLevel ? context.Database.GetStoryLevelById(1) : context.CreateLevel(user); + int levelId = isStoryLevel ? level.StoryId : level.LevelId; + string slotType = level.SlotType.ToGameType(); + long pinIdToCheck = isStoryLevel ? (long)ManuallyAwardedPins.TopFourthOfXStoryLevelsWithOver50Scores + : (long)ManuallyAwardedPins.TopFourthOfXCommunityLevelsWithOver50Scores; - // Prepare by posting 100 scores by other people - context.FillLeaderboard(level, 100, scoreType); + // Prepare by posting 10 scores by other people + context.FillLeaderboard(level, 10, scoreType); - // Ensure the level now has 100 unique scores + // Ensure the level now has only 10 unique scores (less than 50) DatabaseList scores = context.Database.GetTopScoresForLevel(level, 100, 0, scoreType); - Assert.That(scores.TotalItems, Is.EqualTo(100)); + Assert.That(scores.TotalItems, Is.EqualTo(10)); - // Now post our score, which definitely won't make it to the top 25% + // Post our own score using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); SerializedScore score = new() { Host = true, ScoreType = scoreType, - Score = 5, + Score = 10, }; context.Database.PlayLevel(level, user, 1); - - HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/user/{level.LevelId}", new StringContent(score.AsXML())).Result; + + HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/{slotType}/{levelId}", new StringContent(score.AsXML())).Result; Assert.That(message.StatusCode, Is.EqualTo(OK)); // Ensure we don't have the pin - PinProgressRelation? relation = context.Database.GetUserPinProgress((long)ManuallyAwardedPins.TopFourthOfXCommunityLevelsWithOver50Scores, user, false); + PinProgressRelation? relation = context.Database.GetUserPinProgress(pinIdToCheck, user, false); Assert.That(relation, Is.Null); } [Test] - [TestCase(1)] - [TestCase(2)] - public void AchieveTopFourthOfXStoryLeaderboardsPin(byte scoreType) + [TestCase(1, false)] + [TestCase(2, false)] + [TestCase(1, true)] + [TestCase(2, true)] + public void RejectTopFourthOfXLeaderboardsPinIfSkillIssue(byte scoreType, bool isStoryLevel) { using TestContext context = this.GetServer(); GameUser user = context.CreateUser(); - GameLevel level = context.Database.GetStoryLevelById(1); + GameLevel level = isStoryLevel ? context.Database.GetStoryLevelById(1) : context.CreateLevel(user); + int levelId = isStoryLevel ? level.StoryId : level.LevelId; + string slotType = level.SlotType.ToGameType(); + long pinIdToCheck = isStoryLevel ? (long)ManuallyAwardedPins.TopFourthOfXStoryLevelsWithOver50Scores + : (long)ManuallyAwardedPins.TopFourthOfXCommunityLevelsWithOver50Scores; // Prepare by posting 100 scores by other people context.FillLeaderboard(level, 100, scoreType); - // Ensure the level now has 100 unique scores + // Ensure the level now has 100 unique scores to try to create the pin relation DatabaseList scores = context.Database.GetTopScoresForLevel(level, 100, 0, scoreType); Assert.That(scores.TotalItems, Is.EqualTo(100)); - // Now post our score, which will beat most other scores + // Post a score which will definitely not make it to the top 25% using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); SerializedScore score = new() { Host = true, ScoreType = scoreType, - Score = 420, + Score = 10, }; context.Database.PlayLevel(level, user, 1); - - HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/developer/{level.StoryId}", new StringContent(score.AsXML())).Result; - Assert.That(message.StatusCode, Is.EqualTo(OK)); - - // Ensure we have the pin - PinProgressRelation? relation = context.Database.GetUserPinProgress((long)ManuallyAwardedPins.TopFourthOfXStoryLevelsWithOver50Scores, user, false); - Assert.That(relation, Is.Not.Null); - } - - [Test] - [TestCase(1)] - [TestCase(2)] - public void RejectTopFourthOfXStoryLeaderboardsPinIfSkillIssue(byte scoreType) - { - using TestContext context = this.GetServer(); - GameUser user = context.CreateUser(); - GameLevel level = context.Database.GetStoryLevelById(1); - - // Prepare by posting 100 scores by other people - context.FillLeaderboard(level, 100, scoreType); - // Ensure the level now has 100 unique scores - DatabaseList scores = context.Database.GetTopScoresForLevel(level, 100, 0, scoreType); - Assert.That(scores.TotalItems, Is.EqualTo(100)); - - // Now post our score, which definitely won't make it to the top 25% - using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); - SerializedScore score = new() - { - Host = true, - ScoreType = scoreType, - Score = 5, - }; - - context.Database.PlayLevel(level, user, 1); - - HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/developer/{level.StoryId}", new StringContent(score.AsXML())).Result; + HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/{slotType}/{levelId}", new StringContent(score.AsXML())).Result; Assert.That(message.StatusCode, Is.EqualTo(OK)); // Ensure we don't have the pin - PinProgressRelation? relation = context.Database.GetUserPinProgress((long)ManuallyAwardedPins.TopFourthOfXStoryLevelsWithOver50Scores, user, false); + PinProgressRelation? relation = context.Database.GetUserPinProgress(pinIdToCheck, user, false); Assert.That(relation, Is.Null); } } \ No newline at end of file From 14e8bad76d5043d48afea85707b602966f9d3daf Mon Sep 17 00:00:00 2001 From: Toaster Date: Sun, 3 Aug 2025 17:08:49 +0200 Subject: [PATCH 05/10] Pin test refactor, add pin progress syncing test, fix syncing bug --- Refresh.Database/GameDatabaseContext.Pins.cs | 10 +- .../Tests/Pins/PinProgressUpdatingTests.cs | 119 ++++++++++++++++++ .../Tests/{Levels => Pins}/ScorePinTests.cs | 0 3 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 RefreshTests.GameServer/Tests/Pins/PinProgressUpdatingTests.cs rename RefreshTests.GameServer/Tests/{Levels => Pins}/ScorePinTests.cs (100%) diff --git a/Refresh.Database/GameDatabaseContext.Pins.cs b/Refresh.Database/GameDatabaseContext.Pins.cs index 9bda8906b..022356322 100644 --- a/Refresh.Database/GameDatabaseContext.Pins.cs +++ b/Refresh.Database/GameDatabaseContext.Pins.cs @@ -39,17 +39,19 @@ public void UpdateUserPinProgress(Dictionary pinProgressUpdates, Game IsBeta = isBeta, }; this.PinProgressRelations.Add(newRelation); + continue; } + + bool isSpecialTreatmentPin = specialTreatmentPins.Contains(pinId); + // Only update progress if it's better. For most pins it's better the greater it is, but for the pins in // specialTreatmentPins, it's better the smaller it is. - else if ((specialTreatmentPins.Contains(pinId) - && newProgress < existingProgress.Progress) - || newProgress > existingProgress.Progress) + if (isSpecialTreatmentPin && newProgress < existingProgress.Progress + || !isSpecialTreatmentPin && newProgress > existingProgress.Progress) { existingProgress.Progress = newProgress; existingProgress.LastUpdated = now; } - // Only update if the new progress is actually better } }); } diff --git a/RefreshTests.GameServer/Tests/Pins/PinProgressUpdatingTests.cs b/RefreshTests.GameServer/Tests/Pins/PinProgressUpdatingTests.cs new file mode 100644 index 000000000..a8c03abad --- /dev/null +++ b/RefreshTests.GameServer/Tests/Pins/PinProgressUpdatingTests.cs @@ -0,0 +1,119 @@ +using System.Net.Http.Json; +using System.Text; +using Newtonsoft.Json; +using Refresh.Database; +using Refresh.Database.Models.Authentication; +using Refresh.Database.Models.Levels; +using Refresh.Database.Models.Levels.Scores; +using Refresh.Database.Models.Pins; +using Refresh.Database.Models.Relations; +using Refresh.Database.Models.Users; +using Refresh.Interfaces.Game.Types.Pins; +using Refresh.Interfaces.Game.Types.UserData.Leaderboard; +using RefreshTests.GameServer.Extensions; + +namespace RefreshTests.GameServer.Tests.Levels; + +public class PinProgressUpdatingTests : GameServerTest +{ + private static List ToList(Dictionary pins) + { + List pinList = []; + foreach(KeyValuePair pin in pins) + { + pinList.Add(pin.Key); + pinList.Add(pin.Value); + } + return pinList; + } + + [Test] + public async Task UploadListOfPinProgressesTest() + { + using TestContext context = this.GetServer(); + GameUser user = context.CreateUser(); + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + + // These pins' progress works a little differently (better the smaller, not the greater), + // so we also test them specifically and differently + long specialPin1Id = (long)ManuallyAwardedPins.TopXOfAnyCommunityLevelWithOver50Scores; + long specialPin2Id = (long)ManuallyAwardedPins.TopXOfAnyStoryLevelWithOver50Scores; + + // Upload some pins to sync + Dictionary pinsToUpload1 = new() + { + {1, 1}, + {2, 2}, + {3, 1}, + {specialPin1Id, 10}, + {specialPin2Id, 10}, + }; + SerializedPins request1 = new() + { + ProgressPins = ToList(pinsToUpload1), + }; + + HttpResponseMessage message = client.PostAsync($"/lbp/update_my_pins", new StringContent(request1.AsJson())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + // Deserialize + SerializedPins? response1 = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(await message.Content.ReadAsByteArrayAsync())); + Assert.That(response1, Is.Not.Null); + Assert.That(response1!.ProgressPins, Is.Not.Empty); + + Dictionary responsePins1 = SerializedPins.ToDictionary(response1!.ProgressPins); + + // Check the response to have the same pins as the request + foreach (KeyValuePair requestPin in pinsToUpload1) + { + KeyValuePair? responsePin = responsePins1.FirstOrDefault(p => p.Key == requestPin.Key); + Assert.That(responsePin, Is.Not.Null); + Assert.That(responsePin.Value.Value, Is.EqualTo(requestPin.Value)); + } + + // Now upload another request to try to update the pins and add new ones + Dictionary pinsToUpload2 = new() + { + {4, 1}, + {5, 1}, + {2, 1}, // Have this one be intentionally worse to test whether the server only keeps better progresses + {3, 4}, + {specialPin1Id, 5}, + {specialPin2Id, 100}, // Same with this one + }; + SerializedPins request2 = new() + { + ProgressPins = ToList(pinsToUpload2), + }; + + message = client.PostAsync($"/lbp/update_my_pins", new StringContent(request2.AsJson())).Result; + Assert.That(message.StatusCode, Is.EqualTo(OK)); + + // Deserialize + SerializedPins? response2 = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(await message.Content.ReadAsByteArrayAsync())); + Assert.That(response2, Is.Not.Null); + Assert.That(response2!.ProgressPins, Is.Not.Empty); + + Dictionary responsePins2 = SerializedPins.ToDictionary(response2!.ProgressPins); + + // This is how the pin progress in the new response should look like + Dictionary syncedPinsSolution = new() + { + {1, 1}, // Ignored + {2, 2}, // Not updated + {3, 4}, // Updated + {4, 1}, // New + {5, 1}, // New + {specialPin1Id, 5}, // Updated + {specialPin2Id, 10}, // Not Updated + }; + + // Check the new response with the solution + foreach (KeyValuePair solutionPin in syncedPinsSolution) + { + KeyValuePair? responsePin = responsePins2.FirstOrDefault(p => p.Key == solutionPin.Key); + Assert.That(responsePin, Is.Not.Null); + Assert.That(responsePin.Value.Value, Is.EqualTo(solutionPin.Value)); + } + } +} \ No newline at end of file diff --git a/RefreshTests.GameServer/Tests/Levels/ScorePinTests.cs b/RefreshTests.GameServer/Tests/Pins/ScorePinTests.cs similarity index 100% rename from RefreshTests.GameServer/Tests/Levels/ScorePinTests.cs rename to RefreshTests.GameServer/Tests/Pins/ScorePinTests.cs From ab84f1b62eb901fb4e7c5deed4c11920ef42f187 Mon Sep 17 00:00:00 2001 From: Toaster Date: Sun, 3 Aug 2025 18:07:39 +0200 Subject: [PATCH 06/10] Fix and improve pin tests, slightly optimize pin syncing, improve comments --- Refresh.Database/GameDatabaseContext.Pins.cs | 5 +- .../Endpoints/Levels/LeaderboardEndpoints.cs | 6 +- .../Tests/Pins/PinProgressUpdatingTests.cs | 6 -- .../Tests/Pins/ScorePinTests.cs | 90 ++++++------------- 4 files changed, 33 insertions(+), 74 deletions(-) diff --git a/Refresh.Database/GameDatabaseContext.Pins.cs b/Refresh.Database/GameDatabaseContext.Pins.cs index 022356322..580d05435 100644 --- a/Refresh.Database/GameDatabaseContext.Pins.cs +++ b/Refresh.Database/GameDatabaseContext.Pins.cs @@ -46,8 +46,8 @@ public void UpdateUserPinProgress(Dictionary pinProgressUpdates, Game // Only update progress if it's better. For most pins it's better the greater it is, but for the pins in // specialTreatmentPins, it's better the smaller it is. - if (isSpecialTreatmentPin && newProgress < existingProgress.Progress - || !isSpecialTreatmentPin && newProgress > existingProgress.Progress) + if (!isSpecialTreatmentPin && newProgress > existingProgress.Progress + || isSpecialTreatmentPin && newProgress < existingProgress.Progress) { existingProgress.Progress = newProgress; existingProgress.LastUpdated = now; @@ -108,7 +108,6 @@ public PinProgressRelation UpdateUserPinProgressToLowest(long pinId, int newProg { this.Write(() => { - //PinProgressRelation newRelation = new() progressToUpdate = new() { PinId = pinId, diff --git a/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs index 2fde1c0bf..7e882aee2 100644 --- a/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs @@ -144,8 +144,8 @@ private void AwardScoreboardPins(DatabaseList scores, DataContext dataContext.Database.EnsureLevelStatisticsCreated(level); int uniqueScoreCount = scores.TotalItems; - // All pins below are only expected to be awarded if the level's leaderboard has atleast 50 scores - // This also prevents dividing by 0 below. + // All pins below are only expected to be awarded if the level's leaderboard has atleast 50 scores. + // This check also prevents dividing by 0 below. if (uniqueScoreCount < 50) return; ScoreWithRank? ownScore = scores.Items.FirstOrDefault(s => s.score.PlayerIds.Contains(user.UserId)); @@ -154,7 +154,7 @@ private void AwardScoreboardPins(DatabaseList scores, DataContext // Examples for rankingInPercent: // - rank 20 out of 40 = 50% // - rank 5 out of 40 = 12.5% - // Always rounding up will prevent users from being top 0% of a leaderboard (since 1% is the maximum) + // Always rounding up will prevent users from being top 0% of a leaderboard (since 1% should be the maximum) // after the int cast; and for the top 25% pins, being in the top 25.001% for example technically // doesn't count as completing the pins' objective, since that's still greater than 25%. int rankingInPercent = (int)Math.Ceiling((float)ownScore.rank / uniqueScoreCount * 100); diff --git a/RefreshTests.GameServer/Tests/Pins/PinProgressUpdatingTests.cs b/RefreshTests.GameServer/Tests/Pins/PinProgressUpdatingTests.cs index a8c03abad..d2539cf9f 100644 --- a/RefreshTests.GameServer/Tests/Pins/PinProgressUpdatingTests.cs +++ b/RefreshTests.GameServer/Tests/Pins/PinProgressUpdatingTests.cs @@ -1,15 +1,9 @@ -using System.Net.Http.Json; using System.Text; using Newtonsoft.Json; -using Refresh.Database; using Refresh.Database.Models.Authentication; -using Refresh.Database.Models.Levels; -using Refresh.Database.Models.Levels.Scores; using Refresh.Database.Models.Pins; -using Refresh.Database.Models.Relations; using Refresh.Database.Models.Users; using Refresh.Interfaces.Game.Types.Pins; -using Refresh.Interfaces.Game.Types.UserData.Leaderboard; using RefreshTests.GameServer.Extensions; namespace RefreshTests.GameServer.Tests.Levels; diff --git a/RefreshTests.GameServer/Tests/Pins/ScorePinTests.cs b/RefreshTests.GameServer/Tests/Pins/ScorePinTests.cs index dec229d01..5e6b3d5cd 100644 --- a/RefreshTests.GameServer/Tests/Pins/ScorePinTests.cs +++ b/RefreshTests.GameServer/Tests/Pins/ScorePinTests.cs @@ -1,7 +1,5 @@ -using Refresh.Database; using Refresh.Database.Models.Authentication; using Refresh.Database.Models.Levels; -using Refresh.Database.Models.Levels.Scores; using Refresh.Database.Models.Pins; using Refresh.Database.Models.Relations; using Refresh.Database.Models.Users; @@ -21,21 +19,18 @@ public void AchieveTopXOfLeaderboardsPin(byte scoreType, bool isStoryLevel) { using TestContext context = this.GetServer(); GameUser user = context.CreateUser(); + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + long pinIdToCheck = isStoryLevel ? (long)ManuallyAwardedPins.TopXOfAnyStoryLevelWithOver50Scores + : (long)ManuallyAwardedPins.TopXOfAnyCommunityLevelWithOver50Scores; + GameLevel level = isStoryLevel ? context.Database.GetStoryLevelById(1) : context.CreateLevel(user); int levelId = isStoryLevel ? level.StoryId : level.LevelId; string slotType = level.SlotType.ToGameType(); - long pinIdToCheck = isStoryLevel ? (long)ManuallyAwardedPins.TopXOfAnyStoryLevelWithOver50Scores - : (long)ManuallyAwardedPins.TopXOfAnyCommunityLevelWithOver50Scores; // Prepare by posting 100 scores by other people context.FillLeaderboard(level, 100, scoreType); - // Ensure the level now has 100 unique scores - DatabaseList scores = context.Database.GetTopScoresForLevel(level, 100, 0, scoreType); - Assert.That(scores.TotalItems, Is.EqualTo(100)); - - // ROUND 1: Post our initial score to create the pin relation - using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + // ROUND 1: Post our initial score to create a pin relation SerializedScore score1 = new() { Host = true, @@ -44,7 +39,6 @@ public void AchieveTopXOfLeaderboardsPin(byte scoreType, bool isStoryLevel) }; context.Database.PlayLevel(level, user, 1); - HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/{slotType}/{levelId}", new StringContent(score1.AsXML())).Result; Assert.That(message.StatusCode, Is.EqualTo(OK)); @@ -53,6 +47,8 @@ public void AchieveTopXOfLeaderboardsPin(byte scoreType, bool isStoryLevel) Assert.That(relation, Is.Not.Null); int progress = relation!.Progress; + context.Database.Refresh(); + // ROUND 2: Now post a better score to update our pin relation SerializedScore score2 = new() { @@ -65,10 +61,12 @@ public void AchieveTopXOfLeaderboardsPin(byte scoreType, bool isStoryLevel) Assert.That(message.StatusCode, Is.EqualTo(OK)); // Ensure the pin now has a better progress value (is smaller) - context.Database.Refresh(); relation = context.Database.GetUserPinProgress(pinIdToCheck, user, false); Assert.That(relation, Is.Not.Null); Assert.That(relation!.Progress, Is.LessThan(progress)); + + // Ensure the pin's progress is above 0 + Assert.That(relation!.Progress, Is.GreaterThan(0)); } [Test] @@ -80,22 +78,19 @@ public void AchieveTopFourthOfXLeaderboardsPin(byte scoreType, bool isStoryLevel { using TestContext context = this.GetServer(); GameUser user = context.CreateUser(); + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + long pinIdToCheck = isStoryLevel ? (long)ManuallyAwardedPins.TopFourthOfXStoryLevelsWithOver50Scores + : (long)ManuallyAwardedPins.TopFourthOfXCommunityLevelsWithOver50Scores; + + // Create a level and spam it with scores by others GameLevel level = isStoryLevel ? context.Database.GetStoryLevelById(1) : context.CreateLevel(user); int levelId = isStoryLevel ? level.StoryId : level.LevelId; string slotType = level.SlotType.ToGameType(); - long pinIdToCheck = isStoryLevel ? (long)ManuallyAwardedPins.TopXOfAnyStoryLevelWithOver50Scores - : (long)ManuallyAwardedPins.TopXOfAnyCommunityLevelWithOver50Scores; - // Prepare by posting 100 scores by other people context.FillLeaderboard(level, 100, scoreType); - // Ensure the level now has 100 unique scores - DatabaseList scores = context.Database.GetTopScoresForLevel(level, 100, 0, scoreType); - Assert.That(scores.TotalItems, Is.EqualTo(100)); - - // ROUND 1: Post our score which will definitely make it to the top 25% - using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); - SerializedScore score1 = new() + // Now post our score which will definitely make it to the top 25% + SerializedScore score = new() { Host = true, ScoreType = scoreType, @@ -103,34 +98,13 @@ public void AchieveTopFourthOfXLeaderboardsPin(byte scoreType, bool isStoryLevel }; context.Database.PlayLevel(level, user, 1); - - HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/{slotType}/{levelId}", new StringContent(score1.AsXML())).Result; + HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/{slotType}/{levelId}", new StringContent(score.AsXML())).Result; Assert.That(message.StatusCode, Is.EqualTo(OK)); // Ensure we now have the pin PinProgressRelation? relation = context.Database.GetUserPinProgress(pinIdToCheck, user, false); Assert.That(relation, Is.Not.Null); - int progress = relation!.Progress; - - // ROUND 2: Now post a better score to update our pin relation - SerializedScore score2 = new() - { - Host = true, - ScoreType = scoreType, - Score = 1000, - }; - - message = client.PostAsync($"/lbp/scoreboard/{slotType}/{levelId}", new StringContent(score2.AsXML())).Result; - Assert.That(message.StatusCode, Is.EqualTo(OK)); - - // Ensure the pin now has a better progress value (is smaller) - context.Database.Refresh(); - relation = context.Database.GetUserPinProgress(pinIdToCheck, user, false); - Assert.That(relation, Is.Not.Null); - Assert.That(relation!.Progress, Is.LessThan(progress)); - - // Ensure that progress is higher than 0 - Assert.That(relation!.Progress, Is.GreaterThan(0)); + Assert.That(relation!.Progress, Is.EqualTo(1)); } [Test] @@ -142,21 +116,18 @@ public void RejectTopXOfLeaderboardsPinIfTooFewScores(byte scoreType, bool isSto { using TestContext context = this.GetServer(); GameUser user = context.CreateUser(); + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + long pinIdToCheck = isStoryLevel ? (long)ManuallyAwardedPins.TopFourthOfXStoryLevelsWithOver50Scores + : (long)ManuallyAwardedPins.TopFourthOfXCommunityLevelsWithOver50Scores; + GameLevel level = isStoryLevel ? context.Database.GetStoryLevelById(1) : context.CreateLevel(user); int levelId = isStoryLevel ? level.StoryId : level.LevelId; string slotType = level.SlotType.ToGameType(); - long pinIdToCheck = isStoryLevel ? (long)ManuallyAwardedPins.TopFourthOfXStoryLevelsWithOver50Scores - : (long)ManuallyAwardedPins.TopFourthOfXCommunityLevelsWithOver50Scores; - // Prepare by posting 10 scores by other people + // Prepare by posting only 10 scores by other people (less than 50) context.FillLeaderboard(level, 10, scoreType); - // Ensure the level now has only 10 unique scores (less than 50) - DatabaseList scores = context.Database.GetTopScoresForLevel(level, 100, 0, scoreType); - Assert.That(scores.TotalItems, Is.EqualTo(10)); - // Post our own score - using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); SerializedScore score = new() { Host = true, @@ -165,7 +136,6 @@ public void RejectTopXOfLeaderboardsPinIfTooFewScores(byte scoreType, bool isSto }; context.Database.PlayLevel(level, user, 1); - HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/{slotType}/{levelId}", new StringContent(score.AsXML())).Result; Assert.That(message.StatusCode, Is.EqualTo(OK)); @@ -183,21 +153,18 @@ public void RejectTopFourthOfXLeaderboardsPinIfSkillIssue(byte scoreType, bool i { using TestContext context = this.GetServer(); GameUser user = context.CreateUser(); + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + long pinIdToCheck = isStoryLevel ? (long)ManuallyAwardedPins.TopFourthOfXStoryLevelsWithOver50Scores + : (long)ManuallyAwardedPins.TopFourthOfXCommunityLevelsWithOver50Scores; + GameLevel level = isStoryLevel ? context.Database.GetStoryLevelById(1) : context.CreateLevel(user); int levelId = isStoryLevel ? level.StoryId : level.LevelId; string slotType = level.SlotType.ToGameType(); - long pinIdToCheck = isStoryLevel ? (long)ManuallyAwardedPins.TopFourthOfXStoryLevelsWithOver50Scores - : (long)ManuallyAwardedPins.TopFourthOfXCommunityLevelsWithOver50Scores; // Prepare by posting 100 scores by other people context.FillLeaderboard(level, 100, scoreType); - // Ensure the level now has 100 unique scores to try to create the pin relation - DatabaseList scores = context.Database.GetTopScoresForLevel(level, 100, 0, scoreType); - Assert.That(scores.TotalItems, Is.EqualTo(100)); - // Post a score which will definitely not make it to the top 25% - using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); SerializedScore score = new() { Host = true, @@ -206,7 +173,6 @@ public void RejectTopFourthOfXLeaderboardsPinIfSkillIssue(byte scoreType, bool i }; context.Database.PlayLevel(level, user, 1); - HttpResponseMessage message = client.PostAsync($"/lbp/scoreboard/{slotType}/{levelId}", new StringContent(score.AsXML())).Result; Assert.That(message.StatusCode, Is.EqualTo(OK)); From 2c4d47feea891585340efe24f7c0dee52b583933 Mon Sep 17 00:00:00 2001 From: Toaster Date: Mon, 4 Aug 2025 10:43:53 +0200 Subject: [PATCH 07/10] Rename ManuallyAwardedPins to ServerPins --- Refresh.Database/GameDatabaseContext.Pins.cs | 4 ++-- .../Models/Pins/ManuallyAwardedPins.cs | 2 +- .../Endpoints/Levels/LeaderboardEndpoints.cs | 8 ++++---- .../Tests/Pins/PinProgressUpdatingTests.cs | 4 ++-- .../Tests/Pins/ScorePinTests.cs | 16 ++++++++-------- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Refresh.Database/GameDatabaseContext.Pins.cs b/Refresh.Database/GameDatabaseContext.Pins.cs index 580d05435..2b6f623c1 100644 --- a/Refresh.Database/GameDatabaseContext.Pins.cs +++ b/Refresh.Database/GameDatabaseContext.Pins.cs @@ -15,8 +15,8 @@ public void UpdateUserPinProgress(Dictionary pinProgressUpdates, Game IEnumerable existingProgresses = this.GetPinProgressesByUser(user, isBeta); FrozenSet specialTreatmentPins = [ - (long)ManuallyAwardedPins.TopXOfAnyStoryLevelWithOver50Scores, - (long)ManuallyAwardedPins.TopXOfAnyCommunityLevelWithOver50Scores, + (long)ServerPins.TopXOfAnyStoryLevelWithOver50Scores, + (long)ServerPins.TopXOfAnyCommunityLevelWithOver50Scores, ]; this.Write(() => diff --git a/Refresh.Database/Models/Pins/ManuallyAwardedPins.cs b/Refresh.Database/Models/Pins/ManuallyAwardedPins.cs index 1468101b5..3a6624fb0 100644 --- a/Refresh.Database/Models/Pins/ManuallyAwardedPins.cs +++ b/Refresh.Database/Models/Pins/ManuallyAwardedPins.cs @@ -3,7 +3,7 @@ namespace Refresh.Database.Models.Pins; /// /// The progress types of pins which have to be awarded manually by the server. /// -public enum ManuallyAwardedPins : long +public enum ServerPins : long { // Level Leaderboards TopFourthOfXStoryLevelsWithOver50Scores = 3394094772, diff --git a/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs index 8c10cceaa..1b3edc8dd 100644 --- a/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs @@ -164,12 +164,12 @@ private void AwardScoreboardPins(DatabaseList scores, DataContext // Update lowest rankingInPercent of any story/user level leaderboard if (isStoryLevel) { - dataContext.Database.UpdateUserPinProgressToLowest((long)ManuallyAwardedPins.TopXOfAnyStoryLevelWithOver50Scores, + dataContext.Database.UpdateUserPinProgressToLowest((long)ServerPins.TopXOfAnyStoryLevelWithOver50Scores, rankingInPercent, user, isGameBetaBuild); } else { - dataContext.Database.UpdateUserPinProgressToLowest((long)ManuallyAwardedPins.TopXOfAnyCommunityLevelWithOver50Scores, + dataContext.Database.UpdateUserPinProgressToLowest((long)ServerPins.TopXOfAnyCommunityLevelWithOver50Scores, rankingInPercent, user, isGameBetaBuild); } @@ -178,12 +178,12 @@ private void AwardScoreboardPins(DatabaseList scores, DataContext if (isStoryLevel) { - dataContext.Database.IncrementUserPinProgress((long)ManuallyAwardedPins.TopFourthOfXStoryLevelsWithOver50Scores, + dataContext.Database.IncrementUserPinProgress((long)ServerPins.TopFourthOfXStoryLevelsWithOver50Scores, 1, user, isGameBetaBuild); } else { - dataContext.Database.IncrementUserPinProgress((long)ManuallyAwardedPins.TopFourthOfXCommunityLevelsWithOver50Scores, + dataContext.Database.IncrementUserPinProgress((long)ServerPins.TopFourthOfXCommunityLevelsWithOver50Scores, 1, user, isGameBetaBuild); } } diff --git a/RefreshTests.GameServer/Tests/Pins/PinProgressUpdatingTests.cs b/RefreshTests.GameServer/Tests/Pins/PinProgressUpdatingTests.cs index d2539cf9f..23c7c70f8 100644 --- a/RefreshTests.GameServer/Tests/Pins/PinProgressUpdatingTests.cs +++ b/RefreshTests.GameServer/Tests/Pins/PinProgressUpdatingTests.cs @@ -30,8 +30,8 @@ public async Task UploadListOfPinProgressesTest() // These pins' progress works a little differently (better the smaller, not the greater), // so we also test them specifically and differently - long specialPin1Id = (long)ManuallyAwardedPins.TopXOfAnyCommunityLevelWithOver50Scores; - long specialPin2Id = (long)ManuallyAwardedPins.TopXOfAnyStoryLevelWithOver50Scores; + long specialPin1Id = (long)ServerPins.TopXOfAnyCommunityLevelWithOver50Scores; + long specialPin2Id = (long)ServerPins.TopXOfAnyStoryLevelWithOver50Scores; // Upload some pins to sync Dictionary pinsToUpload1 = new() diff --git a/RefreshTests.GameServer/Tests/Pins/ScorePinTests.cs b/RefreshTests.GameServer/Tests/Pins/ScorePinTests.cs index 5e6b3d5cd..66b6811a2 100644 --- a/RefreshTests.GameServer/Tests/Pins/ScorePinTests.cs +++ b/RefreshTests.GameServer/Tests/Pins/ScorePinTests.cs @@ -20,8 +20,8 @@ public void AchieveTopXOfLeaderboardsPin(byte scoreType, bool isStoryLevel) using TestContext context = this.GetServer(); GameUser user = context.CreateUser(); using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); - long pinIdToCheck = isStoryLevel ? (long)ManuallyAwardedPins.TopXOfAnyStoryLevelWithOver50Scores - : (long)ManuallyAwardedPins.TopXOfAnyCommunityLevelWithOver50Scores; + long pinIdToCheck = isStoryLevel ? (long)ServerPins.TopXOfAnyStoryLevelWithOver50Scores + : (long)ServerPins.TopXOfAnyCommunityLevelWithOver50Scores; GameLevel level = isStoryLevel ? context.Database.GetStoryLevelById(1) : context.CreateLevel(user); int levelId = isStoryLevel ? level.StoryId : level.LevelId; @@ -79,8 +79,8 @@ public void AchieveTopFourthOfXLeaderboardsPin(byte scoreType, bool isStoryLevel using TestContext context = this.GetServer(); GameUser user = context.CreateUser(); using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); - long pinIdToCheck = isStoryLevel ? (long)ManuallyAwardedPins.TopFourthOfXStoryLevelsWithOver50Scores - : (long)ManuallyAwardedPins.TopFourthOfXCommunityLevelsWithOver50Scores; + long pinIdToCheck = isStoryLevel ? (long)ServerPins.TopFourthOfXStoryLevelsWithOver50Scores + : (long)ServerPins.TopFourthOfXCommunityLevelsWithOver50Scores; // Create a level and spam it with scores by others GameLevel level = isStoryLevel ? context.Database.GetStoryLevelById(1) : context.CreateLevel(user); @@ -117,8 +117,8 @@ public void RejectTopXOfLeaderboardsPinIfTooFewScores(byte scoreType, bool isSto using TestContext context = this.GetServer(); GameUser user = context.CreateUser(); using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); - long pinIdToCheck = isStoryLevel ? (long)ManuallyAwardedPins.TopFourthOfXStoryLevelsWithOver50Scores - : (long)ManuallyAwardedPins.TopFourthOfXCommunityLevelsWithOver50Scores; + long pinIdToCheck = isStoryLevel ? (long)ServerPins.TopFourthOfXStoryLevelsWithOver50Scores + : (long)ServerPins.TopFourthOfXCommunityLevelsWithOver50Scores; GameLevel level = isStoryLevel ? context.Database.GetStoryLevelById(1) : context.CreateLevel(user); int levelId = isStoryLevel ? level.StoryId : level.LevelId; @@ -154,8 +154,8 @@ public void RejectTopFourthOfXLeaderboardsPinIfSkillIssue(byte scoreType, bool i using TestContext context = this.GetServer(); GameUser user = context.CreateUser(); using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); - long pinIdToCheck = isStoryLevel ? (long)ManuallyAwardedPins.TopFourthOfXStoryLevelsWithOver50Scores - : (long)ManuallyAwardedPins.TopFourthOfXCommunityLevelsWithOver50Scores; + long pinIdToCheck = isStoryLevel ? (long)ServerPins.TopFourthOfXStoryLevelsWithOver50Scores + : (long)ServerPins.TopFourthOfXCommunityLevelsWithOver50Scores; GameLevel level = isStoryLevel ? context.Database.GetStoryLevelById(1) : context.CreateLevel(user); int levelId = isStoryLevel ? level.StoryId : level.LevelId; From 7e6e46468335f4a65329350b4591d3bfaf87c07e Mon Sep 17 00:00:00 2001 From: Toaster Date: Mon, 4 Aug 2025 10:45:53 +0200 Subject: [PATCH 08/10] Rename specialTreatmentPins to descendingProgressPins, use List instead of FrozenSet --- Refresh.Database/GameDatabaseContext.Pins.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Refresh.Database/GameDatabaseContext.Pins.cs b/Refresh.Database/GameDatabaseContext.Pins.cs index 2b6f623c1..812819655 100644 --- a/Refresh.Database/GameDatabaseContext.Pins.cs +++ b/Refresh.Database/GameDatabaseContext.Pins.cs @@ -2,7 +2,6 @@ using Refresh.Database.Models.Users; using Refresh.Database.Models.Relations; using Refresh.Database.Models.Pins; -using System.Collections.Frozen; namespace Refresh.Database; @@ -13,7 +12,7 @@ public void UpdateUserPinProgress(Dictionary pinProgressUpdates, Game DateTimeOffset now = this._time.Now; bool isBeta = game == TokenGame.BetaBuild; IEnumerable existingProgresses = this.GetPinProgressesByUser(user, isBeta); - FrozenSet specialTreatmentPins = + List descendingProgressPins = [ (long)ServerPins.TopXOfAnyStoryLevelWithOver50Scores, (long)ServerPins.TopXOfAnyCommunityLevelWithOver50Scores, @@ -42,7 +41,7 @@ public void UpdateUserPinProgress(Dictionary pinProgressUpdates, Game continue; } - bool isSpecialTreatmentPin = specialTreatmentPins.Contains(pinId); + bool isSpecialTreatmentPin = descendingProgressPins.Contains(pinId); // Only update progress if it's better. For most pins it's better the greater it is, but for the pins in // specialTreatmentPins, it's better the smaller it is. From 1bc574e594fa0d0e0d6f8eff21d19198fca321fe Mon Sep 17 00:00:00 2001 From: Toaster Date: Mon, 4 Aug 2025 10:55:46 +0200 Subject: [PATCH 09/10] Implement IncrementUserPinProgress overload without isBeta parameter --- Refresh.Database/GameDatabaseContext.Pins.cs | 5 +++++ .../Endpoints/AuthenticationApiEndpoints.cs | 3 +-- Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs | 3 +-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Refresh.Database/GameDatabaseContext.Pins.cs b/Refresh.Database/GameDatabaseContext.Pins.cs index 812819655..d060e59c9 100644 --- a/Refresh.Database/GameDatabaseContext.Pins.cs +++ b/Refresh.Database/GameDatabaseContext.Pins.cs @@ -133,6 +133,11 @@ public PinProgressRelation UpdateUserPinProgressToLowest(long pinId, int newProg return progressToUpdate!; } + public void IncrementUserPinProgress(long pinId, int progressToAdd, GameUser user) + { + this.IncrementUserPinProgress(pinId, progressToAdd, user, true); + this.IncrementUserPinProgress(pinId, progressToAdd, user, false); + } public PinProgressRelation IncrementUserPinProgress(long pinId, int progressToAdd, GameUser user, bool isBeta) { diff --git a/Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs index 11678808d..85f67e257 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/AuthenticationApiEndpoints.cs @@ -109,8 +109,7 @@ public ApiResponse Authenticate(RequestContext conte context.Logger.LogInfo(BunkumCategory.Authentication, $"{user} successfully logged in through the API"); // Update pin progress for signing into the API - database.IncrementUserPinProgress((long)ManuallyAwardedPins.SignIntoWebsite, 1, user, false); - database.IncrementUserPinProgress((long)ManuallyAwardedPins.SignIntoWebsite, 1, user, true); + database.IncrementUserPinProgress((long)ServerPins.SignIntoWebsite, 1, user); return new ApiAuthenticationResponse { diff --git a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs index 86daff746..d95b26221 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs @@ -234,8 +234,7 @@ public ApiOkResponse QueueLevel(RequestContext context, GameDatabaseContext data database.QueueLevel(level, user); // Update pin progress for queueing a level through the API - database.IncrementUserPinProgress((long)ManuallyAwardedPins.QueueLevelOnWebsite, 1, user, false); - database.IncrementUserPinProgress((long)ManuallyAwardedPins.QueueLevelOnWebsite, 1, user, true); + database.IncrementUserPinProgress((long)ServerPins.QueueLevelOnWebsite, 1, user); return new ApiOkResponse(); } From d759b593869f506b165b7e4aee9ad05cfa02134a Mon Sep 17 00:00:00 2001 From: Toaster Date: Mon, 4 Aug 2025 11:49:57 +0200 Subject: [PATCH 10/10] Optimize isBeta-independent pin progress incrementing --- Refresh.Database/GameDatabaseContext.Pins.cs | 34 +++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/Refresh.Database/GameDatabaseContext.Pins.cs b/Refresh.Database/GameDatabaseContext.Pins.cs index d060e59c9..ce444b7e5 100644 --- a/Refresh.Database/GameDatabaseContext.Pins.cs +++ b/Refresh.Database/GameDatabaseContext.Pins.cs @@ -133,13 +133,30 @@ public PinProgressRelation UpdateUserPinProgressToLowest(long pinId, int newProg return progressToUpdate!; } + public void IncrementUserPinProgress(long pinId, int progressToAdd, GameUser user) { - this.IncrementUserPinProgress(pinId, progressToAdd, user, true); - this.IncrementUserPinProgress(pinId, progressToAdd, user, false); + this.Write(() => + { + this.IncrementUserPinProgressInternal(pinId, progressToAdd, user, true); + this.IncrementUserPinProgressInternal(pinId, progressToAdd, user, false); + }); + } public PinProgressRelation IncrementUserPinProgress(long pinId, int progressToAdd, GameUser user, bool isBeta) + { + PinProgressRelation relation = null!; + + this.Write(() => + { + relation = this.IncrementUserPinProgressInternal(pinId, progressToAdd, user, isBeta); + }); + + return relation; + } + + private PinProgressRelation IncrementUserPinProgressInternal(long pinId, int progressToAdd, GameUser user, bool isBeta) { // Get pin progress if it exists already PinProgressRelation? progressToUpdate = this.PinProgressRelations.FirstOrDefault(p => p.PinId == pinId && p.PublisherId == user.UserId && p.IsBeta == isBeta); @@ -158,20 +175,13 @@ public PinProgressRelation IncrementUserPinProgress(long pinId, int progressToAd IsBeta = isBeta, }; - this.Write(() => - { - this.PinProgressRelations.Add(newRelation); - }); - + this.PinProgressRelations.Add(newRelation); return newRelation; } else { - this.Write(() => - { - progressToUpdate.Progress =+ progressToAdd; - progressToUpdate.LastUpdated = now; - }); + progressToUpdate.Progress =+ progressToAdd; + progressToUpdate.LastUpdated = now; } return progressToUpdate;