using System.Security.Claims; using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using RadicadeServer.API.Avatars.V2; using RadicadeServer.API.GameRewards.V1; using RadicadeServer.API.Storefront; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Avatar; using RadicadeServer.RecNet.Enums.Store; using RadicadeServer.RecNet.Moderation; using RadicadeServer.RecNet.Progression; using RadicadeServer.RecNet.Rooms; using RadicadeServer.RecNet.Storefront; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; /// /// What a completion is filed as, and what the player is handed when they choose. /// /// Three things were wrong together: the first activity of the day was not treated as a completion /// at all, so it paid tokens; a game whose own pool could not pay was paid tokens and nothing else; /// and completing a selection credited the item without telling the client, so the selection could /// not be replayed and the reward only showed after a relog. /// public class GameRewardFlowTests { private const long PlayerId = 77; private static RecDBContext NewDb() => new(TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); /// /// The same store, with the one console warning the consume route would otherwise raise turned /// off: it opens a transaction, which the in-memory provider ignores - and reports as an error - /// rather than supporting. The route's own behaviour is what is under test, not that warning. /// private static RecDBContext NewTransactionalDb() => new(TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .ConfigureWarnings(warnings => warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning)) .Options); private static GameRewardsAPI AsPlayer(RecDBContext db, long playerId = PlayerId) => new(db) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity( [new Claim(ClaimTypes.NameIdentifier, playerId.ToString())], "test")) } } }; /// /// Points the player's last visited room at a game room. The activity a completion is filed under /// is named by the room the player was in, and that is the last room the server recorded a visit /// to - there is no other way for a test to say which game was played. /// private static async Task SeedLastVisitedRoomAsync(RecDBContext db, string roomName) { var room = new RoomData { Name = roomName }; db.Rooms.Add(room); await db.SaveChangesAsync(); db.RoomInteractions.Add(new PlayerInteractionDB { PlayerId = PlayerId, RoomId = room.RoomId, Visited = true }); await db.SaveChangesAsync(); } private static async Task RequestRewardAsync(GameRewardsAPI api, RewardType type, GiftContext? giftContext = null) { await RequestOnlyAsync(api, type, giftContext); return await LatestPendingAsync(api); } /// /// Files a reward request and asserts only that it was accepted. /// /// A quest's reward does not reach /pending: it is handed over as the run's own box with no /// selection screen, so the tests that check what a quest pays read the player's gifts instead. /// private static async Task RequestOnlyAsync(GameRewardsAPI api, RewardType type, GiftContext? giftContext = null) { var body = Encoding.UTF8.GetBytes(giftContext.HasValue ? $"{{\"rewardType\":{(int)type},\"giftContext\":{(int)giftContext.Value}}}" : $"{{\"rewardType\":{(int)type}}}"); api.ControllerContext.HttpContext.Request.Body = new MemoryStream(body); api.ControllerContext.HttpContext.Request.ContentType = "application/json"; var result = await api.RequestReward(); Assert.True(result.Result is OkObjectResult, $"{(result.Result as ObjectResult)?.StatusCode.ToString() ?? "no result"}"); // The route answers the shared success object, not the reward: the client's own wrapper for it // parses one of those, and the reward reaches the watch through the box the route hands over. var ok = Assert.IsType(((OkObjectResult)result.Result!).Value); Assert.True(ok.Success); } /// /// Requests a quest completion and returns the box it paid. /// /// A quest asks the player to choose nothing, so the reward never reaches /pending as a selection: /// it is filed as the run's own box for the watch to open. These tests read that box, and the empty /// /pending is asserted here because a quest that left a selection behind would be a quest offering /// a choice it does not have. /// private static async Task RequestAndTakeQuestBoxAsync( GameRewardsAPI api, RecDBContext db, GiftContext claimed, long playerId = PlayerId) { await RequestOnlyAsync(api, RewardType.PostGameActivity, claimed); Assert.Empty(await api.GetPendingGameRewards()); var boxes = await StorefrontShared.GetPendingGiftsAsync(db, playerId); return Assert.Single(boxes); } private static async Task LatestPendingAsync(GameRewardsAPI api) { var pending = await api.GetPendingGameRewards(); Assert.NotEmpty(pending); return pending[^1]; } private static GameRewardsAPI AsSelecting(RecDBContext db, long selectionId, long giftDropId) { var api = AsPlayer(db); var body = Encoding.UTF8.GetBytes( $"{{\"rewardSelectionId\":{selectionId},\"giftDropId\":{giftDropId}}}"); api.ControllerContext.HttpContext.Request.Body = new MemoryStream(body); api.ControllerContext.HttpContext.Request.ContentType = "application/json"; return api; } // --------------------------------------------------------------------------------- // Which type a completion is filed under // --------------------------------------------------------------------------------- private static readonly DateTime Monday = new(2026, 10, 5, 12, 0, 0, DateTimeKind.Utc); [Fact] public void OnlyTheFirstCompletionOfTheDayIsTheFirstActivityOfTheDay() { var ledger = RewardDay.NewDay(Monday); // The client asks for FirstActivityOfDay on every completion. Honouring that claim every time // is what printed "First Game of the Day" on every reward all day. Assert.Equal(RewardType.FirstActivityOfDay, RewardDay.CompletionType(ledger, RewardType.FirstActivityOfDay)); Assert.True(RewardDay.TryTakeFirstActivity(ledger)); Assert.Equal(RewardType.PostGameActivity, RewardDay.CompletionType(ledger, RewardType.FirstActivityOfDay)); Assert.False(RewardDay.TryTakeFirstActivity(ledger)); // The first box of the day is its own: it does not eat into the twelve the games pay. Assert.Equal(0, ledger.ActivityBoxes); } [Fact] public void AFirstCompletionIsTheFirstActivityOfTheDayEvenWhenTheClientCallsItAPostGameReward() { var ledger = RewardDay.NewDay(Monday); Assert.Equal(RewardType.FirstActivityOfDay, RewardDay.CompletionType(ledger, RewardType.PostGameActivity)); RewardDay.TryTakeFirstActivity(ledger); Assert.Equal(RewardType.PostGameActivity, RewardDay.CompletionType(ledger, RewardType.PostGameActivity)); } [Fact] public void AQuestKeepsItsOwnTypeAndStillTakesTheDay() { var ledger = RewardDay.NewDay(Monday); Assert.Equal(RewardType.PostQuestActivity, RewardDay.CompletionType(ledger, RewardType.PostQuestActivity)); RewardDay.TryTakeFirstActivity(ledger); // The quest was the day's activity, so the game played after it is an ordinary game reward. Assert.Equal(RewardType.PostGameActivity, RewardDay.CompletionType(ledger, RewardType.FirstActivityOfDay)); } /// /// The budget: one first-of-the-day box, twelve game boxes, and a daily-challenge box of its own. /// [Fact] public void TheDayPaysFourteenBoxesAndNoMore() { var ledger = RewardDay.NewDay(Monday); Assert.True(RewardDay.TryTakeFirstActivity(ledger)); Assert.False(RewardDay.TryTakeFirstActivity(ledger)); for (var i = 0; i < RewardDay.ActivityBoxesPerDay; i++) Assert.True(RewardDay.TryTakeActivityBox(ledger), $"activity box {i + 1} was refused"); Assert.False(RewardDay.TryTakeActivityBox(ledger)); Assert.True(RewardDay.TryTakeDailyChallengeBox(ledger)); Assert.False(RewardDay.TryTakeDailyChallengeBox(ledger)); Assert.Equal(14, RewardDay.BoxesPerDay); } /// /// The ledger is the player's for the day, so a day rolls over - and a second day starts its own /// count rather than inheriting yesterday's. /// [Fact] public void ANewDayStartsANewLedgerAndTheSameDayDoesNot() { var json = new ProgressionJson(); var monday = RewardDay.For(json, Monday); Assert.True(RewardDay.TryTakeFirstActivity(monday)); RewardDay.TryTakeActivityBox(monday); // The same day is the same ledger, whatever moment it is asked at. Assert.Same(monday, RewardDay.For(json, Monday.AddHours(6))); Assert.True(monday.FirstActivityPaid); Assert.Equal(1, monday.ActivityBoxes); // A new day is a new count. var tuesday = RewardDay.For(json, Monday.AddDays(1)); Assert.NotSame(monday, tuesday); Assert.False(tuesday.FirstActivityPaid); Assert.Equal(0, tuesday.ActivityBoxes); } // --------------------------------------------------------------------------------- // What a completion is filed as // --------------------------------------------------------------------------------- [Fact] public async Task TheFirstGameOfTheDayIsFiledAsOneAndTheSecondIsNot() { await using var db = NewDb(); await SeedLastVisitedRoomAsync(db, "RecRoyaleSolos"); var api = AsPlayer(db); var first = await RequestRewardAsync(api, RewardType.FirstActivityOfDay); var second = await RequestRewardAsync(api, RewardType.FirstActivityOfDay); Assert.Equal(RewardType.FirstActivityOfDay, first.RewardType); Assert.Equal(RewardType.PostGameActivity, second.RewardType); } [Fact] public async Task ARewardThatIsNotACompletionStillPaysTokens() { await using var db = NewDb(); var api = AsPlayer(db); var reward = await RequestRewardAsync(api, RewardType.Levelup); Assert.Equal(RewardType.Levelup, reward.RewardType); Assert.All(new[] { reward.GiftDrop1, reward.GiftDrop2, reward.GiftDrop3 }, box => Assert.Equal(CurrencyType.RecCenterTokens, box.CurrencyType)); // The boxes are titled with the amount they hold rather than all being "Rec Center Tokens", // which made a 100 and a 300 read as the same reward twice. Assert.Equal("100 Tokens", reward.GiftDrop1.FriendlyName); Assert.Equal("200 Tokens", reward.GiftDrop2.FriendlyName); Assert.Equal("300 Tokens", reward.GiftDrop3.FriendlyName); } // --------------------------------------------------------------------------------- // The day's boxes: one first of the day, twelve games, one daily challenge // --------------------------------------------------------------------------------- /// /// A completion that pays no box, because the day's are spent. The route still answers success: /// the client asked for a game reward and the request was accepted, the player is simply not /// owed another box today. /// private static async Task RequestRewardWithNoBoxAsync(GameRewardsAPI api, RewardType type) { var before = (await api.GetPendingGameRewards()).Count; var body = Encoding.UTF8.GetBytes($"{{\"rewardType\":{(int)type}}}"); api.ControllerContext.HttpContext.Request.Body = new MemoryStream(body); api.ControllerContext.HttpContext.Request.ContentType = "application/json"; var result = await api.RequestReward(); Assert.True(Assert.IsType(((OkObjectResult)result.Result!).Value).Success); Assert.Equal(before, (await api.GetPendingGameRewards()).Count); } [Fact] public async Task TheDayStopsPayingAfterItsFirstBoxAndTwelveGames() { await using var db = NewDb(); await SeedLastVisitedRoomAsync(db, "RecRoyaleSolos"); var api = AsPlayer(db); var first = await RequestRewardAsync(api, RewardType.FirstActivityOfDay); Assert.Equal(RewardType.FirstActivityOfDay, first.RewardType); for (var game = 0; game < RewardDay.ActivityBoxesPerDay; game++) { var box = await RequestRewardAsync(api, RewardType.FirstActivityOfDay); Assert.Equal(RewardType.PostGameActivity, box.RewardType); } // Thirteen boxes: the first of the day and the twelve games. Assert.Equal(RewardDay.BoxesPerDay - 1, (await api.GetPendingGameRewards()).Count); // The fourteenth completion of the day is answered, and pays nothing. await RequestRewardWithNoBoxAsync(api, RewardType.FirstActivityOfDay); } [Fact] public async Task TheFirstGameOfTheDayIsNotPaidAgainAfterARestart() { await using var db = NewDb(); await SeedLastVisitedRoomAsync(db, "RecRoyaleSolos"); // A second API over the same progression is what a server restart looks like from here. The // day used to be counted in memory, so the first completion after a restart was the first of // the day again and the player was handed that box a second time. var beforeRestart = await RequestRewardAsync(AsPlayer(db), RewardType.FirstActivityOfDay); Assert.Equal(RewardType.FirstActivityOfDay, beforeRestart.RewardType); Assert.Equal("First Game of the Day", beforeRestart.Message); var afterRestart = await RequestRewardAsync(AsPlayer(db), RewardType.FirstActivityOfDay); Assert.Equal(RewardType.PostGameActivity, afterRestart.RewardType); // And the title is the server's. The client sends "First Game of the Day" as the message on // every completion, which is why the screen still read that on the day's fifth box. Assert.Equal("Game Reward", afterRestart.Message); } [Fact] public async Task TheDailyChallengePaysOneBoxADay() { await using var db = NewDb(); var api = AsPlayer(db); var reward = await RequestRewardAsync(api, RewardType.DailyChallengesComplete); Assert.Equal(RewardType.DailyChallengesComplete, reward.RewardType); // The daily challenge is a box of its own, filed under the context the client's own enum // gives it: it is not a game and it names no room. Assert.Equal(GiftContext.Daily_Challenge_Complete, reward.GiftContext); // And one a day - the same completion reported again pays nothing. await RequestRewardWithNoBoxAsync(api, RewardType.DailyChallengesComplete); } // --------------------------------------------------------------------------------- // Which activity the reward is answered from // --------------------------------------------------------------------------------- /// /// The reward request arrives after the player has left the game: a finished Stunt Runner run's /// request came in with the player already back in the Rec Center, so the last visited room was /// the Rec Center, which pays nothing, and every completion was paid the token package. The /// context the client claims is what survives the player moving on, and it is what decides the /// activity when the room they last visited does not pay it. /// [Fact] public async Task TheClaimedContextDecidesTheActivityWhenTheLastRoomIsNotOne() { await using var db = NewDb(); await SeedLastVisitedRoomAsync(db, "RecCenter"); var api = AsPlayer(db); var box = await RequestAndTakeQuestBoxAsync(api, db, GiftContext.StuntRunner_TheMainEvent_C); // Before the claim was consulted the Rec Center named the activity, which is none, and the // reward was the token package filed under GameRewards_Tokens. A quest asks the player to // choose nothing, so what the completion pays is the run's own box - not a selection. Assert.Equal(GiftContext.StuntRunner_TheMainEvent_C, box.Context); Assert.NotEqual(GiftContext.GameRewards_Tokens, box.Context); } [Fact] public async Task ALasertagRewardIsPaidTicketsEvenWhenTheLastRoomIsNotOne() { await using var db = NewDb(); await SeedLastVisitedRoomAsync(db, "RecCenter"); var api = AsPlayer(db); // Tickets are the one reward that needs no catalogue to tell apart from the token package, // so this is what shows the claim took over the room without depending on the shipped items. var reward = await RequestRewardAsync(api, RewardType.PostGameActivity, GiftContext.Lasertag); Assert.All(new[] { reward.GiftDrop1, reward.GiftDrop2, reward.GiftDrop3 }, box => Assert.Equal(CurrencyType.LaserTagTickets, box.CurrencyType)); Assert.All(new[] { reward.GiftDrop1, reward.GiftDrop2, reward.GiftDrop3 }, box => Assert.Equal("Laser Tag Tickets", box.FriendlyName)); } [Fact] public async Task AClaimTheServerDoesNotKnowStillLeavesTheRoomInCharge() { await using var db = NewDb(); await SeedLastVisitedRoomAsync(db, "RecCenter"); var api = AsPlayer(db); var reward = await RequestRewardAsync(api, RewardType.PostGameActivity, GiftContext.Default); // A claim that names no activity leaves the room in charge, and the room pays nothing, so the // reward is the token package - filed under the claim it was asked for, which names no game. Assert.Null(RroRewardPools.RoomForContext(reward.GiftContext)); Assert.All(new[] { reward.GiftDrop1, reward.GiftDrop2, reward.GiftDrop3 }, box => Assert.Equal(CurrencyType.RecCenterTokens, box.CurrencyType)); // Each box is titled with its own amount, not all with "Rec Center Tokens". Assert.Equal("50 Tokens", reward.GiftDrop1.FriendlyName); Assert.Equal("100 Tokens", reward.GiftDrop2.FriendlyName); Assert.Equal("150 Tokens", reward.GiftDrop3.FriendlyName); } /// /// The quest's item is what the whole collection flow leaves in the player's inventory. /// /// This is the half that was broken. The selection was right and so was the box it held, and then /// the item became a box of Rec Center tokens at the moment it was collected, because the collect /// - a generate naming the reward's context, exactly as the client sends it - asked the server to /// invent a gift instead of handing over the one the reward was holding. The last assertion is the /// one that matters: the quest's own descriptor and name, in the player's own unlocks. /// [Fact] public async Task AQuestsItemIsWhatTheGenerateAndConsumeFlowLeavesInTheInventory() { await using var db = NewTransactionalDb(); var item = new Gift { GiftDropId = 6104, FriendlyName = "Runner Harness (Green)", Tooltip = "Stunt Runner: The Main Event", AvatarItemDesc = "d2d3264b-fbf6-40b5-9f10-c85ec737d112,1ba65dd5-9df9-4ff3-be96-f83a7abf67b3", CurrencyType = CurrencyType.Invalid, Currency = 0, Rarity = Rarity.Uncommon, Context = GiftContext.StuntRunner_TheMainEvent_C }; var reward = new GameReward { RewardSelectionId = 8877, Message = "Stunt Runner: The Main Event", GiftContext = GiftContext.StuntRunner_TheMainEvent_C, RewardType = RewardType.PostQuestActivity, GiftDrop1 = item, GiftDrop2 = item, GiftDrop3 = item, CreatedAt = DateTime.UtcNow }; var json = new ProgressionJson { PendingGameRewards = new List { reward } }; db.Progression.Add(new ProgressionData { PlayerId = PlayerId, Progress = json.ToJson() }); await db.SaveChangesAsync(); // The collect the client sends: a generate naming the reward's context, then a consume of the // box that answered it. Neither names an item; both have to end up naming this one. var generated = Assert.IsType(Assert.IsType( await AsAvatarV2(db, $"MFPELEJLJLD={(int)GiftContext.StuntRunner_TheMainEvent_C}&DPAJDIEBNKM=Stunt+Runner") .GenerateGift()).Value); Assert.Equal("Runner Harness (Green)", generated.FriendlyName); Assert.Equal(item.AvatarItemDesc, generated.AvatarItemDesc); Assert.Equal(CurrencyType.Invalid, generated.CurrencyType); Assert.Equal(0, generated.Currency); var consume = AsAvatarV2(db, $"{{\"giftDropId\":{generated.GiftDropId}}}"); Assert.IsType(await consume.ConsumeGiftBody()); var unlocks = await db.Unlocks.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == PlayerId); var unlocked = Assert.Single(unlocks!.GetAvatarItems(), x => x.AvatarItemDesc == item.AvatarItemDesc); Assert.Equal("Runner Harness (Green)", unlocked.FriendlyName); } private static AvatarV2 AsAvatarV2(RecDBContext db, string body) { var api = new AvatarV2(db) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity( [new Claim(ClaimTypes.NameIdentifier, PlayerId.ToString())], "test")) } } }; api.ControllerContext.HttpContext.Request.ContentType = "application/json"; api.ControllerContext.HttpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body)); api.ControllerContext.HttpContext.Request.ContentLength = api.ControllerContext.HttpContext.Request.Body.Length; return api; } /// /// Records a room visit at a point in time, which is what says which game was played and when. /// private static async Task SeedVisitAsync(RecDBContext db, string roomName, DateTime visitedAt) { var room = await db.Rooms.FirstOrDefaultAsync(x => x.Name == roomName); if (room == null) { room = new RoomData { Name = roomName }; db.Rooms.Add(room); await db.SaveChangesAsync(); } db.RoomInteractions.Add(new PlayerInteractionDB { PlayerId = PlayerId, RoomId = room.RoomId, Visited = true, LastVisted = visitedAt, }); await db.SaveChangesAsync(); } /// /// What the log showed: the reward request for a finished game arrives with the player already /// back in the Rec Center and a body that names no activity at all. The room they walked into is /// the newest visit and pays nothing, so the activity has to come from the newest visit that /// does - the game they were actually placed into. /// [Fact] public async Task ACompletionThatNamesNoActivityIsAnsweredFromTheNewestActivityThePlayerWasPlacedInto() { await using var db = NewDb(); var now = DateTime.Now; await SeedVisitAsync(db, "Dodgeball", now.AddMinutes(-5)); await SeedVisitAsync(db, "RecCenter", now.AddSeconds(-5)); var api = AsPlayer(db); var reward = await RequestRewardAsync(api, RewardType.PostGameActivity); // Before the room history was consulted the Rec Center named the activity, which is none, // and the reward was the token package filed under GameRewards_Tokens. Assert.Equal(GiftContext.Dodgeball, reward.GiftContext); Assert.NotEqual(GiftContext.GameRewards_Tokens, reward.GiftContext); } /// /// The claim outranks the room history: a client that names the activity is believed, so the game /// played most recently does not answer for the one the completion belongs to. /// [Fact] public async Task TheClaimedActivityOutranksTheNewestOneThePlayerWasPlacedInto() { await using var db = NewDb(); var now = DateTime.Now; await SeedVisitAsync(db, "Dodgeball", now.AddMinutes(-30)); await SeedVisitAsync(db, "RecCenter", now.AddSeconds(-5)); var api = AsPlayer(db); var box = await RequestAndTakeQuestBoxAsync(api, db, GiftContext.StuntRunner_TheMainEvent_C); // The quest the claim names, not the game played most recently: a Stunt Runner run's box is // filed under Stunt Runner even though a Dodgeball game is what the room history remembers. Assert.Equal(GiftContext.StuntRunner_TheMainEvent_C, box.Context); } /// /// Only recent visits count: a game played hours ago must not answer for a game the server cannot /// place, or yesterday's activity pays for today's reward. /// [Fact] public async Task AStaleActivityDoesNotAnswerForACompletionThatNamesNothing() { await using var db = NewDb(); var now = DateTime.Now; await SeedVisitAsync(db, "Dodgeball", now.AddHours(-8)); await SeedVisitAsync(db, "RecCenter", now.AddSeconds(-5)); var api = AsPlayer(db); var reward = await RequestRewardAsync(api, RewardType.PostGameActivity); Assert.Equal(GiftContext.GameRewards_Tokens, reward.GiftContext); } // --------------------------------------------------------------------------------- // What is not a completion // --------------------------------------------------------------------------------- [Fact] public async Task ALevelUpRequestIsNotPaidTheGameCompletedXp() { await using var db = NewDb(); // A player of its own, so this account starts with no XP. var api = AsPlayer(db, 9201); var reward = await RequestRewardAsync(api, RewardType.Levelup); Assert.Equal(RewardType.Levelup, reward.RewardType); var progression = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == 9201); Assert.Equal(0, progression?.XP ?? 0); } [Fact] public async Task ACompletionIsPaidTheGameCompletedXp() { await using var db = NewDb(); var api = AsPlayer(db, 9202); // No room visited, so the reward is the token package - and it is still a completion, so it // is still paid the game-completed XP. await RequestRewardAsync(api, RewardType.PostGameActivity); var progression = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == 9202); Assert.Equal(ProgressionAwardService.XpPerGameCompleted, progression?.XP ?? 0); } [Fact] public async Task ALevelUpPackageIsFiledUnderItsOwnContext() { await using var db = NewDb(); var api = AsPlayer(db, 9203); // A bonus is not a game: the game context the client sent with it does not become the context // of the boxes, which is what the client paints and groups a gift by. var reward = await RequestRewardAsync(api, RewardType.Levelup, GiftContext.StuntRunner_TheMainEvent_C); Assert.Equal(GiftContext.LevelUp, reward.GiftContext); Assert.All(new[] { reward.GiftDrop1, reward.GiftDrop2, reward.GiftDrop3 }, box => Assert.Equal(GiftContext.LevelUp, box.Context)); } // --------------------------------------------------------------------------------- // Choosing, and choosing again // --------------------------------------------------------------------------------- [Fact] public async Task ChoosingHandsOverAGiftBoxAndRetryingIsNotAFailure() { await using var db = NewDb(); const long selectionId = 280843441; const long itemDropId = 2045372488; var item = new Gift { GiftDropId = itemDropId, FriendlyName = "Archer Hat", AvatarItemDesc = "Archer Hat (Tan)", Context = GiftContext.Quest_Goblin_C, CurrencyType = CurrencyType.Invalid }; db.Progression.Add(new ProgressionData { PlayerId = PlayerId, Progress = new ProgressionJson { PendingGameRewards = [ new GameReward { RewardSelectionId = selectionId, RewardType = RewardType.PostGameActivity, GiftContext = GiftContext.Quest_Goblin_C, GiftDrop1 = item, GiftDrop2 = item, GiftDrop3 = item } ] }.ToJson() }); await db.SaveChangesAsync(); var api = AsSelecting(db, selectionId, itemDropId); var first = await api.SelectReward(); Assert.True(Assert.IsType(((OkObjectResult)first.Result!).Value).Success); // Handed over, not credited here: what the player opens is the box, and the drop keeps the id // the client already knows because that is the id its own consume call names. var boxes = await StorefrontShared.GetPendingGiftsAsync(db, PlayerId); var box = Assert.Single(boxes); Assert.Equal(itemDropId, box.GiftDropId); Assert.Equal("Archer Hat (Tan)", box.AvatarItemDesc); Assert.Empty(await api.GetPendingGameRewards()); // The retry the client makes after a slow answer: the same success, and no second box. var retryApi = AsSelecting(db, selectionId, itemDropId); var retry = await retryApi.SelectReward(); Assert.True(Assert.IsType(((OkObjectResult)retry.Result!).Value).Success); Assert.Single(await StorefrontShared.GetPendingGiftsAsync(db, PlayerId)); var persisted = (await db.Progression.SingleAsync()).GetProgressionJson()!; var completed = Assert.Single(persisted.PendingGameRewards); Assert.True(completed.Consumed); Assert.Equal(itemDropId, completed.ConsumedGiftDropId); } [Fact] public async Task AnUnrecognisedBoxIsStillTheRewardAndSaysSo() { await using var db = NewDb(); const long selectionId = 99; var item = new Gift { GiftDropId = 5, FriendlyName = "Knight Cape", AvatarItemDesc = "Knight Cape (Blue)" }; db.Progression.Add(new ProgressionData { PlayerId = PlayerId, Progress = new ProgressionJson { PendingGameRewards = [new GameReward { RewardSelectionId = selectionId, GiftDrop1 = item }] }.ToJson() }); await db.SaveChangesAsync(); var api = AsSelecting(db, selectionId, giftDropId: 123456); var result = await api.SelectReward(); Assert.True(Assert.IsType(((OkObjectResult)result.Result!).Value).Success); Assert.Equal(5, Assert.Single(await StorefrontShared.GetPendingGiftsAsync(db, PlayerId)).GiftDropId); } }