using System.Text.Json; using RadicadeServer.RecNet.Avatar; using RadicadeServer.RecNet.Enums.Store; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; /// /// The level gift boxes for levels 1-50: a consumable at 1/3/5/7/9, and clothing of a stated star /// rating everywhere else, ending on 5-star at 50. What goes in the box comes from the curated /// pool, and never repeats something the player already has. /// public class LevelGiftBoxTests { // The specification, written out independently of the implementation. private static readonly int[] ConsumableLevels = [1, 3, 5, 7, 9]; private static readonly int[] TwoStarLevels = [2, 4, 6, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 25]; private static readonly int[] ThreeStarLevels = [22, 24, 26, 27, 28, 29, 30, 32, 33, 34, 35, 36, 37, 38, 39]; private static readonly int[] FourStarLevels = [31, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49]; private const int FiveStarLevel = 50; [Fact] public void TheTableCoversEveryLevelFromOneToFiftyExactlyOnce() { var covered = ConsumableLevels .Concat(TwoStarLevels) .Concat(ThreeStarLevels) .Concat(FourStarLevels) .Append(FiveStarLevel) .OrderBy(x => x) .ToList(); Assert.Equal(Enumerable.Range(1, 50), covered); Assert.Equal(50, covered.Distinct().Count()); Assert.True(LevelRewardTable.IsItemLevel(1)); Assert.True(LevelRewardTable.IsItemLevel(50)); Assert.False(LevelRewardTable.IsItemLevel(0)); Assert.False(LevelRewardTable.IsItemLevel(51)); } [Fact] public void ConsumableLevelsAskForAConsumableAndEverythingElseAsksForClothing() { for (var level = 1; level <= 50; level++) { var expected = ConsumableLevels.Contains(level) ? LevelRewardTable.RewardKind.Consumable : LevelRewardTable.RewardKind.Clothing; Assert.Equal(expected, LevelRewardTable.KindForLevel(level)); } // Above the table (and below it) nothing is granted. The progression map's token drop used // to fill these, which is the "Level Up Reward" box a level set past 50 produced. Assert.Equal(LevelRewardTable.RewardKind.None, LevelRewardTable.KindForLevel(51)); Assert.Equal(LevelRewardTable.RewardKind.None, LevelRewardTable.KindForLevel(0)); } [Fact] public void TheStarRatingOfEachClothingLevelMatchesTheSpecification() { foreach (var level in TwoStarLevels) Assert.Equal(Rarity.Uncommon, LevelRewardTable.RarityForLevel(level)); foreach (var level in ThreeStarLevels) Assert.Equal(Rarity.Rare, LevelRewardTable.RarityForLevel(level)); foreach (var level in FourStarLevels) Assert.Equal(Rarity.Epic, LevelRewardTable.RarityForLevel(level)); Assert.Equal(Rarity.Legendary, LevelRewardTable.RarityForLevel(FiveStarLevel)); } // ------------------------------------------------------------------------------------- // The boxes themselves // ------------------------------------------------------------------------------------- private static string AvatarCatalogueJson() { var items = new List(); var id = 1; foreach (var rarity in new[] { Rarity.Uncommon, Rarity.Rare, Rarity.Epic, Rarity.Legendary }) { for (var i = 0; i < 4; i++) { items.Add(new AvatarItem { AvatarItemId = id++, AvatarItemDesc = $"{rarity}-item-{i}", FriendlyName = $"{rarity} Item {i}", Rarity = (int)rarity, AvatarItemType = 0, IsBaseAvatarItem = false }); } } return JsonSerializer.Serialize(items); } 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.Common }, }); /// /// A pool that names everything in the synthetic catalogues, so these tests exercise the reward /// table rather than the shipped allow-list (which the pool tests cover separately). /// private static LevelUpRewardPool.Pool PoolFor(string avatarsJson, string consumablesJson) { var avatars = JsonSerializer.Deserialize>(avatarsJson) ?? new List(); var consumables = JsonSerializer.Deserialize>(consumablesJson) ?? new List(); return new LevelUpRewardPool.Pool { AvatarItems = avatars.Select(x => x.AvatarItemDesc).ToList(), Consumables = consumables.Select(x => x.ConsumableItemDesc).ToList() }; } private static Gift? Build(int level, string avatars, string consumables, ISet? owned = null, int seed = 1) => LevelUpGiftService.BuildItemReward( level, avatars, consumables, owned: owned, random: new Random(seed), pool: PoolFor(avatars, consumables)); [Fact] public void EveryLevelOneToFiftyBuildsABoxWithTheSpecifiedItem() { var avatars = AvatarCatalogueJson(); var consumables = ConsumableCatalogueJson(); for (var level = 1; level <= 50; level++) { var gift = Build(level, avatars, consumables); Assert.NotNull(gift); Assert.Contains($"Level {level} Reward", gift!.FriendlyName); if (ConsumableLevels.Contains(level)) { Assert.False(string.IsNullOrWhiteSpace(gift.ConsumableItemDesc)); Assert.True(string.IsNullOrWhiteSpace(gift.AvatarItemDesc), $"level {level} should hold only a consumable"); } else { Assert.False(string.IsNullOrWhiteSpace(gift.AvatarItemDesc)); Assert.True(string.IsNullOrWhiteSpace(gift.ConsumableItemDesc), $"level {level} should hold only clothing"); Assert.Equal(LevelRewardTable.RarityForLevel(level), gift.Rarity); } // A level-up box names its own item; it is not a storefront drop and has no price. Assert.Equal(0, gift.Currency); Assert.Equal(0, gift.Xp); } } [Fact] public void EveryBoxCarriesTheCongratsMessageForItsLevel() { var avatars = AvatarCatalogueJson(); var consumables = ConsumableCatalogueJson(); for (var level = 1; level <= 50; level++) { var gift = Build(level, avatars, consumables)!; // Message is the box's own text (Gift.Message maps onto Tooltip), and it should name // the level the player just reached - not describe the item inside. Assert.Equal($"Congrats on getting to Level {level}!", gift.Message); } } [Fact] public void PlayersAtTheSameLevelDoNotAllReceiveTheSameItem() { var avatars = AvatarCatalogueJson(); var consumables = ConsumableCatalogueJson(); // Four 2-star items exist. The old selection was "candidate[(level - 1) % count]", which // handed every player the same item for a given level; the pool must spread them out. var picks = new HashSet(StringComparer.OrdinalIgnoreCase); for (var seed = 0; seed < 32; seed++) picks.Add(Build(2, avatars, consumables, seed: seed)!.AvatarItemDesc); Assert.True(picks.Count > 1, "every player was handed the same level-2 item"); Assert.All(picks, desc => Assert.StartsWith("Uncommon-item-", desc)); } [Fact] public void AnItemThePlayerAlreadyOwnsIsNotHandedOutAgain() { var avatars = AvatarCatalogueJson(); var consumables = ConsumableCatalogueJson(); // Own every 2-star item but one. var owned = new HashSet(StringComparer.OrdinalIgnoreCase) { "Uncommon-item-0", "Uncommon-item-1", "Uncommon-item-2" }; for (var seed = 0; seed < 8; seed++) { var gift = Build(2, avatars, consumables, owned, seed)!; Assert.Equal("Uncommon-item-3", gift.AvatarItemDesc); } } [Fact] public void AnExhaustedTierFallsBackToAConsumableRatherThanToAForbiddenItem() { var avatars = AvatarCatalogueJson(); var consumables = ConsumableCatalogueJson(); var owned = new HashSet(StringComparer.OrdinalIgnoreCase) { "Uncommon-item-0", "Uncommon-item-1", "Uncommon-item-2", "Uncommon-item-3" }; var gift = Build(2, avatars, consumables, owned)!; Assert.True(string.IsNullOrWhiteSpace(gift.AvatarItemDesc)); Assert.False(string.IsNullOrWhiteSpace(gift.ConsumableItemDesc)); Assert.Equal("Congrats on getting to Level 2!", gift.Message); } [Fact] public void AConsumableLevelWithNothingLeftGrantsNoBox() { var avatars = AvatarCatalogueJson(); var consumables = ConsumableCatalogueJson(); var owned = new HashSet(StringComparer.OrdinalIgnoreCase) { "consumable-a", "consumable-b" }; Assert.Null(Build(1, avatars, consumables, owned)); } [Fact] public void AnEmptyCatalogueYieldsNoEmptyBox() { // If the catalogue cannot supply the item, the box must not be silently empty; when even the // consumable fallback has nothing, the level simply gets no box - and it is never replaced // with the old token drop. Assert.Null(Build(4, "[]", "[]")); Assert.Null(Build(1, AvatarCatalogueJson(), "[]")); // Outside the table there is no item box at all. Assert.Null(Build(50 + 1, AvatarCatalogueJson(), ConsumableCatalogueJson())); // A clothing level with no clothing at all is served by the consumable fallback. var fallback = Build(2, "[]", ConsumableCatalogueJson())!; Assert.True(string.IsNullOrWhiteSpace(fallback.AvatarItemDesc)); Assert.False(string.IsNullOrWhiteSpace(fallback.ConsumableItemDesc)); } [Fact] public void ALevelUpBoxIsNeverFilledWithAShirtItShouldNotHandOut() { var avatars = JsonSerializer.Serialize(new List { new() { AvatarItemId = 1, AvatarItemDesc = "exclusive-item", FriendlyName = "Top Hat (Gold)", Rarity = (int)Rarity.Uncommon, IsBaseAvatarItem = false }, new() { AvatarItemId = 2, AvatarItemDesc = "ordinary-item", FriendlyName = "Plain Tee", Rarity = (int)Rarity.Uncommon, IsBaseAvatarItem = false }, }); var consumables = ConsumableCatalogueJson(); // The allow-list names both, so only the deny-list stands between the player and the // exclusive top hat - which is exactly the case it has to cover. var pool = new LevelUpRewardPool.Pool { AvatarItems = new List { "exclusive-item", "ordinary-item" }, Consumables = new List { "consumable-a", "consumable-b" } }; for (var seed = 0; seed < 16; seed++) { var gift = LevelUpGiftService.BuildItemReward( 2, avatars, consumables, random: new Random(seed), pool: pool)!; Assert.Equal("ordinary-item", gift.AvatarItemDesc); } } [Fact] public void AnItemThePoolDoesNotNameIsNeverAwarded() { var avatars = JsonSerializer.Serialize(new List { new() { AvatarItemId = 1, AvatarItemDesc = "listed-item", FriendlyName = "Listed Hat", Rarity = (int)Rarity.Uncommon, IsBaseAvatarItem = false }, new() { AvatarItemId = 2, AvatarItemDesc = "unlisted-item", FriendlyName = "Unlisted Hat", Rarity = (int)Rarity.Uncommon, IsBaseAvatarItem = false }, }); var consumables = ConsumableCatalogueJson(); var pool = new LevelUpRewardPool.Pool { AvatarItems = new List { "listed-item" }, Consumables = new List { "consumable-a" } }; // The unlisted item is never picked even though it is a perfectly ordinary 2-star hat. for (var seed = 0; seed < 8; seed++) { var gift = LevelUpGiftService.BuildItemReward( 2, avatars, consumables, random: new Random(seed), pool: pool)!; Assert.Equal("listed-item", gift.AvatarItemDesc); } // And once the only listed item is owned, the level falls back to a consumable rather than // reaching for the unlisted one. var owned = new HashSet(StringComparer.OrdinalIgnoreCase) { "listed-item" }; var fallback = LevelUpGiftService.BuildItemReward( 2, avatars, consumables, owned, new Random(1), pool)!; Assert.True(string.IsNullOrWhiteSpace(fallback.AvatarItemDesc)); Assert.False(string.IsNullOrWhiteSpace(fallback.ConsumableItemDesc)); } [Fact] public void TwoLevelsInOnePassNeverHandOutTheSameItem() { var avatars = AvatarCatalogueJson(); var consumables = ConsumableCatalogueJson(); // Levels 2 and 4 are both 2-star. Delivered together they still have to differ, which the // delivered-so-far set inside BuildItemReward/pass wiring is responsible for. for (var seed = 0; seed < 16; seed++) { var pool = PoolFor(avatars, consumables); var random = new Random(seed); var owned = new HashSet(StringComparer.OrdinalIgnoreCase); var two = LevelUpGiftService.BuildItemReward(2, avatars, consumables, owned, random, pool)!; owned.Add(LevelUpRewardPool.OwnershipKey(two.AvatarItemDesc)); var four = LevelUpGiftService.BuildItemReward(4, avatars, consumables, owned, random, pool)!; Assert.NotEqual(two.AvatarItemDesc, four.AvatarItemDesc); } } }