using System.Text.Json; using RadicadeServer.RecNet.Avatar; using RadicadeServer.RecNet.Enums.Players.Avatar; using RadicadeServer.RecNet.Enums.Store; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; /// /// What a completion pays once the player owns everything the activity hands out. The reward can no /// longer be a first-time one, so it becomes a reacquisition: a selection of ordinary items for a /// map game, a non-rare consumable for a quest. /// public class RroReacquisitionTests { private static string ShippedCatalogueJson() => File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "2022-avatar.json")); private static string ConsumableCatalogueJson() => JsonSerializer.Serialize(new List { new() { ConsumableItemDesc = "consumable-a", FriendlyName = "Consumable A", Rarity = Rarity.Common }, new() { ConsumableItemDesc = "consumable-b", FriendlyName = "Consumable B", Rarity = Rarity.Uncommon }, }); private static LevelUpRewardPool.Pool ConsumablePool() => new() { Consumables = ["consumable-a", "consumable-b"] }; // --------------------------------------------------------------------------------- // The repeat is filed under its own context // --------------------------------------------------------------------------------- [Fact] public void ARepeatIsFiledUnderTheReacquisitionContext() { // Not under the activity's own context: the client has to be able to tell a duplicate from // a first-time reward. Assert.Equal(20, (int)RroRewardPools.ReacquisitionContext); Assert.Equal(GiftContext.Reacquisition, RroRewardPools.ReacquisitionContext); Assert.False(RroRewardPools.IsDeprecated(RroRewardPools.ReacquisitionContext)); } // --------------------------------------------------------------------------------- // A map game offers a selection of ordinary items // --------------------------------------------------------------------------------- [Fact] public void AMapGameOffersASelectionOfOrdinaryItems() { var drops = RroRewardPools.BuildFallbackDrops( selection: true, owned: null, random: new Random(4), catalogueJson: ShippedCatalogueJson()); Assert.Equal(3, drops.Count); Assert.Equal(3, drops.Select(x => x.AvatarItemDesc).Distinct(StringComparer.Ordinal).Count()); Assert.All(drops, drop => Assert.Equal(GiftContext.Reacquisition, drop.Context)); Assert.All(drops, drop => Assert.Equal(20, drop.GiftContext)); Assert.All(drops, drop => Assert.Contains(drop.Rarity, new[] { Rarity.Uncommon, Rarity.Rare })); } [Fact] public void TheOrdinaryPoolIsTheTwoLowestRaritiesAndNothingDenied() { var json = ShippedCatalogueJson(); var pool = RroRewardPools.ReacquisitionPool(json); // The shipped scale is 2/3/4/5 stars as rarity 10/20/30/50; the ordinary tiers are the two // lowest, which is a few hundred items rather than the whole catalogue. Assert.True(pool.Count > 300, $"only {pool.Count} ordinary items"); Assert.All(pool, item => Assert.Contains(item.Rarity, new[] { 10, 20 })); Assert.All(pool, item => Assert.False(LevelUpRewardPool.IsAlwaysDenied(item))); Assert.All(pool, item => Assert.False(string.IsNullOrWhiteSpace(item.AvatarItemDesc))); Assert.All(pool, item => Assert.False(item.IsBaseAvatarItem)); } [Fact] public void TheGenericPoolIsNotAnotherActivitysRewardList() { // A dodgeball clear must not offer a Crimson Cauldron piece as a consolation prize: nothing // from any reward set belongs in the generic draw. var json = ShippedCatalogueJson(); var pool = RroRewardPools.ReacquisitionPool(json); Assert.All(pool, item => Assert.False(RroRewardPools.IsRewardSetItem(item), $"{item.FriendlyName} is a reward-set item and should not be offered generically")); Assert.All(pool, item => Assert.DoesNotContain("Witch Hunter", item.FriendlyName, StringComparison.OrdinalIgnoreCase)); Assert.All(pool, item => Assert.DoesNotContain("Angler", item.FriendlyName, StringComparison.OrdinalIgnoreCase)); Assert.All(pool, item => Assert.DoesNotContain("Knight", item.FriendlyName, StringComparison.OrdinalIgnoreCase)); // And the ordinary items a player actually saw are still eligible. Assert.Contains(pool, item => item.FriendlyName == "Turtleneck (White)"); Assert.Contains(pool, item => item.FriendlyName == "Bike Gloves (Blue)"); } [Fact] public void TheFilterCatchesRewardItemsThePoolsDoNot() { // The pools only name the colours a rank pays. A colour they do not name is still that // activity's item and must not leak into a generic reward either. Assert.True(RroRewardPools.IsRewardSetItem(new AvatarItem { FriendlyName = "Stunt Singlet (Pink)" })); Assert.True(RroRewardPools.IsRewardSetItem(new AvatarItem { FriendlyName = "Wizard Robes (Green)" })); Assert.True(RroRewardPools.IsRewardSetItem(new AvatarItem { FriendlyName = "Runner Harness (Orange)" })); Assert.True(RroRewardPools.IsRewardSetItem(new AvatarItem { FriendlyName = "Angler Hat (Gray)" })); Assert.False(RroRewardPools.IsRewardSetItem(new AvatarItem { FriendlyName = "Turtleneck (White)" })); Assert.False(RroRewardPools.IsRewardSetItem(new AvatarItem { FriendlyName = "Bike Gloves (Blue)" })); } [Fact] public void AnItemBoxCarriesNoCurrencyType() { var json = ShippedCatalogueJson(); var quest = RroRewardPools.BuildGift(GiftContext.Quest_Goblin_A, null, new Random(1), json); Assert.NotNull(quest); Assert.Equal(CurrencyType.Invalid, quest!.CurrencyType); var reacquired = RroRewardPools.BuildFallbackDrops(true, null, new Random(1), json); Assert.All(reacquired, drop => Assert.Equal(CurrencyType.Invalid, drop.CurrencyType)); Assert.All(reacquired, drop => Assert.Equal(0, drop.Currency)); } [Fact] public void TheSelectionOpensUpTheCatalogueBeforeItRepeats() { // The point of preferring what is unowned: a player who has finished Golden Trophy should be // offered something they have never had, not one of the two pieces they already own. var json = ShippedCatalogueJson(); var pool = RroRewardPools.ReacquisitionPool(json); var keep = pool.Take(2).ToList(); var owned = pool.Skip(2) .Select(x => LevelUpRewardPool.OwnershipKey(x.AvatarItemDesc)) .ToHashSet(StringComparer.OrdinalIgnoreCase); var drops = RroRewardPools.BuildFallbackDrops(true, owned, new Random(6), json); Assert.Equal(2, drops.Count); var expected = keep.Select(x => x.AvatarItemDesc).ToHashSet(StringComparer.Ordinal); Assert.All(drops, drop => Assert.Contains(drop.AvatarItemDesc, expected)); } [Fact] public void APlayerWhoOwnsEverythingIsStillOfferedASelection() { // A repeat has to terminate: once the catalogue itself is owned, the draw repeats rather // than leaving the player with nothing. var json = ShippedCatalogueJson(); var owned = RroRewardPools.ReacquisitionPool(json) .Select(x => LevelUpRewardPool.OwnershipKey(x.AvatarItemDesc)) .ToHashSet(StringComparer.OrdinalIgnoreCase); var drops = RroRewardPools.BuildFallbackDrops(true, owned, new Random(8), json); Assert.Equal(3, drops.Count); Assert.Equal(3, drops.Select(x => x.AvatarItemDesc).Distinct(StringComparer.Ordinal).Count()); } // --------------------------------------------------------------------------------- // A quest is handed a consumable instead // --------------------------------------------------------------------------------- [Fact] public void AQuestIsHandedANonRareConsumableInsteadOfAChoice() { var drops = RroRewardPools.BuildFallbackDrops( selection: false, owned: null, random: new Random(2), catalogueJson: ShippedCatalogueJson(), consumableCatalogJson: ConsumableCatalogueJson(), consumablePool: ConsumablePool()); Assert.Single(drops); Assert.False(string.IsNullOrWhiteSpace(drops[0].ConsumableItemDesc)); Assert.True(string.IsNullOrWhiteSpace(drops[0].AvatarItemDesc)); Assert.Equal(GiftContext.Reacquisition, drops[0].Context); Assert.Equal(0, drops[0].Currency); Assert.Equal(0, drops[0].Xp); } [Fact] public void TheConsumablePrefersOneThePlayerDoesNotHave() { var owned = new HashSet(StringComparer.OrdinalIgnoreCase) { LevelUpRewardPool.OwnershipKey(ConsumableCatalog.ToClientDesc("consumable-a")) }; var gift = RroRewardPools.BuildFallbackConsumable( owned, new Random(3), ConsumableCatalogueJson(), ConsumablePool()); Assert.NotNull(gift); Assert.Equal(ConsumableCatalog.ToClientDesc("consumable-b"), gift!.ConsumableItemDesc); } [Fact] public void AnExhaustedConsumablePoolStillPaysOne() { var owned = new[] { "consumable-a", "consumable-b" } .Select(x => LevelUpRewardPool.OwnershipKey(ConsumableCatalog.ToClientDesc(x))) .ToHashSet(StringComparer.OrdinalIgnoreCase); var gift = RroRewardPools.BuildFallbackConsumable( owned, new Random(3), ConsumableCatalogueJson(), ConsumablePool()); Assert.NotNull(gift); Assert.Equal(GiftContext.Reacquisition, gift!.Context); } [Fact] public void WithNothingToDrawFromTheFallbackIsEmptyRatherThanAWrongItem() { // An empty consumable catalogue leaves the caller to its token fallback. It must not reach // for an ordinary item here: a quest is not a selection. Assert.Null(RroRewardPools.BuildFallbackConsumable(null, new Random(1), "[]", new LevelUpRewardPool.Pool())); Assert.Empty(RroRewardPools.BuildFallbackDrops( selection: false, owned: null, random: new Random(1), consumableCatalogJson: "[]", consumablePool: new LevelUpRewardPool.Pool())); // And a catalogue that cannot satisfy the ordinary tiers yields no selection. Assert.Empty(RroRewardPools.BuildFallbackDrops( selection: true, owned: null, random: new Random(1), catalogueJson: "[]")); } // --------------------------------------------------------------------------------- // A completion whose own pool cannot pay still offers items, with tokens as one of them // --------------------------------------------------------------------------------- [Fact] public void AStuntRunnerCompletionPaysItsOwnItems() { // The reward request for a finished Stunt Runner run arrives after the player has left the // room, so the room the visit was recorded against names the Rec Center rather than the // activity. The context the client claims is what names it, and the consumable the client // files a run with nothing new to give under belongs to the same activity, not to tokens. Assert.Equal("StuntRunner", RroRewardPools.RoomForContext(GiftContext.StuntRunner_TheMainEvent_C)); Assert.Equal("StuntRunner", RroRewardPools.RoomForContext(GiftContext.StuntRunner_TheMainEvent_SS)); Assert.Equal("StuntRunner", RroRewardPools.RoomForContext(GiftContext.StuntRunner_TheMainEvent_Consumable)); Assert.True(RroRewardPools.IsRewardActivity("StuntRunner")); // A quest's pool is served by rank, and a consumable is no rank: the activity's lowest rank // is what answers it. Assert.True(RroRewardPools.TryFloorContext("StuntRunner", out var floor)); Assert.Equal(GiftContext.StuntRunner_TheMainEvent_C, floor); var drops = RroRewardPools.BuildDrops(GiftContext.StuntRunner_TheMainEvent_C, selection: false, owned: null, random: new Random(3), catalogueJson: ShippedCatalogueJson()); var box = Assert.Single(drops); Assert.False(string.IsNullOrWhiteSpace(box.AvatarItemDesc)); // An item is not a token box: the run pays a Stunt Runner item, never the token package. Assert.Equal(CurrencyType.Invalid, box.CurrencyType); Assert.Equal(0, box.Currency); } [Fact] public void ACompletionNothingOwnOfItsOwnCanPayIsNotPaidInTokensAlone() { // Rec Royale's Season 1 rewards came from the Frontier Pass, which is not implemented, so a // completed match used to be handed the token package as its whole reward. var drops = RroRewardPools.BuildFallbackSelection(random: new Random(6), catalogueJson: ShippedCatalogueJson()); Assert.Equal(RroRewardPools.SelectionSize, drops.Count); Assert.Equal(RroRewardPools.SelectionSize, drops.Count(drop => !string.IsNullOrWhiteSpace(drop.AvatarItemDesc))); // An item box is an item: none of them is a token box with an item's name on it. Assert.All(drops, drop => Assert.Equal(0, drop.Currency)); Assert.All(drops, drop => Assert.Equal(CurrencyType.Invalid, drop.CurrencyType)); } /// /// The token box used to be in EVERY fallback selection - a fixed one-in-three chance of being /// handed tokens on a completion that had nothing of its own left to give - which is what "I keep /// getting these" was. It is now the draw, and this is the draw's own budget. /// [Fact] public void TheTokenBoxIsTheExceptionInAFallbackSelection() { var json = ShippedCatalogueJson(); var withTokens = 0; const int draws = 400; for (var seed = 1; seed <= draws; seed++) { var drops = RroRewardPools.BuildFallbackSelection(random: new Random(seed), catalogueJson: json); Assert.Equal(RroRewardPools.SelectionSize, drops.Count); if (drops.Any(drop => drop.CurrencyType == CurrencyType.RecCenterTokens)) withTokens++; } Assert.True(withTokens > 0, "the token option is never offered at all"); Assert.True(withTokens < draws / 4, $"the token option is in {withTokens} of {draws} selections; it is meant to be the exception"); } [Fact] public void TheTokenBoxIsTitledWithTheAmountItHolds() { var tokens = RroRewardPools.TokenGift(GiftContext.GameRewards_Tokens, RroRewardPools.CompletionTokenAmount); // Every token box used to be called "Rec Center Tokens" whatever it carried, so a 50 and a // 150 looked like the same reward twice; the amount is the title now. Assert.Equal("50 Tokens", tokens.FriendlyName); Assert.Equal(50, tokens.Currency); Assert.Equal(CurrencyType.RecCenterTokens, tokens.CurrencyType); Assert.Equal("200 Tokens", RroRewardPools.TokenGift(GiftContext.GameRewards_Tokens, 200).FriendlyName); } [Fact] public void TheTokenBoxFillsTheSlotTheOrdinaryPoolCannot() { // A catalogue with two ordinary items cannot fill a three-box selection, so the slot it // cannot fill is the token option rather than a short selection. var drops = RroRewardPools.BuildFallbackSelection(random: new Random(1), catalogueJson: TwoItemCatalogueJson()); Assert.Equal(RroRewardPools.SelectionSize, drops.Count); var tokens = Assert.Single(drops, drop => drop.CurrencyType == CurrencyType.RecCenterTokens); Assert.Equal(RroRewardPools.CompletionTokenAmount, tokens.Currency); Assert.Equal("50 Tokens", tokens.FriendlyName); Assert.Equal(GiftContext.GameRewards_Tokens, tokens.Context); } [Fact] public void ACatalogueWithNothingOrdinaryStillPaysTheTokenOption() { var drops = RroRewardPools.BuildFallbackSelection(random: new Random(1), catalogueJson: "[]"); // Nothing to offer but tokens, so tokens it is - the caller's own token package is the same // answer, and this only has to be the one box rather than a failure. var tokens = Assert.Single(drops); Assert.Equal(CurrencyType.RecCenterTokens, tokens.CurrencyType); Assert.Equal("50 Tokens", tokens.FriendlyName); } /// Two ordinary items, which is what a selection of three cannot be filled from. private static string TwoItemCatalogueJson() => JsonSerializer.Serialize(new List { new() { AvatarItemDesc = "aa11,bb22", FriendlyName = "Plain Shirt (Grey)", Rarity = 10, AvatarItemId = 1, CreatedAt = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc) }, new() { AvatarItemDesc = "cc33,dd44", FriendlyName = "Plain Hat (Grey)", Rarity = 20, AvatarItemId = 2, CreatedAt = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc) }, }); }