mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 07:29:00 -07:00
342 lines
14 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|