Files
Radicade/ServerTests/LevelUpRewardPoolTests.cs
T
2026-10-16 23:33:13 -07:00

175 lines
8.1 KiB
C#

using System.Text.Json;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.RecNet.Enums.Store;
using RadicadeServer.Utils;
using Xunit;
namespace RadicadeServer.Tests;
/// <summary>
/// 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.
/// </summary>
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<AvatarItem>
{
new() { AvatarItemDesc = "x", FriendlyName = "Hat", Rarity = (int)Rarity.Uncommon }
};
Assert.Null(LevelUpRewardPool.PickAvatarItem(catalogue, Rarity.Uncommon, new HashSet<string>(), new Random(1), pool));
}
// -------------------------------------------------------------------------------------
// The shipped artefacts
// -------------------------------------------------------------------------------------
/// <summary>
/// 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).
/// </summary>
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<string> AvatarItems { get; set; } = new();
public List<string> 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<PoolFile>(
File.ReadAllText(Path.Join(config, LevelUpRewardPool.ConfigFileName)),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
var catalogue = JsonSerializer.Deserialize<List<AvatarItem>>(File.ReadAllText(Path.Join(config, "2022-avatar.json")))!;
var resolvable = new HashSet<string>(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<string>();
var missing = new List<string>();
var notResolvable = new List<string>();
var wrongTier = new List<string>();
var baseItems = new List<string>();
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<PoolFile>(
File.ReadAllText(Path.Join(config, LevelUpRewardPool.ConfigFileName)),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
var catalogue = JsonSerializer.Deserialize<List<AvatarItem>>(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<string>(), new Random(1), pool);
Assert.NotNull(pick);
Assert.Equal((int)rarity, pick!.Rarity);
}
var consumable = LevelUpRewardPool.PickConsumable(
JsonSerializer.Deserialize<List<ConsumableItem>>(
File.ReadAllText(Path.Join(config, "consumables.json")))!,
new HashSet<string>(), new Random(1), pool);
Assert.NotNull(consumable);
}
}