mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
Radicade Public Code
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace RadicadeServer.Radicade.Gifts.Enums;
|
||||
|
||||
public enum ItemAccessibility
|
||||
{
|
||||
Private = -1,
|
||||
Exclusive,
|
||||
SubscribersOnly,
|
||||
DevelopersOnly,
|
||||
Purchasable = 10,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user