mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
349 lines
17 KiB
C#
349 lines
17 KiB
C#
using System.Text.Json;
|
|
using RadicadeServer.RecNet.Avatar;
|
|
using RadicadeServer.RecNet.Enums.Players.Avatar;
|
|
using RadicadeServer.RecNet.Enums.Store;
|
|
using RadicadeServer.Utils;
|
|
using Xunit;
|
|
|
|
namespace RadicadeServer.Tests;
|
|
|
|
/// <summary>
|
|
/// What a completion pays once the player owns everything the activity hands out. The reward can no
|
|
/// longer be a first-time one, so it becomes a reacquisition: a selection of ordinary items for a
|
|
/// map game, a non-rare consumable for a quest.
|
|
/// </summary>
|
|
public class RroReacquisitionTests
|
|
{
|
|
private static string ShippedCatalogueJson() =>
|
|
File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "2022-avatar.json"));
|
|
|
|
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.Uncommon },
|
|
});
|
|
|
|
private static LevelUpRewardPool.Pool ConsumablePool() => new()
|
|
{
|
|
Consumables = ["consumable-a", "consumable-b"]
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// The repeat is filed under its own context
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void ARepeatIsFiledUnderTheReacquisitionContext()
|
|
{
|
|
// Not under the activity's own context: the client has to be able to tell a duplicate from
|
|
// a first-time reward.
|
|
Assert.Equal(20, (int)RroRewardPools.ReacquisitionContext);
|
|
Assert.Equal(GiftContext.Reacquisition, RroRewardPools.ReacquisitionContext);
|
|
Assert.False(RroRewardPools.IsDeprecated(RroRewardPools.ReacquisitionContext));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// A map game offers a selection of ordinary items
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void AMapGameOffersASelectionOfOrdinaryItems()
|
|
{
|
|
var drops = RroRewardPools.BuildFallbackDrops(
|
|
selection: true, owned: null, random: new Random(4), catalogueJson: ShippedCatalogueJson());
|
|
|
|
Assert.Equal(3, drops.Count);
|
|
Assert.Equal(3, drops.Select(x => x.AvatarItemDesc).Distinct(StringComparer.Ordinal).Count());
|
|
Assert.All(drops, drop => Assert.Equal(GiftContext.Reacquisition, drop.Context));
|
|
Assert.All(drops, drop => Assert.Equal(20, drop.GiftContext));
|
|
Assert.All(drops, drop => Assert.Contains(drop.Rarity, new[] { Rarity.Uncommon, Rarity.Rare }));
|
|
}
|
|
|
|
[Fact]
|
|
public void TheOrdinaryPoolIsTheTwoLowestRaritiesAndNothingDenied()
|
|
{
|
|
var json = ShippedCatalogueJson();
|
|
var pool = RroRewardPools.ReacquisitionPool(json);
|
|
|
|
// The shipped scale is 2/3/4/5 stars as rarity 10/20/30/50; the ordinary tiers are the two
|
|
// lowest, which is a few hundred items rather than the whole catalogue.
|
|
Assert.True(pool.Count > 300, $"only {pool.Count} ordinary items");
|
|
Assert.All(pool, item => Assert.Contains(item.Rarity, new[] { 10, 20 }));
|
|
Assert.All(pool, item => Assert.False(LevelUpRewardPool.IsAlwaysDenied(item)));
|
|
Assert.All(pool, item => Assert.False(string.IsNullOrWhiteSpace(item.AvatarItemDesc)));
|
|
Assert.All(pool, item => Assert.False(item.IsBaseAvatarItem));
|
|
}
|
|
|
|
[Fact]
|
|
public void TheGenericPoolIsNotAnotherActivitysRewardList()
|
|
{
|
|
// A dodgeball clear must not offer a Crimson Cauldron piece as a consolation prize: nothing
|
|
// from any reward set belongs in the generic draw.
|
|
var json = ShippedCatalogueJson();
|
|
var pool = RroRewardPools.ReacquisitionPool(json);
|
|
|
|
Assert.All(pool, item => Assert.False(RroRewardPools.IsRewardSetItem(item),
|
|
$"{item.FriendlyName} is a reward-set item and should not be offered generically"));
|
|
Assert.All(pool, item => Assert.DoesNotContain("Witch Hunter", item.FriendlyName, StringComparison.OrdinalIgnoreCase));
|
|
Assert.All(pool, item => Assert.DoesNotContain("Angler", item.FriendlyName, StringComparison.OrdinalIgnoreCase));
|
|
Assert.All(pool, item => Assert.DoesNotContain("Knight", item.FriendlyName, StringComparison.OrdinalIgnoreCase));
|
|
|
|
// And the ordinary items a player actually saw are still eligible.
|
|
Assert.Contains(pool, item => item.FriendlyName == "Turtleneck (White)");
|
|
Assert.Contains(pool, item => item.FriendlyName == "Bike Gloves (Blue)");
|
|
}
|
|
|
|
[Fact]
|
|
public void TheFilterCatchesRewardItemsThePoolsDoNot()
|
|
{
|
|
// The pools only name the colours a rank pays. A colour they do not name is still that
|
|
// activity's item and must not leak into a generic reward either.
|
|
Assert.True(RroRewardPools.IsRewardSetItem(new AvatarItem { FriendlyName = "Stunt Singlet (Pink)" }));
|
|
Assert.True(RroRewardPools.IsRewardSetItem(new AvatarItem { FriendlyName = "Wizard Robes (Green)" }));
|
|
Assert.True(RroRewardPools.IsRewardSetItem(new AvatarItem { FriendlyName = "Runner Harness (Orange)" }));
|
|
Assert.True(RroRewardPools.IsRewardSetItem(new AvatarItem { FriendlyName = "Angler Hat (Gray)" }));
|
|
|
|
Assert.False(RroRewardPools.IsRewardSetItem(new AvatarItem { FriendlyName = "Turtleneck (White)" }));
|
|
Assert.False(RroRewardPools.IsRewardSetItem(new AvatarItem { FriendlyName = "Bike Gloves (Blue)" }));
|
|
}
|
|
|
|
[Fact]
|
|
public void AnItemBoxCarriesNoCurrencyType()
|
|
{
|
|
var json = ShippedCatalogueJson();
|
|
var quest = RroRewardPools.BuildGift(GiftContext.Quest_Goblin_A, null, new Random(1), json);
|
|
Assert.NotNull(quest);
|
|
Assert.Equal(CurrencyType.Invalid, quest!.CurrencyType);
|
|
|
|
var reacquired = RroRewardPools.BuildFallbackDrops(true, null, new Random(1), json);
|
|
Assert.All(reacquired, drop => Assert.Equal(CurrencyType.Invalid, drop.CurrencyType));
|
|
Assert.All(reacquired, drop => Assert.Equal(0, drop.Currency));
|
|
}
|
|
|
|
[Fact]
|
|
public void TheSelectionOpensUpTheCatalogueBeforeItRepeats()
|
|
{
|
|
// The point of preferring what is unowned: a player who has finished Golden Trophy should be
|
|
// offered something they have never had, not one of the two pieces they already own.
|
|
var json = ShippedCatalogueJson();
|
|
var pool = RroRewardPools.ReacquisitionPool(json);
|
|
var keep = pool.Take(2).ToList();
|
|
var owned = pool.Skip(2)
|
|
.Select(x => LevelUpRewardPool.OwnershipKey(x.AvatarItemDesc))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
var drops = RroRewardPools.BuildFallbackDrops(true, owned, new Random(6), json);
|
|
|
|
Assert.Equal(2, drops.Count);
|
|
var expected = keep.Select(x => x.AvatarItemDesc).ToHashSet(StringComparer.Ordinal);
|
|
Assert.All(drops, drop => Assert.Contains(drop.AvatarItemDesc, expected));
|
|
}
|
|
|
|
[Fact]
|
|
public void APlayerWhoOwnsEverythingIsStillOfferedASelection()
|
|
{
|
|
// A repeat has to terminate: once the catalogue itself is owned, the draw repeats rather
|
|
// than leaving the player with nothing.
|
|
var json = ShippedCatalogueJson();
|
|
var owned = RroRewardPools.ReacquisitionPool(json)
|
|
.Select(x => LevelUpRewardPool.OwnershipKey(x.AvatarItemDesc))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
var drops = RroRewardPools.BuildFallbackDrops(true, owned, new Random(8), json);
|
|
Assert.Equal(3, drops.Count);
|
|
Assert.Equal(3, drops.Select(x => x.AvatarItemDesc).Distinct(StringComparer.Ordinal).Count());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// A quest is handed a consumable instead
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void AQuestIsHandedANonRareConsumableInsteadOfAChoice()
|
|
{
|
|
var drops = RroRewardPools.BuildFallbackDrops(
|
|
selection: false,
|
|
owned: null,
|
|
random: new Random(2),
|
|
catalogueJson: ShippedCatalogueJson(),
|
|
consumableCatalogJson: ConsumableCatalogueJson(),
|
|
consumablePool: ConsumablePool());
|
|
|
|
Assert.Single(drops);
|
|
Assert.False(string.IsNullOrWhiteSpace(drops[0].ConsumableItemDesc));
|
|
Assert.True(string.IsNullOrWhiteSpace(drops[0].AvatarItemDesc));
|
|
Assert.Equal(GiftContext.Reacquisition, drops[0].Context);
|
|
Assert.Equal(0, drops[0].Currency);
|
|
Assert.Equal(0, drops[0].Xp);
|
|
}
|
|
|
|
[Fact]
|
|
public void TheConsumablePrefersOneThePlayerDoesNotHave()
|
|
{
|
|
var owned = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
LevelUpRewardPool.OwnershipKey(ConsumableCatalog.ToClientDesc("consumable-a"))
|
|
};
|
|
|
|
var gift = RroRewardPools.BuildFallbackConsumable(
|
|
owned, new Random(3), ConsumableCatalogueJson(), ConsumablePool());
|
|
|
|
Assert.NotNull(gift);
|
|
Assert.Equal(ConsumableCatalog.ToClientDesc("consumable-b"), gift!.ConsumableItemDesc);
|
|
}
|
|
|
|
[Fact]
|
|
public void AnExhaustedConsumablePoolStillPaysOne()
|
|
{
|
|
var owned = new[] { "consumable-a", "consumable-b" }
|
|
.Select(x => LevelUpRewardPool.OwnershipKey(ConsumableCatalog.ToClientDesc(x)))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
var gift = RroRewardPools.BuildFallbackConsumable(
|
|
owned, new Random(3), ConsumableCatalogueJson(), ConsumablePool());
|
|
|
|
Assert.NotNull(gift);
|
|
Assert.Equal(GiftContext.Reacquisition, gift!.Context);
|
|
}
|
|
|
|
[Fact]
|
|
public void WithNothingToDrawFromTheFallbackIsEmptyRatherThanAWrongItem()
|
|
{
|
|
// An empty consumable catalogue leaves the caller to its token fallback. It must not reach
|
|
// for an ordinary item here: a quest is not a selection.
|
|
Assert.Null(RroRewardPools.BuildFallbackConsumable(null, new Random(1), "[]", new LevelUpRewardPool.Pool()));
|
|
Assert.Empty(RroRewardPools.BuildFallbackDrops(
|
|
selection: false, owned: null, random: new Random(1),
|
|
consumableCatalogJson: "[]", consumablePool: new LevelUpRewardPool.Pool()));
|
|
|
|
// And a catalogue that cannot satisfy the ordinary tiers yields no selection.
|
|
Assert.Empty(RroRewardPools.BuildFallbackDrops(
|
|
selection: true, owned: null, random: new Random(1), catalogueJson: "[]"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// A completion whose own pool cannot pay still offers items, with tokens as one of them
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void AStuntRunnerCompletionPaysItsOwnItems()
|
|
{
|
|
// The reward request for a finished Stunt Runner run arrives after the player has left the
|
|
// room, so the room the visit was recorded against names the Rec Center rather than the
|
|
// activity. The context the client claims is what names it, and the consumable the client
|
|
// files a run with nothing new to give under belongs to the same activity, not to tokens.
|
|
Assert.Equal("StuntRunner", RroRewardPools.RoomForContext(GiftContext.StuntRunner_TheMainEvent_C));
|
|
Assert.Equal("StuntRunner", RroRewardPools.RoomForContext(GiftContext.StuntRunner_TheMainEvent_SS));
|
|
Assert.Equal("StuntRunner", RroRewardPools.RoomForContext(GiftContext.StuntRunner_TheMainEvent_Consumable));
|
|
Assert.True(RroRewardPools.IsRewardActivity("StuntRunner"));
|
|
|
|
// A quest's pool is served by rank, and a consumable is no rank: the activity's lowest rank
|
|
// is what answers it.
|
|
Assert.True(RroRewardPools.TryFloorContext("StuntRunner", out var floor));
|
|
Assert.Equal(GiftContext.StuntRunner_TheMainEvent_C, floor);
|
|
|
|
var drops = RroRewardPools.BuildDrops(GiftContext.StuntRunner_TheMainEvent_C,
|
|
selection: false, owned: null, random: new Random(3), catalogueJson: ShippedCatalogueJson());
|
|
|
|
var box = Assert.Single(drops);
|
|
Assert.False(string.IsNullOrWhiteSpace(box.AvatarItemDesc));
|
|
// An item is not a token box: the run pays a Stunt Runner item, never the token package.
|
|
Assert.Equal(CurrencyType.Invalid, box.CurrencyType);
|
|
Assert.Equal(0, box.Currency);
|
|
}
|
|
|
|
[Fact]
|
|
public void ACompletionNothingOwnOfItsOwnCanPayIsNotPaidInTokensAlone()
|
|
{
|
|
// Rec Royale's Season 1 rewards came from the Frontier Pass, which is not implemented, so a
|
|
// completed match used to be handed the token package as its whole reward.
|
|
var drops = RroRewardPools.BuildFallbackSelection(random: new Random(6), catalogueJson: ShippedCatalogueJson());
|
|
|
|
Assert.Equal(RroRewardPools.SelectionSize, drops.Count);
|
|
Assert.Equal(RroRewardPools.SelectionSize,
|
|
drops.Count(drop => !string.IsNullOrWhiteSpace(drop.AvatarItemDesc)));
|
|
|
|
// An item box is an item: none of them is a token box with an item's name on it.
|
|
Assert.All(drops, drop => Assert.Equal(0, drop.Currency));
|
|
Assert.All(drops, drop => Assert.Equal(CurrencyType.Invalid, drop.CurrencyType));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The token box used to be in EVERY fallback selection - a fixed one-in-three chance of being
|
|
/// handed tokens on a completion that had nothing of its own left to give - which is what "I keep
|
|
/// getting these" was. It is now the draw, and this is the draw's own budget.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TheTokenBoxIsTheExceptionInAFallbackSelection()
|
|
{
|
|
var json = ShippedCatalogueJson();
|
|
var withTokens = 0;
|
|
const int draws = 400;
|
|
|
|
for (var seed = 1; seed <= draws; seed++)
|
|
{
|
|
var drops = RroRewardPools.BuildFallbackSelection(random: new Random(seed), catalogueJson: json);
|
|
Assert.Equal(RroRewardPools.SelectionSize, drops.Count);
|
|
if (drops.Any(drop => drop.CurrencyType == CurrencyType.RecCenterTokens)) withTokens++;
|
|
}
|
|
|
|
Assert.True(withTokens > 0, "the token option is never offered at all");
|
|
Assert.True(withTokens < draws / 4,
|
|
$"the token option is in {withTokens} of {draws} selections; it is meant to be the exception");
|
|
}
|
|
|
|
[Fact]
|
|
public void TheTokenBoxIsTitledWithTheAmountItHolds()
|
|
{
|
|
var tokens = RroRewardPools.TokenGift(GiftContext.GameRewards_Tokens, RroRewardPools.CompletionTokenAmount);
|
|
|
|
// Every token box used to be called "Rec Center Tokens" whatever it carried, so a 50 and a
|
|
// 150 looked like the same reward twice; the amount is the title now.
|
|
Assert.Equal("50 Tokens", tokens.FriendlyName);
|
|
Assert.Equal(50, tokens.Currency);
|
|
Assert.Equal(CurrencyType.RecCenterTokens, tokens.CurrencyType);
|
|
Assert.Equal("200 Tokens", RroRewardPools.TokenGift(GiftContext.GameRewards_Tokens, 200).FriendlyName);
|
|
}
|
|
|
|
[Fact]
|
|
public void TheTokenBoxFillsTheSlotTheOrdinaryPoolCannot()
|
|
{
|
|
// A catalogue with two ordinary items cannot fill a three-box selection, so the slot it
|
|
// cannot fill is the token option rather than a short selection.
|
|
var drops = RroRewardPools.BuildFallbackSelection(random: new Random(1), catalogueJson: TwoItemCatalogueJson());
|
|
|
|
Assert.Equal(RroRewardPools.SelectionSize, drops.Count);
|
|
var tokens = Assert.Single(drops, drop => drop.CurrencyType == CurrencyType.RecCenterTokens);
|
|
Assert.Equal(RroRewardPools.CompletionTokenAmount, tokens.Currency);
|
|
Assert.Equal("50 Tokens", tokens.FriendlyName);
|
|
Assert.Equal(GiftContext.GameRewards_Tokens, tokens.Context);
|
|
}
|
|
|
|
[Fact]
|
|
public void ACatalogueWithNothingOrdinaryStillPaysTheTokenOption()
|
|
{
|
|
var drops = RroRewardPools.BuildFallbackSelection(random: new Random(1), catalogueJson: "[]");
|
|
|
|
// Nothing to offer but tokens, so tokens it is - the caller's own token package is the same
|
|
// answer, and this only has to be the one box rather than a failure.
|
|
var tokens = Assert.Single(drops);
|
|
Assert.Equal(CurrencyType.RecCenterTokens, tokens.CurrencyType);
|
|
Assert.Equal("50 Tokens", tokens.FriendlyName);
|
|
}
|
|
|
|
/// <summary>Two ordinary items, which is what a selection of three cannot be filled from.</summary>
|
|
private static string TwoItemCatalogueJson() => JsonSerializer.Serialize(new List<AvatarItem>
|
|
{
|
|
new()
|
|
{
|
|
AvatarItemDesc = "aa11,bb22", FriendlyName = "Plain Shirt (Grey)", Rarity = 10,
|
|
AvatarItemId = 1, CreatedAt = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
|
},
|
|
new()
|
|
{
|
|
AvatarItemDesc = "cc33,dd44", FriendlyName = "Plain Hat (Grey)", Rarity = 20,
|
|
AvatarItemId = 2, CreatedAt = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
|
},
|
|
});
|
|
}
|