diff --git a/Refresh.Database/GameDatabaseContext.Pins.cs b/Refresh.Database/GameDatabaseContext.Pins.cs index ef1de3eb5..ce444b7e5 100644 --- a/Refresh.Database/GameDatabaseContext.Pins.cs +++ b/Refresh.Database/GameDatabaseContext.Pins.cs @@ -1,6 +1,7 @@ using Refresh.Database.Models.Authentication; using Refresh.Database.Models.Users; using Refresh.Database.Models.Relations; +using Refresh.Database.Models.Pins; namespace Refresh.Database; @@ -11,30 +12,43 @@ public void UpdateUserPinProgress(Dictionary pinProgressUpdates, Game DateTimeOffset now = this._time.Now; bool isBeta = game == TokenGame.BetaBuild; IEnumerable existingProgresses = this.GetPinProgressesByUser(user, isBeta); - + List descendingProgressPins = + [ + (long)ServerPins.TopXOfAnyStoryLevelWithOver50Scores, + (long)ServerPins.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, IsBeta = isBeta, }; this.PinProgressRelations.Add(newRelation); + continue; } - // Only update if the new progress is actually better - else if (pinProgressUpdate.Value > existingProgress.Progress) + + 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. + if (!isSpecialTreatmentPin && newProgress > existingProgress.Progress + || isSpecialTreatmentPin && newProgress < existingProgress.Progress) { - existingProgress.Progress = pinProgressUpdate.Value; + existingProgress.Progress = newProgress; existingProgress.LastUpdated = now; } } @@ -83,6 +97,96 @@ public void UpdateUserProfilePins(List pinUpdates, GameUser user, TokenGam }); } + 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, + PublisherId = user.UserId, + FirstPublished = now, + LastUpdated = now, + IsBeta = isBeta, + }; + + this.PinProgressRelations.Add(progressToUpdate); + }); + } + // Only update if the final progress value is actually lower to the one already set + else if (newProgressValue < progressToUpdate.Progress) + { + this.Write(() => + { + progressToUpdate.Progress = newProgressValue; + progressToUpdate.LastUpdated = now; + }); + } + + return progressToUpdate!; + } + + public void IncrementUserPinProgress(long pinId, int progressToAdd, GameUser user) + { + 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); + DateTimeOffset now = this._time.Now; + + if (progressToUpdate == null) + { + PinProgressRelation newRelation = new() + { + PinId = pinId, + Progress = progressToAdd, + Publisher = user, + PublisherId = user.UserId, + FirstPublished = now, + LastUpdated = now, + IsBeta = isBeta, + }; + + this.PinProgressRelations.Add(newRelation); + return newRelation; + } + else + { + progressToUpdate.Progress =+ progressToAdd; + progressToUpdate.LastUpdated = now; + } + + return progressToUpdate; + } + private IEnumerable GetPinProgressesByUser(GameUser user, bool isBeta) => this.PinProgressRelations .Where(p => p.Publisher == user && p.IsBeta == isBeta) @@ -91,6 +195,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.Database/Models/Pins/ManuallyAwardedPins.cs b/Refresh.Database/Models/Pins/ManuallyAwardedPins.cs new file mode 100644 index 000000000..3a6624fb0 --- /dev/null +++ b/Refresh.Database/Models/Pins/ManuallyAwardedPins.cs @@ -0,0 +1,18 @@ +namespace Refresh.Database.Models.Pins; + +/// +/// The progress types of pins which have to be awarded manually by the server. +/// +public enum ServerPins : long +{ + // Level Leaderboards + TopFourthOfXStoryLevelsWithOver50Scores = 3394094772, + TopFourthOfXCommunityLevelsWithOver50Scores = 1700253570, + TopXOfAnyStoryLevelWithOver50Scores = 191183438, + TopXOfAnyCommunityLevelWithOver50Scores = 2033315234, + + // 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..85f67e257 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,9 @@ 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)ServerPins.SignIntoWebsite, 1, user); + return new ApiAuthenticationResponse { RefreshTokenData = refreshToken.TokenData, diff --git a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs index eef6488bf..d95b26221 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,10 @@ 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 + database.IncrementUserPinProgress((long)ServerPins.QueueLevelOnWebsite, 1, user); + return new ApiOkResponse(); } diff --git a/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs index a83465d0b..1b3edc8dd 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,64 @@ 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. + // This check 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 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% 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); + 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)ServerPins.TopXOfAnyStoryLevelWithOver50Scores, + rankingInPercent, user, isGameBetaBuild); + } + else + { + dataContext.Database.UpdateUserPinProgressToLowest((long)ServerPins.TopXOfAnyCommunityLevelWithOver50Scores, + rankingInPercent, user, isGameBetaBuild); + } + + // 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) + { + dataContext.Database.IncrementUserPinProgress((long)ServerPins.TopFourthOfXStoryLevelsWithOver50Scores, + 1, user, isGameBetaBuild); + } + else + { + dataContext.Database.IncrementUserPinProgress((long)ServerPins.TopFourthOfXCommunityLevelsWithOver50Scores, + 1, user, isGameBetaBuild); + } + } + private (byte, DateTimeOffset?) GetScoreTypeAndMinAge(int originalType, DateTimeOffset now) { return originalType switch diff --git a/RefreshTests.GameServer/Tests/Pins/PinProgressUpdatingTests.cs b/RefreshTests.GameServer/Tests/Pins/PinProgressUpdatingTests.cs new file mode 100644 index 000000000..23c7c70f8 --- /dev/null +++ b/RefreshTests.GameServer/Tests/Pins/PinProgressUpdatingTests.cs @@ -0,0 +1,113 @@ +using System.Text; +using Newtonsoft.Json; +using Refresh.Database.Models.Authentication; +using Refresh.Database.Models.Pins; +using Refresh.Database.Models.Users; +using Refresh.Interfaces.Game.Types.Pins; +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)ServerPins.TopXOfAnyCommunityLevelWithOver50Scores; + long specialPin2Id = (long)ServerPins.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/Pins/ScorePinTests.cs b/RefreshTests.GameServer/Tests/Pins/ScorePinTests.cs new file mode 100644 index 000000000..66b6811a2 --- /dev/null +++ b/RefreshTests.GameServer/Tests/Pins/ScorePinTests.cs @@ -0,0 +1,183 @@ +using Refresh.Database.Models.Authentication; +using Refresh.Database.Models.Levels; +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, 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(); + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + 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; + string slotType = level.SlotType.ToGameType(); + + // Prepare by posting 100 scores by other people + context.FillLeaderboard(level, 100, scoreType); + + // ROUND 1: Post our initial score to create a pin relation + SerializedScore score1 = new() + { + Host = true, + ScoreType = scoreType, + Score = 10, + }; + + 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)); + + // Ensure we now have the pin + PinProgressRelation? relation = context.Database.GetUserPinProgress(pinIdToCheck, user, false); + 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() + { + Host = true, + ScoreType = scoreType, + Score = 50, + }; + + 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) + 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] + [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(); + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + 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); + int levelId = isStoryLevel ? level.StoryId : level.LevelId; + string slotType = level.SlotType.ToGameType(); + + context.FillLeaderboard(level, 100, scoreType); + + // Now post our score which will definitely make it to the top 25% + SerializedScore score = new() + { + Host = true, + ScoreType = scoreType, + Score = 80, + }; + + 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)); + + // Ensure we now have the pin + PinProgressRelation? relation = context.Database.GetUserPinProgress(pinIdToCheck, user, false); + Assert.That(relation, Is.Not.Null); + Assert.That(relation!.Progress, Is.EqualTo(1)); + } + + [Test] + [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(); + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + 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; + string slotType = level.SlotType.ToGameType(); + + // Prepare by posting only 10 scores by other people (less than 50) + context.FillLeaderboard(level, 10, scoreType); + + // Post our own score + SerializedScore score = new() + { + Host = true, + ScoreType = scoreType, + Score = 10, + }; + + 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)); + + // Ensure we don't have the pin + PinProgressRelation? relation = context.Database.GetUserPinProgress(pinIdToCheck, user, false); + Assert.That(relation, Is.Null); + } + + [Test] + [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(); + using HttpClient client = context.GetAuthenticatedClient(TokenType.Game, user); + 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; + string slotType = level.SlotType.ToGameType(); + + // Prepare by posting 100 scores by other people + context.FillLeaderboard(level, 100, scoreType); + + // Post a score which will definitely not make it to the top 25% + SerializedScore score = new() + { + Host = true, + ScoreType = scoreType, + Score = 10, + }; + + 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)); + + // Ensure we don't have the pin + PinProgressRelation? relation = context.Database.GetUserPinProgress(pinIdToCheck, user, false); + Assert.That(relation, Is.Null); + } +} \ No newline at end of file