using RadicadeServer.Extensions; using RadicadeServer.RecNet.Avatar; using RadicadeServer.RecNet.Enums.Store; using RadicadeServer.Utils; using Xunit; namespace RadicadeServer.Tests; /// /// The historical RRO reward pools. The catalogue these assertions run against is the shipped /// 2022-avatar.json fixture - the same one the catalogue tests read - so a set whose items are not /// in the catalogue fails here rather than at runtime. /// public class RroRewardPoolsTests { private static string ShippedCatalogueJson() => File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "2022-avatar.json")); // --------------------------------------------------------------------------------- // The context values themselves // --------------------------------------------------------------------------------- [Theory] // Quests: each activity keeps its own block of values. [InlineData(GiftContext.Quest_Goblin_A, 4000)] [InlineData(GiftContext.Quest_Goblin_B, 4001)] [InlineData(GiftContext.Quest_Goblin_C, 4002)] [InlineData(GiftContext.Quest_Goblin_S, 4003)] [InlineData(GiftContext.Quest_Goblin_Consumable, 4004)] [InlineData(GiftContext.Quest_Cauldron_A, 4010)] [InlineData(GiftContext.Quest_Cauldron_B, 4011)] [InlineData(GiftContext.Quest_Cauldron_C, 4012)] [InlineData(GiftContext.Quest_Cauldron_S, 4013)] [InlineData(GiftContext.Quest_Cauldron_Consumable, 4014)] [InlineData(GiftContext.Quest_Pirate1_A, 4100)] [InlineData(GiftContext.Quest_Pirate1_B, 4101)] [InlineData(GiftContext.Quest_Pirate1_C, 4102)] [InlineData(GiftContext.Quest_Pirate1_S, 4103)] [InlineData(GiftContext.Quest_Pirate1_X, 4104)] [InlineData(GiftContext.Quest_Pirate1_Consumable, 4105)] [InlineData(GiftContext.Quest_Dracula1_A, 4200)] [InlineData(GiftContext.Quest_Dracula1_B, 4201)] [InlineData(GiftContext.Quest_Dracula1_C, 4202)] [InlineData(GiftContext.Quest_Dracula1_S, 4203)] [InlineData(GiftContext.Quest_Dracula1_X, 4204)] [InlineData(GiftContext.Quest_Dracula1_Consumable, 4205)] [InlineData(GiftContext.Quest_Dracula1_SS, 4206)] [InlineData(GiftContext.Quest_SciFi_A, 4500)] [InlineData(GiftContext.Quest_SciFi_B, 4501)] [InlineData(GiftContext.Quest_SciFi_C, 4502)] [InlineData(GiftContext.Quest_SciFi_S, 4503)] [InlineData(GiftContext.Quest_Scifi_Consumable, 4504)] // Stunt Runner. [InlineData(GiftContext.StuntRunner_TheMainEvent_A, 11000)] [InlineData(GiftContext.StuntRunner_TheMainEvent_B, 11001)] [InlineData(GiftContext.StuntRunner_TheMainEvent_C, 11002)] [InlineData(GiftContext.StuntRunner_TheMainEvent_D, 11003)] [InlineData(GiftContext.StuntRunner_TheMainEvent_S, 11004)] [InlineData(GiftContext.StuntRunner_TheMainEvent_X, 11005)] [InlineData(GiftContext.StuntRunner_TheMainEvent_Consumable, 11006)] [InlineData(GiftContext.StuntRunner_TheMainEvent_SS, 11007)] // Map games. [InlineData(GiftContext.Paintball_ClearCut, 2010)] [InlineData(GiftContext.Paintball_Homestead, 2011)] [InlineData(GiftContext.Paintball_Quarry, 2012)] [InlineData(GiftContext.Paintball_River, 2013)] [InlineData(GiftContext.Paintball_Dam, 2014)] [InlineData(GiftContext.Paintball_DriveIn, 2015)] [InlineData(GiftContext.Discgolf_Propulsion, 3010)] [InlineData(GiftContext.Discgolf_Lake, 3011)] [InlineData(GiftContext.Charades, 5001)] [InlineData(GiftContext.Soccer, 6001)] [InlineData(GiftContext.Paddleball, 7001)] [InlineData(GiftContext.Dodgeball, 8001)] [InlineData(GiftContext.Lasertag, 9001)] [InlineData(GiftContext.Bowling, 10001)] [InlineData(GiftContext.RecRoyale, 1200)] // The generic pair, for packages with no specialised activity. [InlineData(GiftContext.GameRewards, 50)] [InlineData(GiftContext.GameRewards_Tokens, 51)] // Deprecated: kept so old data still reads, never chosen for a new reward. [InlineData(GiftContext.DEPRECATED_Paintball_ClearCut, 2000)] [InlineData(GiftContext.DEPRECATED_Paintball_Homestead, 2001)] [InlineData(GiftContext.DEPRECATED_Paintball_Quarry, 2002)] [InlineData(GiftContext.DEPRECATED_Paintball_River, 2003)] [InlineData(GiftContext.DEPRECATED_Paintball_Dam, 2004)] [InlineData(GiftContext.DEPRECATED_Paintball_DriveIn, 2005)] [InlineData(GiftContext.DEPRECATED_Discgolf_Propulsion, 3000)] [InlineData(GiftContext.DEPRECATED_Discgolf_Lake, 3001)] [InlineData(GiftContext.DEPRECATED_Charades, 5000)] [InlineData(GiftContext.DEPRECATED_Soccer, 6000)] [InlineData(GiftContext.DEPRECATED_Paddleball, 7000)] [InlineData(GiftContext.DEPRECATED_Dodgeball, 8000)] [InlineData(GiftContext.DEPRECATED_Lasertag, 9000)] [InlineData(GiftContext.DEPRECATED_Bowling, 10000)] public void TheContextKeepsItsHistoricalValue(GiftContext context, int expected) { Assert.Equal(expected, (int)context); } [Fact] public void NoDeprecatedContextIsRewardable() { foreach (var context in RroRewardPools.Deprecated) { Assert.True(RroRewardPools.IsDeprecated(context)); // A deprecated context may have no pool at all; it must never have a non-empty one. Assert.Empty(RroRewardPools.Resolve(context, ShippedCatalogueJson())); } Assert.Equal(RroRewardPools.Deprecated.Count, RroRewardPools.Deprecated.Distinct().Count()); } // --------------------------------------------------------------------------------- // Which room pays which context // --------------------------------------------------------------------------------- [Theory] [InlineData("GoldenTrophy", "S", GiftContext.Quest_Goblin_S)] [InlineData("GoldenTrophy", "C", GiftContext.Quest_Goblin_C)] [InlineData("TheRiseofJumbotron", "A", GiftContext.Quest_SciFi_A)] [InlineData("CrimsonCauldron", "C", GiftContext.Quest_Cauldron_C)] [InlineData("Crescendo", "S", GiftContext.Quest_Dracula1_S)] [InlineData("StuntRunner", "B", GiftContext.StuntRunner_TheMainEvent_B)] [InlineData("IsleOfLostSkulls", "S", GiftContext.Quest_Pirate1_S)] // The map games take no rank. [InlineData("Clearcut", null, GiftContext.Paintball_ClearCut)] [InlineData("Spillway", null, GiftContext.Paintball_Dam)] [InlineData("DriveIn", null, GiftContext.Paintball_DriveIn)] [InlineData("DiscGolfPropulsion", null, GiftContext.Discgolf_Propulsion)] [InlineData("DiscGolfLake", null, GiftContext.Discgolf_Lake)] [InlineData("3DCharades", null, GiftContext.Charades)] [InlineData("Soccer", null, GiftContext.Soccer)] [InlineData("Paddleball", null, GiftContext.Paddleball)] [InlineData("Dodgeball", null, GiftContext.Dodgeball)] [InlineData("LaserTag", null, GiftContext.Lasertag)] [InlineData("Bowling", null, GiftContext.Bowling)] [InlineData("RecRoyaleSquads", null, GiftContext.RecRoyale)] public void TheRoomNamesTheContext(string room, string? rank, GiftContext expected) { Assert.True(RroRewardPools.TryContext(room, rank, out var context)); Assert.Equal(expected, context); } [Theory] [InlineData("DormRoom")] [InlineData("RecCenter")] [InlineData("SomeonesInvention")] [InlineData("")] [InlineData(null)] public void ARoomThatIsNotARewardActivityPaysNothing(string? room) { Assert.False(RroRewardPools.IsRewardActivity(room)); Assert.False(RroRewardPools.TryContext(room, "S", out var context)); Assert.Equal(GiftContext.None, context); } [Fact] public void ARankAQuestDoesNotDefinePaysNothing() { // A rank the definitions do not name must not resolve: Golden Trophy never pays a D or an // SS, and Isle of Lost Skulls has no C. Assert.False(RroRewardPools.TryContext("GoldenTrophy", "SS", out _)); Assert.False(RroRewardPools.TryContext("GoldenTrophy", "D", out _)); // Stunt Runner does define a tier above S, under both of the names the client has used // for it; D is the one it does not. Assert.False(RroRewardPools.TryContext("StuntRunner", "D", out _)); Assert.False(RroRewardPools.TryContext("Crescendo", "SS", out _)); // A ranked quest still refuses a context from another activity, because the room is what // decides: Golden Trophy cannot file a Jumbotron reward. Assert.Equal( GiftContext.Quest_Goblin_S, RroRewardPools.ContextsForRoom("GoldenTrophy")["S"]); } [Fact] public void TheStuntRunnerTopTierPaysFeatheredHairUnderEitherName() { // The definitions call this tier SS and the watch shows it as S+; both names have to reach // the same context, and the tier has to hand out the item it names. var json = ShippedCatalogueJson(); var pool = RroRewardPools.Resolve(GiftContext.StuntRunner_TheMainEvent_SS, json); Assert.Single(pool); Assert.Equal("Feathered Hair", pool[0].FriendlyName); Assert.True(RroRewardPools.TryContext("StuntRunner", "SS", out var viaSs)); Assert.Equal(GiftContext.StuntRunner_TheMainEvent_SS, viaSs); Assert.Equal(11007, (int)viaSs); Assert.True(RroRewardPools.TryContext("StuntRunner", "S+", out var viaSPlus)); Assert.Equal(GiftContext.StuntRunner_TheMainEvent_SS, viaSPlus); // And the tier is reachable, which is what makes a claim of it honoured. Assert.Contains(GiftContext.StuntRunner_TheMainEvent_SS, RroRewardPools.ContextsForRoom("StuntRunner").Values); } [Fact] public void NoRoomEverPaysADeprecatedContext() { foreach (var room in new[] { "GoldenTrophy", "TheRiseofJumbotron", "CrimsonCauldron", "Crescendo", "IsleOfLostSkulls", "StuntRunner", "Clearcut", "Spillway", "River", "DriveIn", "DiscGolfPropulsion", "3DCharades", "Soccer", "Paddleball", "Dodgeball", "LaserTag", "Bowling", "RecRoyaleSolos" }) { foreach (var context in RroRewardPools.ContextsForRoom(room).Values) { Assert.False(RroRewardPools.IsDeprecated(context), $"{room} pays the deprecated {context}"); } } } // --------------------------------------------------------------------------------- // The pools resolve against the shipped catalogue // --------------------------------------------------------------------------------- [Fact] public void EveryPoolThatNamesASetResolvesToShippedItems() { var json = ShippedCatalogueJson(); var catalogue = (json.FromJson>() ?? []) .ToDictionary(x => x.AvatarItemDesc, StringComparer.Ordinal); foreach (var context in RroRewardPools.RewardableContexts) { var sets = RroRewardPools.SetsFor(context); if (sets.Count == 0) continue; var items = RroRewardPools.Resolve(context, json); Assert.True(items.Count > 0, $"{context} names {sets.Count} set(s) but resolves to no item"); // Every item is a real catalogue row, and it came from one of the context's own sets. foreach (var item in items) { Assert.True(catalogue.ContainsKey(item.AvatarItemDesc), $"{context} resolved {item.AvatarItemDesc}, which is not a catalogue row"); Assert.False(string.IsNullOrWhiteSpace(item.FriendlyName)); Assert.Contains(sets, set => RroRewardPools.Resolve(set, json).Any(x => x.AvatarItemDesc == item.AvatarItemDesc)); } } } [Fact] public void EachQuestPaysEveryRankItDefinesAndNoItemTwice() { var json = ShippedCatalogueJson(); // Each quest's ranks, so a rank can be checked against the quest it belongs to. var quests = new Dictionary { ["Golden Trophy"] = [GiftContext.Quest_Goblin_C, GiftContext.Quest_Goblin_B, GiftContext.Quest_Goblin_A, GiftContext.Quest_Goblin_S], ["Jumbotron"] = [GiftContext.Quest_SciFi_C, GiftContext.Quest_SciFi_B, GiftContext.Quest_SciFi_A, GiftContext.Quest_SciFi_S], ["Crimson Cauldron"] = [GiftContext.Quest_Cauldron_C, GiftContext.Quest_Cauldron_B, GiftContext.Quest_Cauldron_A, GiftContext.Quest_Cauldron_S], ["Crescendo"] = [GiftContext.Quest_Dracula1_C, GiftContext.Quest_Dracula1_B, GiftContext.Quest_Dracula1_A, GiftContext.Quest_Dracula1_S], ["Stunt Runner"] = [GiftContext.StuntRunner_TheMainEvent_C, GiftContext.StuntRunner_TheMainEvent_B, GiftContext.StuntRunner_TheMainEvent_A, GiftContext.StuntRunner_TheMainEvent_S], }; foreach (var (quest, ranks) in quests) { foreach (var rank in ranks) { Assert.True(RroRewardPools.Resolve(rank, json).Count > 0, $"{quest} pays nothing at one of its ranks ({rank})"); } // Nothing here asserts that a quest's ranks are disjoint: the verified Isle of Lost // Skulls tables deliberately pay the same piece at two ranks. What must not happen is a // piece crossing from one activity to another, which NoItemIsPaidByTwoActivities covers. } } [Fact] public void NoItemIsPaidByTwoActivities() { // The rule that matters most: a Golden Trophy item must never be handed out as a Jumbotron // or a paintball reward. A quest's own ranks may overlap - the verified Isle of Lost Skulls // tables do - so the comparison is between activities, not between their tiers. var json = ShippedCatalogueJson(); var owner = new Dictionary(StringComparer.Ordinal); foreach (var (room, ranks) in new[] { ("GoldenTrophy", new[] { GiftContext.Quest_Goblin_C, GiftContext.Quest_Goblin_B, GiftContext.Quest_Goblin_A, GiftContext.Quest_Goblin_S }), ("Jumbotron", new[] { GiftContext.Quest_SciFi_C, GiftContext.Quest_SciFi_B, GiftContext.Quest_SciFi_A, GiftContext.Quest_SciFi_S }), ("CrimsonCauldron", new[] { GiftContext.Quest_Cauldron_C, GiftContext.Quest_Cauldron_B, GiftContext.Quest_Cauldron_A, GiftContext.Quest_Cauldron_S }), ("Crescendo", new[] { GiftContext.Quest_Dracula1_C, GiftContext.Quest_Dracula1_B, GiftContext.Quest_Dracula1_A, GiftContext.Quest_Dracula1_S, GiftContext.Quest_Dracula1_SS }), ("StuntRunner", new[] { GiftContext.StuntRunner_TheMainEvent_C, GiftContext.StuntRunner_TheMainEvent_B, GiftContext.StuntRunner_TheMainEvent_A, GiftContext.StuntRunner_TheMainEvent_S, GiftContext.StuntRunner_TheMainEvent_SS }), ("IsleOfLostSkulls", new[] { GiftContext.Quest_Pirate1_C, GiftContext.Quest_Pirate1_B, GiftContext.Quest_Pirate1_A, GiftContext.Quest_Pirate1_S }), ("Paintball", new[] { GiftContext.Paintball_ClearCut, GiftContext.Paintball_Homestead, GiftContext.Paintball_Quarry, GiftContext.Paintball_River, GiftContext.Paintball_Dam, GiftContext.Paintball_DriveIn }), ("DiscGolf", new[] { GiftContext.Discgolf_Propulsion, GiftContext.Discgolf_Lake }), ("Charades", new[] { GiftContext.Charades }), ("Soccer", new[] { GiftContext.Soccer }), ("Paddleball", new[] { GiftContext.Paddleball }), ("Dodgeball", new[] { GiftContext.Dodgeball }), ("Bowling", new[] { GiftContext.Bowling }), }) { // A quest's ranks may pay the same piece - the verified Isle of Lost Skulls tables do - // so an activity's pieces are gathered up before they are compared with another's. var paidByRoom = ranks .SelectMany(context => RroRewardPools.Resolve(context, json)) .Select(x => x.AvatarItemDesc) .ToHashSet(StringComparer.Ordinal); foreach (var descriptor in paidByRoom) { Assert.True(!owner.TryGetValue(descriptor, out var other), $"{descriptor} is paid by both {other} and {room}"); owner[descriptor] = room; } } Assert.NotEmpty(owner); } [Fact] public void APoolTheDefinitionsNameButTheCatalogueCannotSatisfyPaysNothing() { var json = ShippedCatalogueJson(); // The tiers that name no reward at all. Assert.Empty(RroRewardPools.Resolve(GiftContext.Quest_Dracula1_X, json)); Assert.Empty(RroRewardPools.Resolve(GiftContext.Quest_Pirate1_X, json)); Assert.Empty(RroRewardPools.Resolve(GiftContext.StuntRunner_TheMainEvent_D, json)); Assert.Empty(RroRewardPools.Resolve(GiftContext.StuntRunner_TheMainEvent_X, json)); Assert.Empty(RroRewardPools.Resolve(GiftContext.RecRoyale, json)); // The Scallywag tiers pay now; only X names nothing. Assert.Empty(RroRewardPools.Resolve(GiftContext.Quest_Pirate1_X, json)); } [Fact] public void EveryGapIsWrittenDownRatherThanGuessed() { // The brief's rule: an item that cannot be verified is left unresolved, not invented. Every // context that pays nothing has to be accounted for in the list, so a silently empty pool // cannot slip through. var known = string.Join("\n", RroRewardPools.Unresolved); // The sets the catalogue genuinely cannot satisfy, and nothing else. Every other reward set // turned out to be in the catalogue under its own family name. Assert.Contains("Brown Witch Hunter", known); Assert.Contains("Frontier Pass", known); Assert.Contains("Isle of Lost Skulls X", known); Assert.Contains("Stunt Runner D and X", known); Assert.Contains("Crescendo X", known); Assert.Contains("Rec Royale", known); // And a set that was found must not still be described as missing. Assert.DoesNotContain("Feathered Hair", known); Assert.DoesNotContain("Dude Sweater", known); Assert.DoesNotContain("Dracula's Set", known); Assert.DoesNotContain("Farmer set is not", known); Assert.DoesNotContain("Golfer set is not", known); Assert.Equal(RroRewardPools.Unresolved.Count, RroRewardPools.Unresolved.Distinct().Count()); } [Theory] // Every set that the catalogue turned out to carry, named by a piece it must contain. These are // the pools that were once wrongly written off as missing. [InlineData(GiftContext.Paintball_Homestead, "Farmer Hat")] [InlineData(GiftContext.Paintball_Homestead, "Farmer Overalls")] [InlineData(GiftContext.Paintball_Quarry, "Construction Helmet (Yellow)")] [InlineData(GiftContext.Paintball_Quarry, "Construction Shirt (Blue)")] [InlineData(GiftContext.Discgolf_Lake, "Golfer Hat (Blue)")] [InlineData(GiftContext.Discgolf_Lake, "Golfer Shirt (Tan)")] [InlineData(GiftContext.Dodgeball, "Dodgeball Shirt (Orange)")] [InlineData(GiftContext.Bowling, "Dude Sweater")] [InlineData(GiftContext.Quest_Dracula1_SS, "Dracula Cape")] [InlineData(GiftContext.Quest_Dracula1_SS, "Dracula Jacket")] [InlineData(GiftContext.Quest_Pirate1_A, "Pirate Captain Jacket (Blue)")] [InlineData(GiftContext.Quest_Pirate1_S, "Pirate Captain Jacket (Gold)")] public void ASetTheCatalogueCarriesPaysItsPieces(GiftContext context, string expected) { var names = RroRewardPools.Resolve(context, ShippedCatalogueJson()) .Select(x => x.FriendlyName) .ToHashSet(StringComparer.Ordinal); Assert.NotEmpty(names); Assert.Contains(expected, names); } [Fact] public void TheHomesteadSetIsTwoPiecesAndTheBeardIsNotOne() { // The Farmer set is the hat and the overalls; the beard was a store item and must not be // handed out as a Homestead reward. var names = RroRewardPools.Resolve(GiftContext.Paintball_Homestead, ShippedCatalogueJson()) .Select(x => x.FriendlyName) .ToHashSet(StringComparer.Ordinal); Assert.Equal(2, names.Count); Assert.Contains("Farmer Hat", names); Assert.Contains("Farmer Overalls", names); Assert.DoesNotContain("Farmer Beard", names); } [Fact] public void TheGolferSetIsThreePiecesInThreeColoursAndNothingElse() { // Three clothing types in tan, blue and grey. The catalogue's Golfer Hat also comes in red, // which is a store colour rather than a reward one. var names = RroRewardPools.Resolve(GiftContext.Discgolf_Lake, ShippedCatalogueJson()) .Select(x => x.FriendlyName) .ToHashSet(StringComparer.Ordinal); Assert.Equal(9, names.Count); Assert.Contains("Golfer Hat (Tan)", names); Assert.Contains("Golfer Shirt (Grey)", names); Assert.Contains("Golfer Gloves (Blue)", names); Assert.DoesNotContain("Golfer Hat (Red)", names); } [Fact] public void TheDodgerSetIsTheThreeCataloguePiecesUnderTheShirtName() { // The definitions call it the Dodger Vest in cyan, purple and orange; the catalogue carries // the same three as Dodgeball Shirt in orange, purple and teal. var names = RroRewardPools.Resolve(GiftContext.Dodgeball, ShippedCatalogueJson()) .Select(x => x.FriendlyName) .ToHashSet(StringComparer.Ordinal); Assert.Equal(3, names.Count); Assert.Contains("Dodgeball Shirt (Orange)", names); Assert.Contains("Dodgeball Shirt (Purple)", names); Assert.Contains("Dodgeball Shirt (Teal)", names); } [Fact] public void BowlingPaysItsSweaterOnceInTwoHundredAndFiftyGames() { Assert.Equal(1.0 / 250.0, RroRewardPools.DropChanceFor(GiftContext.Bowling), 10); // Every other activity pays what its pool holds whenever it is earned. Assert.Equal(1.0, RroRewardPools.DropChanceFor(GiftContext.Dodgeball)); Assert.Equal(1.0, RroRewardPools.DropChanceFor(GiftContext.Quest_Goblin_S)); } [Fact] public void LaserTagPaysTicketsInsteadOfDrawingACosmetic() { // Laser Tag is not a random-drop activity: its context exists for the record, and its // reward is tickets for its own shop. Assert.True(RroRewardPools.PaysTickets("LaserTag")); Assert.False(RroRewardPools.PaysTickets("Dodgeball")); Assert.False(RroRewardPools.PaysTickets(null)); Assert.Empty(RroRewardPools.Resolve(GiftContext.Lasertag, ShippedCatalogueJson())); Assert.Empty(RroRewardPools.BuildDrops(GiftContext.Lasertag, selection: false, null, new Random(1), ShippedCatalogueJson())); Assert.Equal(25, RroRewardPools.LaserTagTicketsPerGame); Assert.Equal(100, RroRewardPools.LaserTagFirstGamesBonus); Assert.Equal(5, RroRewardPools.LaserTagBonusGamesPerDay); } [Theory] // Every rank of Isle of Lost Skulls, named by the pieces the verified tables give it. [InlineData(GiftContext.Quest_Pirate1_C, "Scallywag Bracers (Brown)")] [InlineData(GiftContext.Quest_Pirate1_C, "Scallywag Belt (Red)")] [InlineData(GiftContext.Quest_Pirate1_C, "Pirate Captain Cuffs (Green)")] [InlineData(GiftContext.Quest_Pirate1_B, "Scallywag Vest (Brown)")] [InlineData(GiftContext.Quest_Pirate1_B, "Scallywag Vest (Red)")] [InlineData(GiftContext.Quest_Pirate1_B, "Pirate Captain Jacket (Blue)")] [InlineData(GiftContext.Quest_Pirate1_A, "Scallywag Hat (Brown)")] [InlineData(GiftContext.Quest_Pirate1_A, "Scallywag Hat (Red)")] [InlineData(GiftContext.Quest_Pirate1_A, "Pirate Captain Hat (Red)")] [InlineData(GiftContext.Quest_Pirate1_S, "Pirate Captain Hat (Gold)")] [InlineData(GiftContext.Quest_Pirate1_S, "Pirate Eyepatch (Black)")] [InlineData(GiftContext.Quest_Pirate1_S, "Pirate Eyepatch (Skull)")] public void EveryIsleOfLostSkullsRankPaysThePiecesItNames(GiftContext context, string expected) { var names = RroRewardPools.Resolve(context, ShippedCatalogueJson()) .Select(x => x.FriendlyName) .ToHashSet(StringComparer.Ordinal); Assert.NotEmpty(names); Assert.Contains(expected, names); } [Fact] public void TheBlackScallywagSetIsNotACompletionReward() { // It is a shop set, so no Isle of Lost Skulls rank names it. var json = ShippedCatalogueJson(); foreach (var context in new[] { GiftContext.Quest_Pirate1_C, GiftContext.Quest_Pirate1_B, GiftContext.Quest_Pirate1_A, GiftContext.Quest_Pirate1_S, }) { Assert.All( RroRewardPools.Resolve(context, json).Where(x => x.FriendlyName.StartsWith("Scallywag", StringComparison.Ordinal)), item => Assert.DoesNotContain("(Black)", item.FriendlyName, StringComparison.Ordinal)); } } [Fact] public void AQuestFallsBackToItsOwnLowerRanks() { // Strongest first, and never to another activity. Assert.Equal( new[] { GiftContext.Quest_Goblin_A, GiftContext.Quest_Goblin_B, GiftContext.Quest_Goblin_C }, RroRewardPools.LowerRanksOf(GiftContext.Quest_Goblin_S)); Assert.Equal( new[] { GiftContext.Quest_Pirate1_B, GiftContext.Quest_Pirate1_C }, RroRewardPools.LowerRanksOf(GiftContext.Quest_Pirate1_A)); // The weakest rank of a quest has nothing beneath it, and a map game ranks nothing at all. Assert.Empty(RroRewardPools.LowerRanksOf(GiftContext.Quest_Goblin_C)); Assert.Empty(RroRewardPools.LowerRanksOf(GiftContext.Quest_Pirate1_C)); Assert.Empty(RroRewardPools.LowerRanksOf(GiftContext.Paintball_ClearCut)); Assert.Empty(RroRewardPools.LowerRanksOf(GiftContext.Dodgeball)); } [Fact] public void TheLowerRanksHandedBackAreAlwaysTheSameActivitys() { foreach (var context in RroRewardPools.RewardableContexts) { foreach (var weaker in RroRewardPools.LowerRanksOf(context)) { var sameRoom = RroRewardPools.ContextsForRoom("GoldenTrophy").Values.Contains(weaker) || RroRewardPools.ContextsForRoom("CrimsonCauldron").Values.Contains(weaker) || RroRewardPools.ContextsForRoom("IsleOfLostSkulls").Values.Contains(weaker) || RroRewardPools.ContextsForRoom("Crescendo").Values.Contains(weaker) || RroRewardPools.ContextsForRoom("StuntRunner").Values.Contains(weaker) || RroRewardPools.ContextsForRoom("TheRiseofJumbotron").Values.Contains(weaker); Assert.True(sameRoom, $"{weaker} came back as a lower rank of {context} but is not a quest rank"); } } } [Fact] public void RecRoyalePaysThroughItsPassRatherThanADrop() { Assert.True(RroRewardPools.PaysNoItemReward("RecRoyaleSolos")); Assert.True(RroRewardPools.PaysNoItemReward("RecRoyaleSquads")); Assert.False(RroRewardPools.PaysNoItemReward("Dodgeball")); Assert.False(RroRewardPools.PaysNoItemReward(null)); Assert.Empty(RroRewardPools.Resolve(GiftContext.RecRoyale, ShippedCatalogueJson())); } [Fact] public void GreyAndGrayAreOneColour() { // The catalogue spells it both ways depending on the item, so a set named one way still has // to find the pieces spelled the other. var json = ShippedCatalogueJson(); var grey = RroRewardPools.Resolve(new RroRewardPools.ItemSet("Knight", "Grey"), json); var gray = RroRewardPools.Resolve(new RroRewardPools.ItemSet("Knight", "Gray"), json); Assert.NotEmpty(grey); Assert.Equal( grey.Select(x => x.AvatarItemDesc).OrderBy(x => x, StringComparer.Ordinal), gray.Select(x => x.AvatarItemDesc).OrderBy(x => x, StringComparer.Ordinal)); } // --------------------------------------------------------------------------------- // Picking an item // --------------------------------------------------------------------------------- [Fact] public void ThePickSkipsWhatThePlayerAlreadyOwns() { var json = ShippedCatalogueJson(); var pool = RroRewardPools.Resolve(GiftContext.Quest_Goblin_S, json); Assert.True(pool.Count >= 2); // Everything but the last item is owned, so the pick can only be that one. var owned = pool.Take(pool.Count - 1) .Select(x => LevelUpRewardPool.OwnershipKey(x.AvatarItemDesc)) .ToHashSet(StringComparer.OrdinalIgnoreCase); var pick = RroRewardPools.Pick(GiftContext.Quest_Goblin_S, owned, new Random(1), json); Assert.NotNull(pick); Assert.Equal(pool[^1].AvatarItemDesc, pick!.AvatarItemDesc); } [Fact] public void AnExhaustedPoolPicksNothingRatherThanRepeating() { var json = ShippedCatalogueJson(); var pool = RroRewardPools.Resolve(GiftContext.Quest_Goblin_S, json); var owned = pool .Select(x => LevelUpRewardPool.OwnershipKey(x.AvatarItemDesc)) .ToHashSet(StringComparer.OrdinalIgnoreCase); Assert.Null(RroRewardPools.Pick(GiftContext.Quest_Goblin_S, owned, new Random(1), json)); Assert.Null(RroRewardPools.BuildGift(GiftContext.Quest_Goblin_S, owned, new Random(1), json)); } [Fact] public void AContextThatPaysNothingPicksNothing() { var json = ShippedCatalogueJson(); Assert.Null(RroRewardPools.Pick(GiftContext.Quest_Pirate1_X, null, new Random(1), json)); Assert.Null(RroRewardPools.Pick(GiftContext.DEPRECATED_Bowling, null, new Random(1), json)); Assert.Null(RroRewardPools.Pick(GiftContext.GameRewards, null, new Random(1), json)); } [Fact] public void TheBoxCarriesTheContextItWasFiledUnder() { var json = ShippedCatalogueJson(); var gift = RroRewardPools.BuildGift(GiftContext.Quest_Goblin_S, null, new Random(7), json); Assert.NotNull(gift); Assert.Equal(GiftContext.Quest_Goblin_S, gift!.Context); // The wire field the client reads, and the one the admin panel stores, both agree. Assert.Equal(4003, gift.GiftContext); Assert.False(string.IsNullOrWhiteSpace(gift.AvatarItemDesc)); Assert.Equal(0, gift.Currency); Assert.Equal(0, gift.Xp); } [Fact] public void APaintballRewardIsFiledUnderItsOwnMap() { var json = ShippedCatalogueJson(); Assert.True(RroRewardPools.TryContext("Clearcut", null, out var clearcut)); var gift = RroRewardPools.BuildGift(clearcut, null, new Random(3), json); Assert.NotNull(gift); Assert.Equal(2010, gift!.GiftContext); // The item is a Lumberjack piece, not a quest reward. Assert.Contains("Lumberjack", gift.FriendlyName, StringComparison.OrdinalIgnoreCase); } [Fact] public void EveryRewardableContextEitherPaysItemsOrIsAWrittenDownGap() { // A pool that quietly loses all of its items is the failure this guards against: a context // with pools must resolve, and one without must be a gap this file names on purpose. var json = ShippedCatalogueJson(); GiftContext[] deliberatelyEmpty = [ GiftContext.RecRoyale, GiftContext.Lasertag, GiftContext.Quest_Dracula1_X, GiftContext.Quest_Pirate1_X, GiftContext.StuntRunner_TheMainEvent_D, GiftContext.StuntRunner_TheMainEvent_X, ]; foreach (var context in RroRewardPools.RewardableContexts) { if (RroRewardPools.Resolve(context, json).Count > 0) continue; Assert.True(deliberatelyEmpty.Contains(context), $"{context} pays nothing and is not one of the written-down gaps"); } // And the reverse: none of the written-down gaps gained a pool behind our back. foreach (var context in deliberatelyEmpty) Assert.Empty(RroRewardPools.Resolve(context, json)); } }