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
+21
View File
@@ -0,0 +1,21 @@
namespace RadicadeServer.RecNet
{
public enum CardType
{
Charades,
CharadesAprilFoolsDay,
Icebreakers
}
public enum CardDifficulty
{
CharadesEasy,
CharadesHard,
CharadesStupidHard = 10,
Icebreaks = 20
}
public class CardBoxWord
{
public string EN_US { get; set; } = string.Empty;
public CardDifficulty Difficulty { get; set; } = CardDifficulty.CharadesEasy;
}
}
+62
View File
@@ -0,0 +1,62 @@
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.RecNet.Enums.Store;
namespace RadicadeServer.Utils;
public static class FriendotronService
{
public const string DefaultMessage = "A gift for you <3";
public static readonly TimeSpan Cooldown = TimeSpan.FromHours(24);
public static readonly Rarity[] StarTiers = { Rarity.Common, Rarity.Uncommon, Rarity.Rare };
public const int MaxRecipients = 50;
public static string CooldownKey(long accountId) => $"Friendotron.LastSent.{accountId}";
public static string MessageOr(string? message) =>
string.IsNullOrWhiteSpace(message) ? DefaultMessage : message.Trim();
public static Rarity RollStarTier(Random random) => StarTiers[random.Next(StarTiers.Length)];
public static ConsumableItem? RollConsumable(string? catalogJson, out Rarity starTier, Random? random = null)
{
var rng = random ?? Random.Shared;
var tier = RollStarTier(rng);
starTier = tier;
var catalogue = ConsumableCatalog.ParseItems(catalogJson)
.Where(item => !string.IsNullOrWhiteSpace(item.ConsumableItemDesc))
.ToList();
if (catalogue.Count == 0) return null;
var ofTier = catalogue.Where(item => item.Rarity == tier).ToList();
var pool = ofTier.Count > 0 ? ofTier : catalogue;
return pool[rng.Next(pool.Count)];
}
public static Gift BuildBox(long senderId, string? message, ConsumableItem item, Rarity starTier) => new()
{
Context = GiftContext.Friendotron_Gift,
FromPlayerId = senderId,
Rarity = starTier,
FriendlyName = item.FriendlyName,
Tooltip = MessageOr(message),
ConsumableItemDesc = ConsumableCatalog.ToClientDesc(item.ConsumableItemDesc)
};
public static string FormatRemaining(DateTime nowUtc, DateTime lastSentUtc)
{
var remaining = Cooldown - (nowUtc - lastSentUtc);
if (remaining <= TimeSpan.Zero) return "0 minutes";
var hours = (int)remaining.TotalHours;
var minutes = remaining.Minutes;
if (hours <= 0) return minutes <= 1 ? "1 minute" : $"{minutes} minutes";
var hourText = hours == 1 ? "1 hour" : $"{hours} hours";
if (minutes == 0) return hourText;
return $"{hourText} {minutes} minutes";
}
}
+213
View File
@@ -0,0 +1,213 @@
using Microsoft.EntityFrameworkCore;
using RadicadeServer.API.Storefront;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.RecNet.Enums.Store;
using RadicadeServer.RecNet.Progression;
namespace RadicadeServer.Utils
{
public static class GameRewardRedemption
{
public static async Task<Gift?> TakeOutstandingAsync(
RecDBContext db, long playerId, GiftContext context, string? message = null, Action<string>? trace = null)
{
var data = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == playerId);
var json = data?.GetProgressionJson();
var pending = json?.PendingGameRewards;
var outstanding = pending?.Where(r => !r.Consumed).ToList() ?? new List<RecNet.Storefront.GameReward>();
trace?.Invoke($"stage=OutstandingReward claimed={RewardTrace.Context(context)} " +
$"rewards={pending?.Count ?? 0} outstanding={outstanding.Count}");
if (data == null || json == null || outstanding.Count == 0) return null;
foreach (var candidate in outstanding)
{
var held = candidate.GiftDrop1 ?? candidate.GiftDrop2 ?? candidate.GiftDrop3;
trace?.Invoke($"stage=OutstandingCandidate selectionId={candidate.RewardSelectionId} " +
$"rewardType={candidate.RewardType} context={RewardTrace.Context(candidate.GiftContext)} " +
$"box={RewardTrace.Box(held)}");
}
var reward = outstanding.FirstOrDefault(r => r.GiftContext == context);
var matchedBy = reward == null ? null : "context";
if (reward == null && RroRewardPools.RoomForContext(context) is { } room)
{
reward = outstanding.FirstOrDefault(r => RroRewardPools.RoomForContext(r.GiftContext) == room);
matchedBy = reward == null ? null : $"room={room}";
}
if (reward == null)
{
reward = outstanding[0];
matchedBy = "oldest outstanding";
}
var drop = reward.GiftDrop1 ?? reward.GiftDrop2 ?? reward.GiftDrop3;
if (drop == null)
{
trace?.Invoke($"stage=OutstandingRewardUnusable selectionId={reward.RewardSelectionId} " +
"has no gift drop at all; nothing can be handed over from it.");
return null;
}
var gift = drop.Clone();
if (!string.IsNullOrWhiteSpace(message)) gift.Tooltip = message;
if (gift.GiftDropId == 0) gift.GiftDropId = StorefrontShared.NewGiftDropId();
reward.Consumed = true;
reward.ConsumedGiftDropId = gift.GiftDropId;
reward.ConsumedAt = DateTime.UtcNow;
data.Progress = json.ToJson();
await db.SaveChangesAsync();
trace?.Invoke($"stage=OutstandingRewardMatched by={matchedBy} selectionId={reward.RewardSelectionId} " +
$"rewardType={reward.RewardType} context={RewardTrace.Context(reward.GiftContext)} " +
$"box={RewardTrace.Box(gift)}");
return gift;
}
public static async Task<Gift?> BuildRoomRewardAsync(
RecDBContext db, long playerId, GiftContext context, string? message = null,
Action<string>? trace = null, string? catalogueJson = null)
{
var room = RroRewardPools.RoomForContext(context);
if (room == null) return null;
var owned = await LevelUpGiftService.CollectOwnedKeysAsync(db, playerId, context);
Gift? box = RroRewardPools.BuildGift(context, owned, catalogueJson: catalogueJson);
var paidFrom = context;
if (box == null)
{
foreach (var weaker in RroRewardPools.LowerRanksOf(context))
{
box = RroRewardPools.BuildGift(weaker, owned, catalogueJson: catalogueJson);
if (box == null) continue;
paidFrom = weaker;
trace?.Invoke($"stage=RoomBoxFallbackRank context={RewardTrace.Context(context)} " +
$"nothing left unowned, so the same quest's {RewardTrace.Context(weaker)} is what it pays.");
break;
}
}
if (box == null)
{
box = RroRewardPools.BuildFallbackDrops(selection: false, owned: owned).FirstOrDefault();
if (box != null)
{
paidFrom = RroRewardPools.ReacquisitionContext;
trace?.Invoke($"stage=RoomBoxFallbackItem context={RewardTrace.Context(context)} " +
"every reward the activity pays is owned; paying the non-exclusive item pool.");
}
}
if (box == null) return null;
box.GiftDropId = StorefrontShared.NewGiftDropId();
if (!string.IsNullOrWhiteSpace(message)) box.Tooltip = message;
trace?.Invoke($"stage=RoomBox activity={room} context={RewardTrace.Context(context)} " +
$"paidFrom={RewardTrace.Context(paidFrom)} box={RewardTrace.Box(box)}");
await RememberHandoverAsync(db, playerId, context, box, trace);
return box;
}
public static async Task RememberHandoverAsync(
RecDBContext db, long playerId, GiftContext context, Gift box, Action<string>? trace = null)
{
var data = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == playerId);
if (data == null)
{
data = new ProgressionData { PlayerId = playerId };
db.Progression.Add(data);
}
var json = data.GetProgressionJson() ?? new ProgressionJson();
json.PendingGameRewards ??= new List<RecNet.Storefront.GameReward>();
json.PendingGameRewards.Add(new RecNet.Storefront.GameReward
{
RewardSelectionId = StorefrontShared.NewGiftDropId(),
Message = box.Tooltip ?? string.Empty,
GiftContext = context,
RewardType = RroRewardPools.IsQuestContext(context)
? RewardType.PostQuestActivity
: RewardType.PostGameActivity,
GiftDrop1 = box.Clone(),
CreatedAt = DateTime.UtcNow,
Consumed = true,
ConsumedGiftDropId = box.GiftDropId,
ConsumedAt = DateTime.UtcNow
});
data.Progress = json.ToJson();
await db.SaveChangesAsync();
trace?.Invoke($"stage=HandoverRecorded context={RewardTrace.Context(context)} " +
$"box={RewardTrace.Box(box)} the completion is recorded as delivered, so a later request " +
"for it cannot pay a second box.");
}
public static async Task<Gift?> TakePendingBoxAsync(
RecDBContext db, long playerId, GiftContext context, Action<string>? trace = null)
{
var room = RroRewardPools.RoomForContext(context);
if (room == null || !RroRewardPools.IsRewardActivity(room)) return null;
var boxes = await StorefrontShared.GetPendingGiftsAsync(db, playerId);
var box = boxes.FirstOrDefault(b => b.Context == context)
?? boxes.FirstOrDefault(b => RroRewardPools.RoomForContext(b.Context) == room);
trace?.Invoke($"stage=PendingBox claimed={RewardTrace.Context(context)} room={room} " +
$"held={boxes.Count} match={RewardTrace.Box(box)}");
if (box == null) return null;
var gift = box.Clone();
if (gift.GiftDropId == 0) gift.GiftDropId = StorefrontShared.NewGiftDropId();
return gift;
}
public static async Task<Gift?> TakeRecentlyHandedOverAsync(
RecDBContext db, long playerId, GiftContext context, TimeSpan within, Action<string>? trace = null)
{
var data = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == playerId);
var json = data?.GetProgressionJson();
var pending = json?.PendingGameRewards;
if (pending == null || pending.Count == 0) return null;
var cutoff = DateTime.UtcNow - within;
var taken = pending
.Where(r => r.Consumed && r.ConsumedAt is { } at && at >= cutoff)
.Where(r => r.GiftContext == context
|| (RroRewardPools.RoomForContext(context) is { } room
&& RroRewardPools.RoomForContext(r.GiftContext) == room))
.OrderByDescending(r => r.ConsumedAt)
.FirstOrDefault();
if (taken == null)
{
trace?.Invoke($"stage=HandedOver claimed={RewardTrace.Context(context)} none within {within}.");
return null;
}
var drop = taken.GiftDrop1 ?? taken.GiftDrop2 ?? taken.GiftDrop3;
if (drop == null) return null;
var gift = drop.Clone();
gift.GiftDropId = taken.ConsumedGiftDropId != 0 ? taken.ConsumedGiftDropId : gift.GiftDropId;
trace?.Invoke($"stage=HandedOver claimed={RewardTrace.Context(context)} " +
$"selectionId={taken.RewardSelectionId} box={RewardTrace.Box(gift)} - answering the retry " +
"with the box it was given.");
return gift;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace RadicadeServer.Radicade.Gifts.Enums;
public enum ItemAccessibility
{
Private = -1,
Exclusive,
SubscribersOnly,
DevelopersOnly,
Purchasable = 10,
}
+92
View File
@@ -0,0 +1,92 @@
using Microsoft.EntityFrameworkCore;
using RadicadeServer.API.Notifications;
using RadicadeServer.API.Storefront;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.RecNet.Enums.Store;
using RadicadeServer.RecNet.Notifications;
namespace RadicadeServer.Utils
{
public static class QuestRewardDelivery
{
private const string ManualConsumedNotificationId = "gift.manualconsumed";
public static async Task<int> DeliverOnArrivalAsync(RecDBContext db, long playerId, long enteredRoomId)
{
try
{
return await DeliverAsync(db, playerId, enteredRoomId);
}
catch (Exception)
{
return 0;
}
}
private static async Task<int> DeliverAsync(RecDBContext db, long playerId, long enteredRoomId)
{
var boxes = await StorefrontShared.GetPendingGiftsAsync(db, playerId);
if (!boxes.Any(box => RroRewardPools.IsQuestContext(box.Context))) return 0;
var roomName = await db.Rooms
.AsNoTracking()
.Where(x => x.RoomId == enteredRoomId)
.Select(x => x.Name)
.FirstOrDefaultAsync();
var openableHere = RroRewardPools.ContextsForRoom(roomName).Values.ToList();
var stranded = boxes
.Where(box => RroRewardPools.IsQuestContext(box.Context) && !openableHere.Contains(box.Context))
.ToList();
if (stranded.Count == 0) return 0;
var progression = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == playerId);
var json = progression?.GetProgressionJson();
var delivered = 0;
foreach (var box in stranded)
{
var gift = box.Clone();
if (gift.GiftDropId == 0) gift.GiftDropId = StorefrontShared.NewGiftDropId();
var grant = await StorefrontShared.GrantGiftAsync(db, playerId, gift);
await StorefrontShared.RemovePendingGiftAsync(db, playerId, box.GiftDropId);
var source = json?.PendingGameRewards?.FirstOrDefault(r =>
r.ConsumedGiftDropId == box.GiftDropId
|| (!r.Consumed && r.GiftContext == box.Context));
if (source != null && !source.Consumed)
{
source.Consumed = true;
source.ConsumedGiftDropId = box.GiftDropId;
source.ConsumedAt = DateTime.UtcNow;
}
delivered++;
RewardTrace.Log(playerId, $"stage=DeliveredOnLeaving activity={RroRewardPools.RoomForContext(box.Context) ?? "<none>"} " +
$"entered={roomName ?? "<unknown>"} box={RewardTrace.Box(gift)} " +
$"selectionId={source?.RewardSelectionId.ToString() ?? "<none>"} " +
"the quest box could no longer be opened, so the reward it already held was granted to the locker.");
if (grant != null)
await StorefrontShared.SendBalanceUpdateAsync(playerId, grant.CurrencyType, grant.Balance,
grant.Granted, StorefrontBalanceAddTypes.FromGiftBox);
await Notify.SendNotify(playerId, new Notification
{
Id = ManualConsumedNotificationId,
Msg = gift.ForClient()
});
}
if (progression != null && json != null) progression.Progress = json.ToJson();
await db.SaveChangesAsync();
return delivered;
}
}
}
+227
View File
@@ -0,0 +1,227 @@
using Microsoft.EntityFrameworkCore;
using RadicadeServer.API.Storefront;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Accounts.Local;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.RecNet.Enums.Store;
using RadicadeServer.RecNet.Rooms;
namespace RadicadeServer.Utils
{
public static class RoomJoinGiftUtils
{
private const string ClaimKeyPrefix = "Radicade.RoomJoinGift.";
/// <summary>
/// Whether a room's own Rooms row describes a box at all. A room can hold several boxes in
/// <c>RoomJoinGifts</c>; this answers for the single one that row still carries, which is
/// the whole story for a room saved before that table existed.
/// </summary>
public static bool HasConfiguredReward(RoomData room)
{
return room.JoinGiftCurrencyAmount > 0 ||
room.JoinGiftXp > 0 ||
!string.IsNullOrWhiteSpace(room.JoinGiftAvatarItem) ||
!string.IsNullOrWhiteSpace(room.JoinGiftEquipmentItem) ||
!string.IsNullOrWhiteSpace(room.JoinGiftConsumableItem);
}
public static bool HasConfiguredReward(RoomJoinGift gift)
{
return gift.CurrencyAmount > 0 ||
gift.Xp > 0 ||
!string.IsNullOrWhiteSpace(gift.AvatarItem) ||
!string.IsNullOrWhiteSpace(gift.EquipmentItem) ||
!string.IsNullOrWhiteSpace(gift.ConsumableItem);
}
/// <summary>
/// The single box a room's own row describes, as a gift row. Used for a room that has no
/// rows in <c>RoomJoinGifts</c>: it is the same box, read from where it has always lived.
/// </summary>
public static RoomJoinGift Snapshot(RoomData room)
{
return new RoomJoinGift
{
RoomJoinGiftId = 0,
RoomId = room.RoomId,
SortOrder = 0,
Version = room.JoinGiftVersion,
Enabled = room.JoinGiftEnabled,
OncePerPlayer = room.JoinGiftOncePerPlayer,
Title = room.JoinGiftTitle,
Message = room.JoinGiftMessage,
AvatarItem = room.JoinGiftAvatarItem,
AvatarItemType = room.JoinGiftAvatarItemType,
EquipmentItem = room.JoinGiftEquipmentItem,
EquipmentModificationGuid = room.JoinGiftEquipmentModificationGuid,
ConsumableItem = room.JoinGiftConsumableItem,
CurrencyType = room.JoinGiftCurrencyType,
CurrencyAmount = room.JoinGiftCurrencyAmount,
Xp = room.JoinGiftXp,
Context = room.JoinGiftContext,
Rarity = room.JoinGiftRarity,
FromPlayerId = room.JoinGiftFromPlayerId
};
}
/// <summary>The boxes a room hands out, in the order the panel lists them.</summary>
public static async Task<List<RoomJoinGift>> ConfiguredGiftsAsync(RecDBContext ctx, long roomId)
{
return await ctx.RoomJoinGifts
.Where(x => x.RoomId == roomId)
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.RoomJoinGiftId)
.ToListAsync();
}
/// <summary>
/// Hands every box this room is configured to give to the player. A room with several
/// boxes gives several; a box the player has already claimed under its current version is
/// skipped, and each box is claimed on its own so changing one does not silence the rest.
/// </summary>
public static async Task<bool> TryDeliverAsync(RecDBContext ctx, long playerId, RoomData room)
{
// The room's own switch stays the master: a room that is not handing anything out
// hands out nothing, whichever boxes its list happens to hold.
if (!room.JoinGiftEnabled)
{
return false;
}
var configured = await ConfiguredGiftsAsync(ctx, room.RoomId);
// A room with no list still has the one box its Rooms row describes.
var boxes = configured.Count > 0
? configured
: [Snapshot(room)];
var delivered = false;
foreach (var box in boxes)
{
if (!box.Enabled)
{
continue;
}
if (!HasConfiguredReward(box))
{
continue;
}
var claimKey = ClaimKey(room.RoomId, box);
if (box.OncePerPlayer && await HasClaimedAsync(ctx, playerId, claimKey))
{
continue;
}
var giftBox = new Gift
{
GiftDropId = NewGiftDropId(room.RoomId, box.Version, box.RoomJoinGiftId),
ItemSetId = 0,
ItemSetFriendlyName = room.Name,
FriendlyName = string.IsNullOrWhiteSpace(box.Title) ? "Room Visit Gift" : box.Title,
Tooltip = box.Message ?? "",
IsQuery = false,
Unique = false,
SubscribersOnly = false,
ConsumableItemDesc = box.ConsumableItem ?? "",
AvatarItemDesc = box.AvatarItem ?? "",
AvatarItemType = box.AvatarItemType,
EquipmentPrefabName = box.EquipmentItem ?? "",
EquipmentModificationGuid = box.EquipmentModificationGuid ?? "",
CurrencyType = box.CurrencyType,
Currency = Math.Max(0, box.CurrencyAmount),
Xp = Math.Max(0, box.Xp),
Context = box.Context,
Rarity = box.Rarity,
// The box names its sender to the client, which shows it on the box; a room that
// names no sender leaves it at zero rather than inventing one.
FromPlayerId = Math.Max(0, box.FromPlayerId)
};
await StorefrontShared.PersistGiftBoxAsync(ctx, playerId, giftBox);
if (box.OncePerPlayer)
{
await MarkClaimedAsync(ctx, playerId, claimKey);
}
delivered = true;
}
return delivered;
}
public static async Task<bool> TryDeliverAsync(RecDBContext ctx, long playerId, long roomId)
{
var room = await ctx.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
if (room == null)
{
return false;
}
return await TryDeliverAsync(ctx, playerId, room);
}
/// <summary>
/// A gift drop id for one box. Two boxes handed out in the same millisecond must not share
/// an id - the player's boxes are a list keyed by it, so the second would overwrite the
/// first - so a listed box also mixes in which row it came from. A room's own single box
/// keeps the id shape it has always had.
/// </summary>
private static long NewGiftDropId(long roomId, int version, long giftId)
{
var timePart = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() % 1_000_000_000;
var roomPart = Math.Abs(roomId % 100_000);
var versionPart = Math.Abs(version % 1_000);
if (giftId <= 0)
return 9_000_000_000_000 + roomPart * 10_000_000 + versionPart * 10_000 + timePart % 10_000;
var giftPart = giftId % 1_000;
return 9_000_000_000_000 + roomPart * 1_000_000_000 + giftPart * 1_000_000 + versionPart * 1_000 + timePart % 1_000;
}
/// <summary>
/// The key a player's once-per-player claim is remembered under. A room's own single box
/// keeps the key it has always used, so this change hands nobody a second copy of a box
/// they already claimed; a listed box adds its row and version.
/// </summary>
private static string ClaimKey(long roomId, RoomJoinGift box)
{
if (box.RoomJoinGiftId <= 0)
return $"{ClaimKeyPrefix}{roomId}.{box.Version}";
return $"{ClaimKeyPrefix}{roomId}.{box.RoomJoinGiftId}.{box.Version}";
}
private static async Task<bool> HasClaimedAsync(RecDBContext ctx, long playerId, string key)
{
var settings = await ctx.PlayerSettings.FirstOrDefaultAsync(x => x.AccountId == playerId);
if (settings == null) return false;
return settings.DeserializeSettings().Any(x => x.Key == key && x.Value == "1");
}
private static async Task MarkClaimedAsync(RecDBContext ctx, long playerId, string key)
{
var settings = await ctx.PlayerSettings.FirstOrDefaultAsync(x => x.AccountId == playerId);
if (settings == null)
{
settings = new PlayerSettings { AccountId = playerId };
await ctx.PlayerSettings.AddAsync(settings);
}
var list = settings.DeserializeSettings();
var existing = list.FirstOrDefault(x => x.Key == key);
if (existing == null)
list.Add(new Setting { Key = key, Value = "1" });
else
existing.Value = "1";
settings.Settings = list.ToJson();
}
}
}
+643
View File
@@ -0,0 +1,643 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using RadicadeServer.API.Storefront;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.RecNet.Enums.Players.Avatar;
using RadicadeServer.RecNet.Enums.Store;
namespace RadicadeServer.Utils
{
public static class RroRewardPools
{
public sealed record ItemSet(string Family, string Colour);
public const string Unranked = "";
public const int SelectionSize = 3;
public static readonly string[] QuestRanks = ["C", "B", "A", "S"];
private static readonly Dictionary<GiftContext, ItemSet[]> Pools = new()
{
[GiftContext.Quest_Goblin_C] =
[
new("Archer", "Tan"), new("Archer", "Yellow"),
new("Knight", "Blue"), new("Knight", "Grey"),
],
[GiftContext.Quest_Goblin_B] =
[
new("Archer", "Red"),
new("Knight", "Cherry"),
],
[GiftContext.Quest_Goblin_A] =
[
new("Archer", "Blue"), new("Archer", "Green"),
new("Knight", "White"), new("Knight", "Yellow"),
],
[GiftContext.Quest_Goblin_S] =
[
new("Archer", "Black"),
new("Knight", "Black"),
],
[GiftContext.Quest_SciFi_C] =
[
new("Space Marine", "Blue"), new("Space Marine", "Orange"),
new("Smuggler", "Brown"), new("Smuggler", "Green"),
],
[GiftContext.Quest_SciFi_B] =
[
new("Space Marine", "Pink"), new("Space Marine", "Red"),
new("Smuggler", "Cherry"), new("Smuggler", "Yellow"),
],
[GiftContext.Quest_SciFi_A] =
[
new("Space Marine", "Purple"), new("Space Marine", "White"),
new("Smuggler", "Grey"), new("Smuggler", "Orange"),
],
[GiftContext.Quest_SciFi_S] =
[
new("Space Marine", "Black"),
new("Smuggler", "Black"),
],
[GiftContext.Quest_Cauldron_C] =
[
new("Witch Hunter", "Purple"), new("Witch Hunter", "Green"),
new("Wizard", "Blue"), new("Wizard", "Green"),
],
[GiftContext.Quest_Cauldron_B] =
[
new("Witch Hunter", "Red"),
new("Wizard", "Red"),
],
[GiftContext.Quest_Cauldron_A] =
[
new("Witch Hunter", "White"),
new("Wizard", "White"),
],
[GiftContext.Quest_Cauldron_S] =
[
new("Witch Hunter", "Black"),
new("Wizard", "Black"), new("Wizard", "Yellow"),
],
[GiftContext.Quest_Dracula1_C] = [new("Vampire Hunter", "Blue")],
[GiftContext.Quest_Dracula1_B] = [new("Vampire Hunter", "Red")],
[GiftContext.Quest_Dracula1_A] = [new("Vampire Hunter", "Leather")],
[GiftContext.Quest_Dracula1_S] = [new("Vampire Hunter", "Midnight")],
[GiftContext.Quest_Dracula1_SS] = [new("Dracula", "")],
[GiftContext.StuntRunner_TheMainEvent_C] =
[
new("Runner", "Green"), new("Runner", "Yellow"),
],
[GiftContext.StuntRunner_TheMainEvent_B] =
[
new("Runner", "Purple"), new("Runner", "Black"),
],
[GiftContext.StuntRunner_TheMainEvent_A] =
[
new("Stunt", "Fuchsia"), new("Stunt", "Blue"),
],
[GiftContext.StuntRunner_TheMainEvent_S] =
[
new("Stunt", "Green"), new("Stunt", "Red"),
],
[GiftContext.StuntRunner_TheMainEvent_SS] = [new("Feathered Hair", "")],
[GiftContext.Quest_Pirate1_C] =
[
new("Scallywag Bracers", "Brown"), new("Scallywag Belt", "Brown"),
new("Scallywag Bracers", "Red"), new("Scallywag Belt", "Red"),
new("Pirate Captain Cuffs", "Green"), new("Pirate Captain Belt", "Green"),
],
[GiftContext.Quest_Pirate1_B] =
[
new("Scallywag Belt", "Brown"), new("Scallywag Vest", "Brown"),
new("Scallywag Belt", "Red"), new("Scallywag Vest", "Red"),
new("Pirate Captain Belt", "Red"), new("Pirate Captain Jacket", "Red"),
new("Pirate Captain Belt", "Blue"), new("Pirate Captain Jacket", "Blue"),
],
[GiftContext.Quest_Pirate1_A] =
[
new("Scallywag Vest", "Brown"), new("Scallywag Hat", "Brown"),
new("Scallywag Vest", "Red"), new("Scallywag Hat", "Red"),
new("Pirate Captain Jacket", "Red"), new("Pirate Captain Hat", "Red"),
new("Pirate Captain Jacket", "Blue"), new("Pirate Captain Hat", "Blue"),
],
[GiftContext.Quest_Pirate1_S] =
[
new("Pirate Captain Cuffs", "Gold"), new("Pirate Captain Belt", "Gold"),
new("Pirate Captain Jacket", "Gold"), new("Pirate Captain Hat", "Gold"),
new("Pirate Eyepatch", "Black"), new("Pirate Eyepatch", "Skull"),
],
[GiftContext.Paintball_ClearCut] = [new("Lumberjack", "")],
[GiftContext.Paintball_Homestead] = [new("Farmer Hat", ""), new("Farmer Overalls", "")],
[GiftContext.Paintball_Quarry] = [new("Construction", "")],
[GiftContext.Paintball_River] = [new("Angler", "")],
[GiftContext.Paintball_Dam] = [new("Electrician", "")],
[GiftContext.Paintball_DriveIn] = [new("Mechanic", "")],
[GiftContext.Discgolf_Propulsion] = [new("Scientist", "")],
[GiftContext.Discgolf_Lake] =
[
new("Golfer Hat", "Tan"), new("Golfer Hat", "Blue"), new("Golfer Hat", "Grey"),
new("Golfer Shirt", "Tan"), new("Golfer Shirt", "Blue"), new("Golfer Shirt", "Grey"),
new("Golfer Gloves", "Tan"), new("Golfer Gloves", "Blue"), new("Golfer Gloves", "Grey"),
],
[GiftContext.Charades] = [new("Beret", ""), new("Artist Beret", "")],
[GiftContext.Soccer] = [new("Soccer", "")],
[GiftContext.Paddleball] = [new("Tennis", "")],
[GiftContext.Dodgeball] = [new("Dodgeball Shirt", "")],
[GiftContext.Lasertag] = [],
[GiftContext.Bowling] = [new("Dude Sweater", "")],
[GiftContext.RecRoyale] = [],
};
public static readonly IReadOnlyList<string> Unresolved =
[
"Crimson Cauldron S: the Brown Witch Hunter set - the catalogue's Witch Hunter pieces come in black, blue, green, leather, purple, red and white, and none in brown.",
"Isle of Lost Skulls X: no special-tier reward is named.",
"Isle of Lost Skulls B and A: the verified tables name a Pirate Captain Belt (Blue) for B and a Pirate Captain Hat (Blue) for A. The catalogue carries the Captain's blue jacket but no blue belt or hat, so those two pieces are not paid; the rest of each rank's table is.",
"Crimson Cauldron S: the Brown Witch Hunter is a real S-rank colour, but no item in the shipped catalogue carries it - its Witch Hunter pieces are black, blue, green, leather, purple, red and white. The descriptor or the in-game name is needed to name it here.",
"Rec Royale: its Season 1 rewards came from the Frontier Pass, a progression system of its own, so a completed match draws nothing. The Pass itself is not implemented.",
"Crescendo X: no special-tier reward is named.",
"Stunt Runner D and X: no reward is named for either tier, and the catalogue has no item for one.",
"Rec Royale: no concrete reward item is named.",
"Laser Tag: not a random-drop activity at all - a completed game pays tickets, and the shop they are spent in is not implemented yet.",
"Dodgeball: the definitions call the set the Dodger Vest in cyan, purple and orange; the catalogue carries the same three pieces as Dodgeball Shirt in orange, purple and teal.",
];
public static readonly IReadOnlyList<GiftContext> Deprecated =
[
GiftContext.DEPRECATED_Paintball_ClearCut,
GiftContext.DEPRECATED_Paintball_Homestead,
GiftContext.DEPRECATED_Paintball_Quarry,
GiftContext.DEPRECATED_Paintball_River,
GiftContext.DEPRECATED_Paintball_Dam,
GiftContext.DEPRECATED_Paintball_DriveIn,
GiftContext.DEPRECATED_Discgolf_Propulsion,
GiftContext.DEPRECATED_Discgolf_Lake,
GiftContext.DEPRECATED_Charades,
GiftContext.DEPRECATED_Soccer,
GiftContext.DEPRECATED_Paddleball,
GiftContext.DEPRECATED_Dodgeball,
GiftContext.DEPRECATED_Lasertag,
GiftContext.DEPRECATED_Bowling,
];
public static bool IsDeprecated(GiftContext context) => Deprecated.Contains(context);
public static IReadOnlyCollection<GiftContext> RewardableContexts => Pools.Keys;
public static IReadOnlyList<ItemSet> SetsFor(GiftContext context) =>
Pools.TryGetValue(context, out var sets) ? sets : [];
private static readonly Dictionary<string, Dictionary<string, GiftContext>> ByRoom =
new(StringComparer.OrdinalIgnoreCase)
{
["GoldenTrophy"] = Ranks(
("C", GiftContext.Quest_Goblin_C), ("B", GiftContext.Quest_Goblin_B),
("A", GiftContext.Quest_Goblin_A), ("S", GiftContext.Quest_Goblin_S)),
["TheRiseofJumbotron"] = Ranks(
("C", GiftContext.Quest_SciFi_C), ("B", GiftContext.Quest_SciFi_B),
("A", GiftContext.Quest_SciFi_A), ("S", GiftContext.Quest_SciFi_S)),
["CrimsonCauldron"] = Ranks(
("C", GiftContext.Quest_Cauldron_C), ("B", GiftContext.Quest_Cauldron_B),
("A", GiftContext.Quest_Cauldron_A), ("S", GiftContext.Quest_Cauldron_S)),
["Crescendo"] = Ranks(
("C", GiftContext.Quest_Dracula1_C), ("B", GiftContext.Quest_Dracula1_B),
("A", GiftContext.Quest_Dracula1_A), ("S", GiftContext.Quest_Dracula1_S)),
["IsleOfLostSkulls"] = Ranks(
("C", GiftContext.Quest_Pirate1_C), ("B", GiftContext.Quest_Pirate1_B),
("A", GiftContext.Quest_Pirate1_A), ("S", GiftContext.Quest_Pirate1_S)),
["StuntRunner"] = Ranks(
("C", GiftContext.StuntRunner_TheMainEvent_C),
("B", GiftContext.StuntRunner_TheMainEvent_B),
("A", GiftContext.StuntRunner_TheMainEvent_A),
("S", GiftContext.StuntRunner_TheMainEvent_S),
("SS", GiftContext.StuntRunner_TheMainEvent_SS),
("S+", GiftContext.StuntRunner_TheMainEvent_SS),
("SPlus", GiftContext.StuntRunner_TheMainEvent_SS)),
["PaintballClearcut"] = UnrankedContext(GiftContext.Paintball_ClearCut),
["Clearcut"] = UnrankedContext(GiftContext.Paintball_ClearCut),
["PaintballHomestead"] = UnrankedContext(GiftContext.Paintball_Homestead),
["Homestead"] = UnrankedContext(GiftContext.Paintball_Homestead),
["PaintballQuarry"] = UnrankedContext(GiftContext.Paintball_Quarry),
["Quarry"] = UnrankedContext(GiftContext.Paintball_Quarry),
["PaintballRiver"] = UnrankedContext(GiftContext.Paintball_River),
["River"] = UnrankedContext(GiftContext.Paintball_River),
["PaintballSpillway"] = UnrankedContext(GiftContext.Paintball_Dam),
["Spillway"] = UnrankedContext(GiftContext.Paintball_Dam),
["PaintballDriveIn"] = UnrankedContext(GiftContext.Paintball_DriveIn),
["DriveIn"] = UnrankedContext(GiftContext.Paintball_DriveIn),
["DiscGolfPropulsion"] = UnrankedContext(GiftContext.Discgolf_Propulsion),
["PropulsionTestRange"] = UnrankedContext(GiftContext.Discgolf_Propulsion),
["DiscGolfLake"] = UnrankedContext(GiftContext.Discgolf_Lake),
["3DCharades"] = UnrankedContext(GiftContext.Charades),
["Legacy3DCharades"] = UnrankedContext(GiftContext.Charades),
["Soccer"] = UnrankedContext(GiftContext.Soccer),
["Paddleball"] = UnrankedContext(GiftContext.Paddleball),
["Dodgeball"] = UnrankedContext(GiftContext.Dodgeball),
["DodgeballVR"] = UnrankedContext(GiftContext.Dodgeball),
["LaserTag"] = UnrankedContext(GiftContext.Lasertag),
["Bowling"] = UnrankedContext(GiftContext.Bowling),
["BowlingAlley"] = UnrankedContext(GiftContext.Bowling),
["RecRoyaleSolos"] = UnrankedContext(GiftContext.RecRoyale),
["RecRoyaleSquads"] = UnrankedContext(GiftContext.RecRoyale),
};
private static Dictionary<string, GiftContext> Ranks(params (string Rank, GiftContext Context)[] ranks) =>
ranks.ToDictionary(r => r.Rank, r => r.Context, StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, string> RoomAliases =
new(StringComparer.OrdinalIgnoreCase)
{
["TheMainEvent"] = "StuntRunner",
["StuntRunnerTheMainEvent"] = "StuntRunner",
["StuntRunnerMainEvent"] = "StuntRunner",
};
private static string? ResolveRoomName(string? roomName)
{
if (string.IsNullOrWhiteSpace(roomName)) return null;
var trimmed = roomName.Trim();
return RoomAliases.TryGetValue(trimmed, out var target) ? target : trimmed;
}
public static string? RoomForContext(GiftContext context)
{
foreach (var (room, ranks) in ByRoom)
{
if (ranks.Values.Contains(context)) return room;
}
return ConsumableRooms.TryGetValue(context, out var consumableRoom) ? consumableRoom : null;
}
private static readonly Dictionary<GiftContext, string> ConsumableRooms = new()
{
[GiftContext.Quest_Goblin_Consumable] = "GoldenTrophy",
[GiftContext.Quest_Cauldron_Consumable] = "CrimsonCauldron",
[GiftContext.Quest_Pirate1_Consumable] = "IsleOfLostSkulls",
[GiftContext.Quest_Dracula1_Consumable] = "Crescendo",
[GiftContext.Quest_Scifi_Consumable] = "TheRiseofJumbotron",
[GiftContext.StuntRunner_TheMainEvent_Consumable] = "StuntRunner",
};
private static Dictionary<string, GiftContext> UnrankedContext(GiftContext context) =>
new(StringComparer.OrdinalIgnoreCase) { [Unranked] = context };
private static readonly Dictionary<string, GiftContext> NoContexts = new();
public static IReadOnlyDictionary<string, GiftContext> ContextsForRoom(string? roomName)
{
var resolved = ResolveRoomName(roomName);
if (resolved == null) return NoContexts;
return ByRoom.TryGetValue(resolved, out var ranks) ? ranks : NoContexts;
}
public static bool IsRewardActivity(string? roomName) => ContextsForRoom(roomName).Count > 0;
public static bool IsQuestContext(GiftContext context)
{
var room = RoomForContext(context);
if (room == null) return false;
var contexts = ContextsForRoom(room);
return QuestRanks.Any(rank => contexts.ContainsKey(rank));
}
public static bool TryContext(string? roomName, string? rank, out GiftContext context)
{
context = GiftContext.None;
var contexts = ContextsForRoom(roomName);
if (contexts.Count == 0) return false;
return contexts.TryGetValue(string.IsNullOrWhiteSpace(rank) ? Unranked : rank.Trim(), out context);
}
public static List<GiftContext> LowerRanksOf(GiftContext context)
{
var lower = new List<GiftContext>();
foreach (var ranks in ByRoom.Values)
{
if (!ranks.ContainsValue(context)) continue;
var index = Array.FindIndex(QuestRanks, rank => ranks.TryGetValue(rank, out var paid) && paid == context);
if (index < 0) break;
for (int i = index - 1; i >= 0; i--)
if (ranks.TryGetValue(QuestRanks[i], out var weaker)) lower.Add(weaker);
break;
}
return lower;
}
public static bool PaysNoItemReward(string? roomName) =>
string.Equals(roomName?.Trim(), "RecRoyaleSolos", StringComparison.OrdinalIgnoreCase)
|| string.Equals(roomName?.Trim(), "RecRoyaleSquads", StringComparison.OrdinalIgnoreCase);
public static bool OffersSelection(string? roomName) =>
ContextsForRoom(roomName).ContainsKey(Unranked);
public static bool TryFloorContext(string? roomName, out GiftContext context)
{
context = GiftContext.None;
var contexts = ContextsForRoom(roomName);
if (contexts.Count == 0) return false;
if (contexts.TryGetValue(Unranked, out var only))
{
context = only;
return true;
}
foreach (var rank in QuestRanks)
{
if (contexts.TryGetValue(rank, out var ranked))
{
context = ranked;
return true;
}
}
return false;
}
public static List<AvatarItem> Resolve(ItemSet set, string? catalogueJson = null)
{
var colour = Normalise(set.Colour);
var pattern = string.IsNullOrEmpty(colour)
? @"^\s*" + Regex.Escape(set.Family) + @"\b"
: @"^\s*" + Regex.Escape(set.Family) + @"\b.*\(\s*" + Regex.Escape(colour) + @"\s*\)\s*$";
var rx = new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
return LevelUpGiftService.ClothingCatalogue(catalogueJson)
.Where(item => rx.IsMatch(Normalise(item.FriendlyName)))
.OrderBy(item => item.AvatarItemId)
.ThenBy(item => item.AvatarItemDesc, StringComparer.Ordinal)
.ToList();
}
private static string Normalise(string? value) =>
(value ?? "").Replace("gray", "grey", StringComparison.OrdinalIgnoreCase);
private static readonly ConcurrentDictionary<GiftContext, List<AvatarItem>> PoolCache = new();
public static List<AvatarItem> Resolve(GiftContext context, string? catalogueJson = null) =>
string.IsNullOrWhiteSpace(catalogueJson)
? PoolCache.GetOrAdd(context, ctx => ResolveCore(ctx, null))
: ResolveCore(context, catalogueJson);
private static List<AvatarItem> ResolveCore(GiftContext context, string? catalogueJson) =>
SetsFor(context)
.SelectMany(set => Resolve(set, catalogueJson))
.GroupBy(item => item.AvatarItemDesc, StringComparer.Ordinal)
.Select(group => group.First())
.OrderBy(item => item.AvatarItemId)
.ThenBy(item => item.AvatarItemDesc, StringComparer.Ordinal)
.ToList();
public static AvatarItem? Pick(
GiftContext context,
ISet<string>? owned = null,
Random? random = null,
string? catalogueJson = null)
{
var pool = Resolve(context, catalogueJson);
if (pool.Count == 0) return null;
var ownedKeys = owned ?? new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var unowned = pool
.Where(item => !ownedKeys.Contains(LevelUpRewardPool.OwnershipKey(item.AvatarItemDesc)))
.ToList();
return unowned.Count == 0 ? null : unowned[(random ?? Random.Shared).Next(unowned.Count)];
}
public static Gift? BuildGift(
GiftContext context,
ISet<string>? owned = null,
Random? random = null,
string? catalogueJson = null)
{
var pick = Pick(context, owned, random, catalogueJson);
if (pick == null) return null;
return new Gift
{
FriendlyName = pick.FriendlyName,
Tooltip = pick.FriendlyName,
AvatarItemDesc = pick.AvatarItemDesc,
AvatarItemType = AvatarItemType.Outfit,
Rarity = (Rarity)pick.Rarity,
CurrencyType = CurrencyType.Invalid,
Currency = 0,
Xp = 0,
Context = context
};
}
public static List<Gift> BuildDrops(
GiftContext context,
bool selection,
ISet<string>? owned = null,
Random? random = null,
string? catalogueJson = null)
{
var drops = new List<Gift>();
var claimed = owned == null
? new HashSet<string>(StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(owned, StringComparer.OrdinalIgnoreCase);
var rng = random ?? Random.Shared;
for (int i = 0; i < (selection ? SelectionSize : 1); i++)
{
var gift = BuildGift(context, claimed, rng, catalogueJson);
if (gift == null) break;
drops.Add(gift);
claimed.Add(LevelUpRewardPool.OwnershipKey(gift.AvatarItemDesc));
}
return drops;
}
public static bool PaysTickets(string? roomName) =>
string.Equals(roomName?.Trim(), "LaserTag", StringComparison.OrdinalIgnoreCase);
public const int LaserTagTicketsPerGame = 25;
public const int LaserTagFirstGamesBonus = 100;
public const int LaserTagBonusGamesPerDay = 5;
public static double DropChanceFor(GiftContext context) =>
context == GiftContext.Bowling ? 1.0 / 250.0 : 1.0;
public const int CompletionTokenAmount = 50;
public static Gift TokenGift(GiftContext context, int amount, string? message = null) => new()
{
GiftDropId = StorefrontShared.NewGiftDropId(),
FriendlyName = TokenTitle(amount),
Tooltip = string.IsNullOrWhiteSpace(message) ? "Reward for playing!" : message,
CurrencyType = CurrencyType.RecCenterTokens,
Currency = amount,
Context = context,
Rarity = amount >= 200 ? Rarity.Rare : Rarity.Common
};
public static string TokenTitle(int amount) => $"{amount} Tokens";
public const double TokenBoxChance = 0.12;
public static List<Gift> BuildFallbackSelection(
ISet<string>? owned = null,
Random? random = null,
string? catalogueJson = null,
string? message = null)
{
var rng = random ?? Random.Shared;
var drops = BuildFallbackDrops(selection: true, owned: owned, random: random, catalogueJson: catalogueJson)
.Take(SelectionSize)
.ToList();
if (drops.Count == 0)
return [TokenGift(GiftContext.GameRewards_Tokens, CompletionTokenAmount, message)];
var tokenBox = TokenGift(GiftContext.GameRewards_Tokens, CompletionTokenAmount, message);
if (drops.Count < SelectionSize)
drops.Add(tokenBox);
else if (rng.NextDouble() < TokenBoxChance)
drops[^1] = tokenBox;
return drops;
}
public static List<Gift> BuildDailyChallengeSelection(
ISet<string>? owned = null,
Random? random = null,
string? catalogueJson = null)
{
var drops = BuildFallbackDrops(selection: true, owned: owned, random: random, catalogueJson: catalogueJson)
.Take(SelectionSize)
.ToList();
foreach (var drop in drops)
drop.Context = GiftContext.Daily_Challenge_Complete;
return drops;
}
public const GiftContext ReacquisitionContext = GiftContext.Reacquisition;
private static readonly int[] NonRareRarities = [10, 20];
private static List<AvatarItem>? _reacquisitionPool;
private static readonly Regex RewardFamilyPattern = new(
@"^\s*(" + string.Join("|",
Pools.Values.SelectMany(sets => sets)
.Select(set => Regex.Escape(set.Family))
.Distinct(StringComparer.OrdinalIgnoreCase)) + @")\b",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
public static List<AvatarItem> ReacquisitionPool(string? catalogueJson = null)
{
if (!string.IsNullOrWhiteSpace(catalogueJson))
return FilterNonRare(LevelUpGiftService.ClothingCatalogue(catalogueJson));
return _reacquisitionPool ??= FilterNonRare(LevelUpGiftService.ClothingCatalogue(null));
}
private static List<AvatarItem> FilterNonRare(List<AvatarItem> catalogue) =>
catalogue
.Where(item => item != null
&& !string.IsNullOrWhiteSpace(item.AvatarItemDesc)
&& NonRareRarities.Contains(item.Rarity)
&& !LevelUpRewardPool.IsAlwaysDenied(item)
&& !IsRewardSetItem(item))
.OrderBy(item => item.AvatarItemId)
.ThenBy(item => item.AvatarItemDesc, StringComparer.Ordinal)
.ToList();
public static bool IsRewardSetItem(AvatarItem item) =>
item != null && RewardFamilyPattern.IsMatch(item.FriendlyName ?? string.Empty);
private static readonly HashSet<string> EmptyOwned = new(StringComparer.OrdinalIgnoreCase);
public static List<Gift> BuildFallbackDrops(
bool selection,
ISet<string>? owned = null,
Random? random = null,
string? catalogueJson = null,
string? consumableCatalogJson = null,
LevelUpRewardPool.Pool? consumablePool = null)
{
var rng = random ?? Random.Shared;
var has = owned == null
? new HashSet<string>(StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(owned, StringComparer.OrdinalIgnoreCase);
if (!selection)
{
var consumable = BuildFallbackConsumable(has, rng, consumableCatalogJson, consumablePool);
return consumable == null ? [] : [consumable];
}
var pool = ReacquisitionPool(catalogueJson);
var unowned = pool
.Where(item => !has.Contains(LevelUpRewardPool.OwnershipKey(item.AvatarItemDesc)))
.ToList();
return (unowned.Count > 0 ? unowned : pool)
.OrderBy(_ => rng.Next())
.Take(SelectionSize)
.Select(item => new Gift
{
FriendlyName = item.FriendlyName,
Tooltip = item.FriendlyName,
AvatarItemDesc = item.AvatarItemDesc,
AvatarItemType = AvatarItemType.Outfit,
Rarity = (Rarity)item.Rarity,
CurrencyType = CurrencyType.Invalid,
Currency = 0,
Xp = 0,
Context = ReacquisitionContext
})
.ToList();
}
public static Gift? BuildFallbackConsumable(
ISet<string>? owned = null,
Random? random = null,
string? consumableCatalogJson = null,
LevelUpRewardPool.Pool? pool = null)
{
var rng = random ?? Random.Shared;
var has = owned ?? EmptyOwned;
var gift = LevelUpGiftService.BuildConsumableGift(consumableCatalogJson, has, rng, pool)
?? LevelUpGiftService.BuildConsumableGift(consumableCatalogJson, EmptyOwned, rng, pool);
if (gift == null) return null;
gift.Context = ReacquisitionContext;
return gift;
}
}
}