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

342 lines
14 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 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.
/// </summary>
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<AvatarItem>();
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<ConsumableItem>
{
new() { ConsumableItemDesc = "consumable-a", FriendlyName = "Consumable A", Rarity = Rarity.Common },
new() { ConsumableItemDesc = "consumable-b", FriendlyName = "Consumable B", Rarity = Rarity.Common },
});
/// <summary>
/// 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).
/// </summary>
private static LevelUpRewardPool.Pool PoolFor(string avatarsJson, string consumablesJson)
{
var avatars = JsonSerializer.Deserialize<List<AvatarItem>>(avatarsJson) ?? new List<AvatarItem>();
var consumables = JsonSerializer.Deserialize<List<ConsumableItem>>(consumablesJson) ?? new List<ConsumableItem>();
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<string>? 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<string>(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<string>(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<string>(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<string>(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<AvatarItem>
{
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<string> { "exclusive-item", "ordinary-item" },
Consumables = new List<string> { "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<AvatarItem>
{
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<string> { "listed-item" },
Consumables = new List<string> { "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<string>(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<string>(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);
}
}
}