Radicade Public Code

This commit is contained in:
JogmansonReal
2026-10-16 23:33:13 -07:00
commit 55175acdf6
832 changed files with 363261 additions and 0 deletions
+673
View File
@@ -0,0 +1,673 @@
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.RecNet.Enums.Store;
using RadicadeServer.Utils;
using Xunit;
namespace RadicadeServer.Tests;
/// <summary>
/// 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.
/// </summary>
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<List<AvatarItem>>() ?? [])
.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<string, GiftContext[]>
{
["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<string, string>(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));
}
}