using RadicadeServer.RecNet.Enums.Store; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; /// /// How a completion turns into reward boxes: which activities offer the player a choice, what the /// server falls back to when the context the client named is not one the activity pays, and what /// the boxes contain. /// public class RroRewardSelectionTests { private static string ShippedCatalogueJson() => File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "2022-avatar.json")); // --------------------------------------------------------------------------------- // Choice or no choice // --------------------------------------------------------------------------------- [Theory] // The map games hand the player a watch selection. [InlineData("Clearcut", true)] [InlineData("River", true)] [InlineData("Spillway", true)] [InlineData("DriveIn", true)] [InlineData("DiscGolfPropulsion", true)] [InlineData("3DCharades", true)] [InlineData("Soccer", true)] [InlineData("Paddleball", true)] // A quest pays the rank the run earned; there is nothing to choose. [InlineData("GoldenTrophy", false)] [InlineData("TheRiseofJumbotron", false)] [InlineData("CrimsonCauldron", false)] [InlineData("Crescendo", false)] [InlineData("IsleOfLostSkulls", false)] [InlineData("StuntRunner", false)] // Not a reward activity at all. [InlineData("DormRoom", false)] [InlineData("RecCenter", false)] [InlineData(null, false)] public void OnlyTheMapGamesOfferAChoice(string? room, bool offersSelection) { Assert.Equal(offersSelection, RroRewardPools.OffersSelection(room)); } // --------------------------------------------------------------------------------- // What an out-of-place claim falls back to // --------------------------------------------------------------------------------- [Theory] [InlineData("GoldenTrophy", GiftContext.Quest_Goblin_C)] // 4002, the quest's lowest rank [InlineData("TheRiseofJumbotron", GiftContext.Quest_SciFi_C)] [InlineData("CrimsonCauldron", GiftContext.Quest_Cauldron_C)] [InlineData("Crescendo", GiftContext.Quest_Dracula1_C)] [InlineData("IsleOfLostSkulls", GiftContext.Quest_Pirate1_C)] // its lowest defined rank [InlineData("StuntRunner", GiftContext.StuntRunner_TheMainEvent_C)] [InlineData("Clearcut", GiftContext.Paintball_ClearCut)] // the map's only context [InlineData("3DCharades", GiftContext.Charades)] public void AClaimTheRoomDoesNotPayFallsToTheLowestRankItDoes(string room, GiftContext expected) { Assert.True(RroRewardPools.TryFloorContext(room, out var floor)); Assert.Equal(expected, floor); } [Fact] public void TheFloorIsNeverAboveWhatTheRoomWouldPay() { // The point of the floor: an inflated claim can only ever drop a player to the activity's // lowest rank, never lift them, and it can never hand out another activity's context. Assert.False(RroRewardPools.TryFloorContext("DormRoom", out var none)); Assert.Equal(GiftContext.None, none); foreach (var room in new[] { "GoldenTrophy", "CrimsonCauldron", "Clearcut", "Soccer" }) { Assert.True(RroRewardPools.TryFloorContext(room, out var floor)); Assert.Contains(floor, RroRewardPools.ContextsForRoom(room).Values); } } // --------------------------------------------------------------------------------- // What a completion pays // --------------------------------------------------------------------------------- [Fact] public void AQuestPaysOneBoxUnderItsOwnRankContext() { var json = ShippedCatalogueJson(); var drops = RroRewardPools.BuildDrops(GiftContext.Quest_Goblin_S, selection: false, null, new Random(5), json); Assert.Single(drops); Assert.Equal(GiftContext.Quest_Goblin_S, drops[0].Context); Assert.Equal(4003, drops[0].GiftContext); Assert.False(string.IsNullOrWhiteSpace(drops[0].AvatarItemDesc)); } [Fact] public void AMapGameFillsThreeBoxesWithThreeDifferentItems() { var json = ShippedCatalogueJson(); // Clear Cut pays seven Lumberjack pieces, so a selection of three is always available. var drops = RroRewardPools.BuildDrops(GiftContext.Paintball_ClearCut, selection: true, null, new Random(11), json); Assert.Equal(3, drops.Count); Assert.Equal(3, drops.Select(x => x.AvatarItemDesc).Distinct(StringComparer.Ordinal).Count()); Assert.All(drops, drop => Assert.Equal(GiftContext.Paintball_ClearCut, drop.Context)); Assert.All(drops, drop => Assert.Contains("Lumberjack", drop.FriendlyName, StringComparison.OrdinalIgnoreCase)); } [Fact] public void ASelectionStopsAtWhatThePoolHasLeft() { var json = ShippedCatalogueJson(); var pool = RroRewardPools.Resolve(GiftContext.Quest_Dracula1_S, json); // Crescendo's S rank pays two pieces, so a selection can offer at most those two. Assert.Equal(2, pool.Count); Assert.Equal(2, RroRewardPools.BuildDrops(GiftContext.Quest_Dracula1_S, selection: true, null, new Random(3), json).Count); } [Fact] public void ABoxIsNeverOfferedForSomethingThePlayerAlreadyOwns() { var json = ShippedCatalogueJson(); var pool = RroRewardPools.Resolve(GiftContext.Paintball_ClearCut, json); // Everything but two pieces is owned, so a selection of three can only offer those two. var owned = pool.Take(pool.Count - 2) .Select(x => LevelUpRewardPool.OwnershipKey(x.AvatarItemDesc)) .ToHashSet(StringComparer.OrdinalIgnoreCase); var drops = RroRewardPools.BuildDrops(GiftContext.Paintball_ClearCut, selection: true, owned, new Random(9), json); Assert.Equal(2, drops.Count); var remaining = pool.Skip(pool.Count - 2).Select(x => x.AvatarItemDesc).ToHashSet(StringComparer.Ordinal); Assert.All(drops, drop => Assert.Contains(drop.AvatarItemDesc, remaining)); Assert.All(drops, drop => Assert.DoesNotContain( LevelUpRewardPool.OwnershipKey(drop.AvatarItemDesc), owned)); } [Fact] public void AnExhaustedPoolPaysNoBoxesAtAll() { var json = ShippedCatalogueJson(); var owned = RroRewardPools.Resolve(GiftContext.Quest_Goblin_S, json) .Select(x => LevelUpRewardPool.OwnershipKey(x.AvatarItemDesc)) .ToHashSet(StringComparer.OrdinalIgnoreCase); Assert.Empty(RroRewardPools.BuildDrops(GiftContext.Quest_Goblin_S, selection: false, owned, new Random(1), json)); } [Fact] public void AGapInTheDefinitionsPaysNoBoxesRatherThanAStandIn() { var json = ShippedCatalogueJson(); // A tier the definitions name but the catalogue cannot satisfy pays nothing here, which is // what sends the caller to the fallback rather than to a guessed item. Assert.Empty(RroRewardPools.BuildDrops(GiftContext.Quest_Pirate1_X, selection: false, null, new Random(1), json)); Assert.Empty(RroRewardPools.BuildDrops(GiftContext.StuntRunner_TheMainEvent_D, selection: false, null, new Random(1), json)); Assert.Empty(RroRewardPools.BuildDrops(GiftContext.RecRoyale, selection: true, null, new Random(1), json)); } [Fact] public void TheSameCompletionTwiceDoesNotRepeatItself() { // Two runs of the same quest must not hand out the same piece: once the first is owned, the // second has to come from what is left. var json = ShippedCatalogueJson(); var first = RroRewardPools.BuildDrops(GiftContext.Quest_Goblin_B, selection: false, null, new Random(4), json); Assert.Single(first); var owned = new HashSet(StringComparer.OrdinalIgnoreCase) { LevelUpRewardPool.OwnershipKey(first[0].AvatarItemDesc) }; var second = RroRewardPools.BuildDrops(GiftContext.Quest_Goblin_B, selection: false, owned, new Random(4), json); Assert.Single(second); Assert.NotEqual(first[0].AvatarItemDesc, second[0].AvatarItemDesc); } [Fact] public void TheBoxesCarryNoCurrencyOrXp() { // A pool reward is the item and nothing else: it must not also pay tokens, or a quest clear // would quietly become a currency source. var json = ShippedCatalogueJson(); var drops = RroRewardPools.BuildDrops(GiftContext.Quest_Goblin_A, selection: false, null, new Random(2), json); Assert.Single(drops); Assert.Equal(0, drops[0].Currency); Assert.Equal(0, drops[0].Xp); // And nothing for the client to paint as currency: with the default CurrencyType an item box // rendered as a stack of coins with "Value: 0" under the item's name. Assert.Equal(CurrencyType.Invalid, drops[0].CurrencyType); } }