using System.Security.Claims; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Avatars.V2; using RadicadeServer.API.Avatars.V3; using RadicadeServer.API.Storefront; using RadicadeServer.DB; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Avatar; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Enums.Store; using RadicadeServer.RecNet.Progression; using RadicadeServer.RecNet.Storefront; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; public class AvatarGiftGenerateTests { private const long PlayerId = 6812; [Theory] [InlineData(false)] [InlineData(true)] public async Task GenerateGiftAcceptsTheEconFormContractAndPersistsAnOpenableGift(bool useV3) { var options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; await using var db = new RecDBContext(options); Controller controller = useV3 ? new AvatarV3(db) : new AvatarV2(db); SetUserAndFormBody(controller, $"MFPELEJLJLD={(int)GiftContext.Game_Drop}&CIEHFJEIOJJ={(int)GiftContext.GameRewards_Tokens}&HNKBFLPDLOC=true&DPAJDIEBNKM=earned+in+game&Xp=17"); var result = Assert.IsType(await (useV3 ? ((AvatarV3)controller).GenerateGift() : ((AvatarV2)controller).GenerateGift())); var gift = Assert.IsType(result.Value); Assert.True(gift.GiftDropId > 0); Assert.Equal(PlayerId, gift.FromPlayerId); Assert.Equal(GiftContext.Game_Drop, gift.Context); Assert.Equal("earned in game", gift.Tooltip); Assert.Equal(Rarity.Rare, gift.Rarity); Assert.Equal(17, gift.Xp); Assert.InRange(gift.Currency, 10, 500); var pending = await StorefrontShared.GetPendingGiftsAsync(db, PlayerId); var stored = Assert.Single(pending); Assert.Equal(gift.GiftDropId, stored.GiftDropId); Assert.Equal(gift.GiftDropId, stored.ForClient().Id); Assert.Equal(gift.Currency, stored.Currency); Assert.Equal(gift.Rarity, stored.Rarity); } [Fact] public async Task GenerateGiftAcceptsFriendlyJsonAndRejectsUnknownContexts() { var options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; await using var db = new RecDBContext(options); var api = new AvatarV2(db); SetUserAndJsonBody(api, "{\"GiftContext\":2,\"AlternateGiftContext\":51,\"IsGameGift\":false,\"Message\":\"daily reward\",\"Xp\":9}"); var result = Assert.IsType(await api.GenerateGift()); var gift = Assert.IsType(result.Value); Assert.Equal(GiftContext.Game_Drop, gift.Context); Assert.Equal("daily reward", gift.Tooltip); Assert.Equal(9, gift.Xp); Assert.Single(await StorefrontShared.GetPendingGiftsAsync(db, PlayerId)); var invalid = new AvatarV2(db); SetUserAndFormBody(invalid, "MFPELEJLJLD=999999&DPAJDIEBNKM=bad"); Assert.IsType(await invalid.GenerateGift()); Assert.Single(await StorefrontShared.GetPendingGiftsAsync(db, PlayerId)); } /// /// A gift box holding an item becomes that item, and it keeps its name. /// /// The unlock used to be written from the descriptor alone - new AvatarItem { AvatarItemDesc } /// - so everything else about the item was missing: the wardrobe listed a nameless, id-less, /// thumbnail-less entry. It read as a copy of the item rather than the item, because that is what /// it was. The gift's own name and rarity are what an unlock this server can resolve has to /// carry, and the catalogue's own record is used where the catalogue has one (see the test /// below). /// [Fact] public async Task AGrantedItemUnlockKeepsTheNameTheRewardWasGivenUnder() { await using var db = new RecDBContext(TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); var gift = new Gift { GiftDropId = 4711, FriendlyName = "Bandana (Flowers, Black)", Tooltip = "First Game of the Day", AvatarItemDesc = "9ff1f0ff-a0d6-4b3c-9b7c-6c0e5a1b2c3d,44d2f8a1-0000-0000-0000-000000000000", Rarity = Rarity.Uncommon, CurrencyType = CurrencyType.Invalid }; await StorefrontShared.GrantGiftAsync(db, PlayerId, gift); await db.SaveChangesAsync(); var unlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == PlayerId); var item = Assert.Single(unlocks!.GetAvatarItems(), x => x.AvatarItemDesc == gift.AvatarItemDesc); Assert.Equal("Bandana (Flowers, Black)", item.FriendlyName); Assert.Equal("First Game of the Day", item.Tooltip); Assert.Equal((int)Rarity.Uncommon, item.Rarity); } /// /// An item the shipped catalogue carries is granted as the catalogue's own record: the name, the /// item id, the type and the thumbnail the client draws it from, not just the descriptor. This is /// what "the ACTUAL item instead of a cloned item" is. /// [Fact] public void TheCatalogueResolvesAGrantedItemToItsOwnRecord() { var catalogue = JsonSerializer.Serialize(new List { new() { AvatarItemId = 55, AvatarItemDesc = "abc123,def456", FriendlyName = "Bandana (Flowers, Black)", Tooltip = "Bandana (Flowers, Black)", Rarity = 20, AvatarItemType = 1, ThumbnailImage = "thumb.png", CreatedAt = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc) } }); var item = AvatarUnlockCatalog.FindItem("abc123,def456", catalogue); Assert.NotNull(item); Assert.Equal("abc123,def456", item!.AvatarItemDesc); Assert.Equal("Bandana (Flowers, Black)", item.FriendlyName); Assert.Equal(55, item.AvatarItemId); Assert.Equal(20, item.Rarity); Assert.Equal(1, item.AvatarItemType); Assert.Equal("thumb.png", item.ThumbnailImage); // A descriptor the catalogue does not carry resolves to nothing, so the grant path falls back // to the gift's own name rather than writing a nameless unlock. Assert.Null(AvatarUnlockCatalog.FindItem("not-in-the-catalogue", catalogue)); } /// /// A generate for a reward the player is still holding hands over THAT item, not a token box. /// /// The client collects a finished activity through this route and never names the item - the /// request carries the reward's context and nothing else - so the item is whatever the reward /// selection is holding. Generating a fresh random token box here is why a completed Stunt /// Runner run or quest paid tokens while its selection screen showed the right items. /// [Fact] public async Task GenerateGiftHandsOverTheItemTheOutstandingRewardIsHolding() { await using var db = new RecDBContext(TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); var item = new Gift { GiftDropId = 9001, FriendlyName = "Archer Hat (Tan)", Tooltip = "Archer Hat (Tan)", AvatarItemDesc = "d2d3264b-fbf6-40b5-9f10-c85ec737d112,9b35ae52-febe-486a-8557-ec7190a9756e", CurrencyType = CurrencyType.Invalid, Currency = 0, Rarity = Rarity.Uncommon, Context = GiftContext.Quest_Goblin_C }; var reward = new GameReward { RewardSelectionId = 4242, Message = "First Game of the Day", GiftContext = GiftContext.Quest_Goblin_C, RewardType = RewardType.PostGameActivity, GiftDrop1 = item, GiftDrop2 = item, GiftDrop3 = item, CreatedAt = new DateTime(2026, 10, 5, 0, 0, 0, DateTimeKind.Utc) }; var json = new ProgressionJson { PendingGameRewards = new List { reward } }; db.Progression.Add(new ProgressionData { PlayerId = PlayerId, Progress = json.ToJson() }); await db.SaveChangesAsync(); var api = new AvatarV2(db); SetUserAndFormBody(api, $"MFPELEJLJLD={(int)GiftContext.Quest_Goblin_C}&DPAJDIEBNKM=First+Game+of+the+Day"); var result = Assert.IsType(await api.GenerateGift()); var gift = Assert.IsType(result.Value); // The item the selection was showing, not a package of Rec Center tokens. Assert.Equal("Archer Hat (Tan)", gift.FriendlyName); Assert.Equal(item.AvatarItemDesc, gift.AvatarItemDesc); Assert.Equal(CurrencyType.Invalid, gift.CurrencyType); Assert.Equal(0, gift.Currency); Assert.Equal(GiftContext.Quest_Goblin_C, gift.Context); // The collection is taken, not copied: the reward is completed and remembers the box. var stored = await db.Progression.AsNoTracking().FirstAsync(x => x.PlayerId == PlayerId); var saved = Assert.Single(stored.GetProgressionJson()!.PendingGameRewards); Assert.True(saved.Consumed); Assert.Equal(gift.GiftDropId, saved.ConsumedGiftDropId); // And it is a pending box, so the consume the client sends next is what unlocks the item. var box = Assert.Single(await StorefrontShared.GetPendingGiftsAsync(db, PlayerId)); Assert.Equal(gift.GiftDropId, box.GiftDropId); Assert.Equal(item.AvatarItemDesc, box.AvatarItemDesc); } /// /// A generate for a reward the selection route already filed as a gift box hands THAT box back. /// /// This is the shape the client actually collects a quest with: the selection route files the /// chosen box as a pending gift box, and opening the watch asks for the gift its context names /// rather than naming the box. The reward itself is completed by that selection, so a lookup that /// only considers *outstanding* rewards finds nothing - and the collection was answered with a /// fresh box of tokens while the player's own item sat pending a step away. /// [Fact] public async Task GenerateGiftHandsBackTheBoxTheSelectionAlreadyFiled() { await using var db = NewDb(); var box = RunnerBox(); await SeedCollectedRewardAsync(db, box); await StorefrontShared.PersistGiftBoxAsync(db, PlayerId, box, notify: false); var api = new AvatarV2(db); SetUserAndFormBody(api, $"MFPELEJLJLD={(int)GiftContext.StuntRunner_TheMainEvent_C}"); var gift = Assert.IsType(Assert.IsType(await api.GenerateGift()).Value); Assert.Equal("Runner Harness (Green)", gift.FriendlyName); Assert.Equal(box.AvatarItemDesc, gift.AvatarItemDesc); Assert.Equal(CurrencyType.Invalid, gift.CurrencyType); Assert.Equal(0, gift.Currency); Assert.Equal(GiftContext.StuntRunner_TheMainEvent_C, gift.Context); } /// /// The reward for an activity context is drawn from that activity's own pool, as an item. /// /// This is the choice the generate route has to make when nothing is on file for the context the /// client named - the box of the run it has just finished. It is drawn from the pool that context /// names and filtered by what the player already owns, and it is never a package of Rec Center /// tokens: the tokens are what every completed game and quest used to be paid here. /// [Fact] public async Task BuildRoomRewardSelectsAnUnownedItemFromTheRoomsOwnPool() { await using var db = NewDb(); var catalogue = ShippedCatalogueJson(); var box = await GameRewardRedemption.BuildRoomRewardAsync( db, PlayerId, GiftContext.StuntRunner_TheMainEvent_C, catalogueJson: catalogue); Assert.NotNull(box); Assert.Equal(GiftContext.StuntRunner_TheMainEvent_C, box!.Context); Assert.Equal(CurrencyType.Invalid, box.CurrencyType); Assert.Equal(0, box.Currency); Assert.True(box.GiftDropId > 0); Assert.Contains(RroRewardPools.Resolve(GiftContext.StuntRunner_TheMainEvent_C, catalogue), item => item.AvatarItemDesc == box.AvatarItemDesc); // And the box the run generated is recorded as handed over, so the reward request that // follows it cannot pay the same run a second time. var stored = await db.Progression.AsNoTracking().FirstAsync(x => x.PlayerId == PlayerId); var reward = Assert.Single(stored.GetProgressionJson()!.PendingGameRewards); Assert.True(reward.Consumed); Assert.Equal(box.GiftDropId, reward.ConsumedGiftDropId); Assert.Equal(GiftContext.StuntRunner_TheMainEvent_C, reward.GiftContext); } /// /// A rank the player has taken everything from falls back inside the same quest, then out of it. /// /// A quest never pays tokens and never reaches into another quest's pool: Stunt Runner S walks /// down its own ranks, and only once the whole quest is spent does the reward leave it for the /// ordinary non-exclusive pool. /// [Fact] public async Task BuildRoomRewardFallsBackInsideTheSameQuestOnly() { await using var db = NewDb(); var catalogue = ShippedCatalogueJson(); // The player owns everything Stunt Runner S pays. var s = RroRewardPools.Resolve(GiftContext.StuntRunner_TheMainEvent_S, catalogue); Assert.NotEmpty(s); await OwnEverythingAsync(db, s); var box = await GameRewardRedemption.BuildRoomRewardAsync( db, PlayerId, GiftContext.StuntRunner_TheMainEvent_S, catalogueJson: catalogue); Assert.NotNull(box); // A lower rank of the same quest - the box keeps the context it was drawn from, and that // context belongs to Stunt Runner. Assert.NotEqual(GiftContext.StuntRunner_TheMainEvent_S, box!.Context); Assert.Equal("StuntRunner", RroRewardPools.RoomForContext(box.Context)); Assert.Contains(RroRewardPools.LowerRanksOf(GiftContext.StuntRunner_TheMainEvent_S), c => c == box.Context); } /// /// A generate for a finished activity is answered with a gift, never with an error. /// /// The client uses whatever this route returns as the box the player opens, so a 400 here is a /// failed generate on the client - "Failed to generate new gift: An error occurred" - and a reward /// the player never collects. Whether the box holds an item or the ordinary fallback, it is a box. /// [Fact] public async Task GenerateGiftNeverAnswersAnActivityBoxWithAnError() { await using var db = NewDb(); await SeedCollectedRewardAsync(db, RunnerBox()); var api = new AvatarV2(db); SetUserAndFormBody(api, $"MFPELEJLJLD={(int)GiftContext.StuntRunner_TheMainEvent_C}"); var gift = Assert.IsType(Assert.IsType(await api.GenerateGift()).Value); Assert.Equal(GiftContext.StuntRunner_TheMainEvent_C, gift.Context); Assert.True(gift.GiftDropId > 0); // It is filed as the player's box, so opening it is what puts the reward in the locker, and // the hand-over is recorded against it. var box = Assert.Single(await StorefrontShared.GetPendingGiftsAsync(db, PlayerId)); Assert.Equal(gift.GiftDropId, box.GiftDropId); var stored = await db.Progression.AsNoTracking().FirstAsync(x => x.PlayerId == PlayerId); Assert.Contains(stored.GetProgressionJson()!.PendingGameRewards, r => r.Consumed && r.ConsumedGiftDropId == gift.GiftDropId); } private static async Task OwnEverythingAsync(RecDBContext db, IEnumerable items) { var owned = items.Select(item => new AvatarItem { AvatarItemDesc = item.AvatarItemDesc }).ToList(); db.Unlocks.Add(new PlayerUnlocks { AccountId = PlayerId, AvatarItems = owned.ToJson() }); await db.SaveChangesAsync(); } private static string ShippedCatalogueJson() => File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "2022-avatar.json")); private static RecDBContext NewDb() => new(TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); private static Gift RunnerBox() => new() { GiftDropId = 7331, FriendlyName = "Runner Harness (Green)", Tooltip = "Runner Harness (Green)", AvatarItemDesc = "1b3f0c6e-9b21-4c77-9d5a-2f0a53b3d1a4,bb31f2e0-0f2f-4d4a-9c3e-6a0d6b1f4c88", CurrencyType = CurrencyType.Invalid, Currency = 0, Rarity = Rarity.Uncommon, Context = GiftContext.StuntRunner_TheMainEvent_C }; private static async Task SeedCollectedRewardAsync(RecDBContext db, Gift box) { var reward = new GameReward { RewardSelectionId = 5150, Message = "Stunt Runner: The Main Event", GiftContext = GiftContext.StuntRunner_TheMainEvent_C, RewardType = RewardType.PostQuestActivity, GiftDrop1 = box, GiftDrop2 = box, GiftDrop3 = box, CreatedAt = DateTime.UtcNow.AddMinutes(-5), Consumed = true, ConsumedGiftDropId = box.GiftDropId, ConsumedAt = DateTime.UtcNow.AddMinutes(-1) }; var json = new ProgressionJson { PendingGameRewards = new List { reward } }; db.Progression.Add(new ProgressionData { PlayerId = PlayerId, Progress = json.ToJson() }); await db.SaveChangesAsync(); } private static void SetUserAndFormBody(Controller controller, string body) { SetRequest(controller, "application/x-www-form-urlencoded", body); } private static void SetUserAndJsonBody(Controller controller, string body) { SetRequest(controller, "application/json", body); } private static void SetRequest(Controller controller, string contentType, string body) { var identity = new ClaimsIdentity("TestAuth"); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, PlayerId.ToString())); var context = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }; context.Request.ContentType = contentType; context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body)); context.Request.ContentLength = context.Request.Body.Length; controller.ControllerContext = new ControllerContext { HttpContext = context }; } }