using System.Text.Json; using RadicadeServer.RecNet.Avatar; using Xunit; namespace RadicadeServer.Tests; /// /// Guards the shipped equipment catalog (RadicadeServer-Config/equipment.json). /// /// That file is what every new account's equipment list is built from, and it reached this repo /// in bad shape: 268 rows carrying 192 distinct prefab/guid pairs, 92 rows whose FriendlyName was /// the bare prefab ("[PaintballAssaultRifle]") because the name never resolved, three guids /// assigned to two different prefabs, and one guid that was a character too long. None of it /// raised an error - the client just listed unnamed entries, and a skin attached to the wrong /// prefab can never be applied - so the invariants are pinned here instead. /// /// The current file is a dump of the 2023 client's own equipment list: 271 rows over the same 37 /// prefabs, every row named, every guid well formed and belonging to exactly one prefab. /// public class EquipmentCatalogTests { private static string Fixture(string name) => Path.Combine(AppContext.BaseDirectory, "Fixtures", name); private static List ShippedCatalog() { var path = Fixture("equipment.json"); Assert.True(File.Exists(path), $"missing fixture {path}"); var rows = JsonSerializer.Deserialize>(File.ReadAllText(path)); Assert.NotNull(rows); return rows!; } private static string Pair(Equipment row) => $"{row.PrefabName}\u001f{row.ModificationGuid}"; [Fact] public void ShippedCatalogCarriesEveryRowItDeclaresAndNoneOfThemTwice() { var rows = ShippedCatalog(); // A floor rather than an exact count: skins are data and will be added, but silently // losing a chunk of the catalog is the failure worth catching. Assert.True(rows.Count >= 271, $"equipment.json shrank to {rows.Count} rows"); var duplicates = rows .GroupBy(Pair, StringComparer.OrdinalIgnoreCase) .Where(group => group.Count() > 1) .Select(group => $"{group.Key} x{group.Count()}") .ToList(); // Service merges by pair, so a repeated row never reached the client - it just made the // file lie about its own size. The catalog it replaced repeated 76 of them. Assert.Empty(duplicates); } [Fact] public void EveryShippedGuidIsAWholeGuid() { var bad = ShippedCatalog() .Where(row => !IsGuid(row.ModificationGuid)) .Select(row => $"{row.PrefabName} {row.ModificationGuid}") .ToList(); Assert.Empty(bad); } /// /// A RecNet modification guid is either the dashed 36-character form or the 22-character /// base64url form the client emits. Anything else is a truncated or padded guid, which is how /// the catalog this replaced carried "[Crossbow] ESQ0qOlgrkCc8MbUXGnF8Ag" - one character /// longer than the real guid for that skin, so the Plaid crossbow could never resolve. /// private static bool IsGuid(string? guid) { if (guid == null) return false; if (guid.Length == 36) return Guid.TryParseExact(guid, "D", out _); if (guid.Length != 22) return false; return guid.All(c => char.IsAsciiLetterOrDigit(c) || c == '-' || c == '_'); } [Fact] public void EveryShippedGuidBelongsToOnePrefab() { var spanning = ShippedCatalog() .GroupBy(row => row.ModificationGuid, StringComparer.OrdinalIgnoreCase) .Where(group => group.Select(row => row.PrefabName).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1) .Select(group => $"{group.Key} -> {string.Join(", ", group.Select(row => row.PrefabName).Distinct())}") .ToList(); // A skin is the pair, so a guid that names two prefabs means one of the rows is wrong and // that skin is attached to a tool the client will never look it up under. Assert.Empty(spanning); } [Fact] public void EveryShippedRowHasARealName() { var unnamed = ShippedCatalog() .Where(row => string.IsNullOrWhiteSpace(row.FriendlyName) || row.FriendlyName.StartsWith('[')) .Select(row => $"{row.PrefabName} {row.ModificationGuid}") .ToList(); // A bare prefab as the name is what the equipment list renders verbatim, which is why // 92 rows used to read "[QuestSword]" instead of "Sword (Extra)". Assert.Empty(unnamed); } [Fact] public void EveryShippedRowIsAResolvableSkin() { var rows = ShippedCatalog(); foreach (var row in rows) { // Bracketed, not necessarily ending in one: "[PaintballGun] Confetti" is a real // prefab of its own. Assert.StartsWith("[", row.PrefabName); Assert.Contains("]", row.PrefabName); Assert.False(string.IsNullOrWhiteSpace(row.ModificationGuid)); Assert.NotNull(row.Tooltip); } // The prefab half has to be one of the client's own tool prefabs, so a typo like the // "[Dodgeball]", "[SciFi_Grenade]" and "[PaintBallRifleScoped]" rows the old catalog // carried shows up here rather than as a skin the player can own but never wear. Pinned // as a set on purpose: a genuinely new tool prefab should be a deliberate addition, and // the count alone would not notice one typo replacing another. var prefabs = rows.Select(row => row.PrefabName).Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(name => name, StringComparer.OrdinalIgnoreCase).ToList(); Assert.Equal(ShippedPrefabs.OrderBy(name => name, StringComparer.OrdinalIgnoreCase), prefabs); } /// The 37 tool prefabs the 2023 client ships skins for. private static readonly string[] ShippedPrefabs = { "[Arena_Grenade]", "[Basketball]", "[BowlingBall]", "[Bucket]", "[Crossbow]", "[Crossbow_Hunter]", "[DiscGolfDisc]", "[DodgeballBall]", "[Grenade]", "[Longbow]", "[MakerPen]", "[PaintballAssaultRifle]", "[PaintballGrenadeLauncher]", "[PaintballGun]", "[PaintballGun] Confetti", "[PaintballRifleScoped]", "[PaintballShield]", "[PaintballShotgun]", "[Paintball_PaintThrower]", "[QuestShield]", "[QuestSword]", "[Quest_Goblin_Wand]", "[Quest_SciFi_AutomaticGun]", "[Quest_SciFi_Pistol]", "[Quest_SciFi_RailGun]", "[Quest_SciFi_Shotgun]", "[RecCenter_Cafe_Mug]", "[RecRoyale_Backpack]", "[Sandbox_D10]", "[Sandbox_D12]", "[Sandbox_D20]", "[Sandbox_D4]", "[Sandbox_D6]", "[Sandbox_D8]", "[ShareCamera]", "[SoccerShield]", "[Vehicle_RallyBuggy]" }; [Fact] public void TheServedCatalogKeepsEveryRowTheFileDeclares() { // What the equipment endpoint actually hands a player: the file merged with the DorkNet // skin catalog, keyed by pair exactly as EquipmentV2 does it. var equipmentPath = Fixture("equipment.json"); var skinsPath = Fixture("equipment_skins.json"); if (!File.Exists(skinsPath)) return; var merged = JsonSerializer.Deserialize>( EquipmentSkinCatalog.MergeInto(File.ReadAllText(equipmentPath), File.ReadAllText(skinsPath)))!; var served = merged .Where(row => !string.IsNullOrWhiteSpace(row.PrefabName)) .GroupBy(Pair, StringComparer.OrdinalIgnoreCase) .ToList(); Assert.Equal(merged.Count, served.Count); Assert.True(served.Count >= 271); // And the pairs the file names are the pairs the endpoint serves - no row is lost to the // merge, and no merge-added row displaces one. var declared = ShippedCatalog().Select(Pair).ToList(); var servedPairs = served.Select(group => group.Key).ToList(); Assert.All(declared, pair => Assert.Contains(pair, servedPairs, StringComparer.OrdinalIgnoreCase)); } }