using System.Text.Json; using RadicadeServer.RecNet.Avatar; using RadicadeServer.RecNet.Enums.Store; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; /// /// The curated level-up pool: the deny-list that must never be overridden, and the shipped /// allow-list that decides what the box may contain. /// public class LevelUpRewardPoolTests { [Theory] // The brief's named exclusions, as they appear in the 2023 catalogue. [InlineData("White Hat", "A hat given to the ethical hackers of Rec Room", null)] // bug bounty / security [InlineData("Top Hat (Gold)", "", null)] // payment milestone [InlineData("Rec Con Hoodie (2021)", "", null)] // convention reward [InlineData("Winged Headphones (Purple)", "RecCon 2021 exclusive", null)] [InlineData("Early Access Tee", "", null)] // launch exclusive [InlineData("Laser Tag Alpha Shirt", "", null)] // alpha reward [InlineData("MrBeast T-Shirt", "", null)] // licensed collaboration [InlineData("Space Visor (Orion)", "Unlocked during Xbox launch", null)] [InlineData("Royal Cape (Creators Contest 2)", "", null)] // contest reward [InlineData("SFVRCC Promo Shirt", "", null)] // promotional [InlineData("Notes Headphones (Gold)", "", null)] // payment milestone [InlineData("Class of 2020 Shirt", "", null)] // educational programme [InlineData("New Years Hat Hat", "", null)] // event dressing [InlineData("Plain Tee", "", "halloween")] // event tag alone [InlineData("Plain Tee", "", null, true)] // control: this one is fine public void TheDenyListRefusesTheItemsTheBriefNames(string name, string tooltip, string? tag, bool allowed = false) { var denied = LevelUpRewardPool.IsAlwaysDenied(name, tooltip, tag); Assert.Equal(!allowed, denied); } [Fact] public void TheDenyListIsCaseInsensitiveAndChecksTheTooltipAsWellAsTheName() { Assert.True(LevelUpRewardPool.IsAlwaysDenied("Halloween Sweater", "")); Assert.True(LevelUpRewardPool.IsAlwaysDenied("Sweater", "HALLOWEEN SWEATER")); Assert.False(LevelUpRewardPool.IsAlwaysDenied("Stripey Sweater", "A cosy sweater")); } [Fact] public void OwnershipKeysMatchRegardlessOfSpacingOrCase() { Assert.Equal( LevelUpRewardPool.OwnershipKey("AAAA-BBBB,CCCC,"), LevelUpRewardPool.OwnershipKey("aaaa-bbbb,cccc,")); } [Fact] public void AnEmptyPoolAwardsNothingButDoesNotThrow() { var pool = new LevelUpRewardPool.Pool(); var catalogue = new List { new() { AvatarItemDesc = "x", FriendlyName = "Hat", Rarity = (int)Rarity.Uncommon } }; Assert.Null(LevelUpRewardPool.PickAvatarItem(catalogue, Rarity.Uncommon, new HashSet(), new Random(1), pool)); } // ------------------------------------------------------------------------------------- // The shipped artefacts // ------------------------------------------------------------------------------------- /// /// Walks up from the test's own output directory to the repository, so this can check the real /// files rather than a copy. Returns null when the tree is not there (a published test run). /// private static string? RepositoryRoot() { var directory = new DirectoryInfo(AppContext.BaseDirectory); while (directory != null) { if (File.Exists(Path.Join(directory.FullName, "Server", "RadicadeServer-Config", LevelUpRewardPool.ConfigFileName))) return directory.FullName; directory = directory.Parent; } return null; } private sealed class PoolFile { public List AvatarItems { get; set; } = new(); public List Consumables { get; set; } = new(); } [Fact] public void TheShippedPoolNamesOnlyOrdinaryCatalogueItemsFromThe2023Client() { var root = RepositoryRoot(); if (root == null) return; var config = Path.Join(root, "Server", "RadicadeServer-Config"); var pool = JsonSerializer.Deserialize( File.ReadAllText(Path.Join(config, LevelUpRewardPool.ConfigFileName)), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; var catalogue = JsonSerializer.Deserialize>(File.ReadAllText(Path.Join(config, "2022-avatar.json")))!; var resolvable = new HashSet(StringComparer.OrdinalIgnoreCase); using (var lookup = JsonDocument.Parse(File.ReadAllText(Path.Join(config, "avatar-item-lookup.json")))) foreach (var description in lookup.RootElement.GetProperty("descriptions").EnumerateArray()) resolvable.Add(description.GetString() ?? ""); var byDesc = catalogue .Where(item => !string.IsNullOrWhiteSpace(item.AvatarItemDesc)) .GroupBy(item => item.AvatarItemDesc, StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); Assert.NotEmpty(pool.AvatarItems); Assert.NotEmpty(pool.Consumables); var denied = new List(); var missing = new List(); var notResolvable = new List(); var wrongTier = new List(); var baseItems = new List(); foreach (var desc in pool.AvatarItems) { if (!byDesc.TryGetValue(desc, out var item)) { missing.Add(desc); continue; } if (LevelUpRewardPool.IsAlwaysDenied(item)) denied.Add($"{item.FriendlyName} ({desc})"); if (!resolvable.Contains(desc)) notResolvable.Add($"{item.FriendlyName} ({desc})"); if (item.Rarity is not ((int)Rarity.Uncommon or (int)Rarity.Rare or (int)Rarity.Epic or (int)Rarity.Legendary)) wrongTier.Add($"{item.FriendlyName} rarity={item.Rarity}"); if (item.IsBaseAvatarItem) baseItems.Add(item.FriendlyName); } // Each of these is a way the generated list could have gone wrong, and each is a hard // failure rather than a warning: the pool decides what a real player receives. Assert.Empty(denied); Assert.Empty(missing); Assert.Empty(notResolvable); Assert.Empty(wrongTier); Assert.Empty(baseItems); } [Fact] public void EveryStarTierHasSomethingToGive() { var root = RepositoryRoot(); if (root == null) return; var config = Path.Join(root, "Server", "RadicadeServer-Config"); var file = JsonSerializer.Deserialize( File.ReadAllText(Path.Join(config, LevelUpRewardPool.ConfigFileName)), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; var catalogue = JsonSerializer.Deserialize>(File.ReadAllText(Path.Join(config, "2022-avatar.json")))!; // Selection is driven by the pool as an object here rather than through the file loader, // which would need the process's own working directory pointed at the repository. var pool = new LevelUpRewardPool.Pool { AvatarItems = file.AvatarItems, Consumables = file.Consumables }; foreach (var rarity in new[] { Rarity.Uncommon, Rarity.Rare, Rarity.Epic, Rarity.Legendary }) { var pick = LevelUpRewardPool.PickAvatarItem(catalogue, rarity, new HashSet(), new Random(1), pool); Assert.NotNull(pick); Assert.Equal((int)rarity, pick!.Rarity); } var consumable = LevelUpRewardPool.PickConsumable( JsonSerializer.Deserialize>( File.ReadAllText(Path.Join(config, "consumables.json")))!, new HashSet(), new Random(1), pool); Assert.NotNull(consumable); } }