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

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)
},
});
}