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
+10
View File
@@ -0,0 +1,10 @@
namespace RadicadeServer.RecNet.Avatar
{
public class Avatar
{
public string OutfitSelections { get; set; } = null!;
public string HairColor { get; set; } = null!;
public string SkinColor { get; set; } = null!;
public string FaceFeatures { get; set; } = null!;
}
}
+18
View File
@@ -0,0 +1,18 @@
namespace RadicadeServer.RecNet.Avatar
{
public class AvatarItem
{
public string AvatarItemDesc { get; set; } = null!;
public int UnlockedLevel { get; set; }
public int PlatformMask { get; set; } = -1;
public string FriendlyName { get; set; } = string.Empty;
public string Tooltip { get; set; } = string.Empty;
public int Rarity { get; set; }
public int AvatarItemType { get; set; }
public int AvatarItemId { get; set; }
public bool IsBaseAvatarItem { get; set; }
public string? TagList { get; set; }
public DateTime CreatedAt { get; set; }
public string ThumbnailImage { get; set; } = string.Empty;
}
}
+69
View File
@@ -0,0 +1,69 @@
using System.Text;
namespace RadicadeServer.RecNet.Avatar;
public static class AvatarItemDescription
{
public static string Normalize(string? description)
{
if (string.IsNullOrWhiteSpace(description))
{
return description?.Trim() ?? string.Empty;
}
var trimmed = description.Trim();
var segments = trimmed.Split(',');
if (segments[0].Length == 0)
{
return trimmed;
}
var hasEmptySlot = false;
for (var index = 1; index < segments.Length - 1; index++)
{
if (segments[index].Length == 0)
{
hasEmptySlot = true;
break;
}
}
if (!hasEmptySlot)
{
return trimmed;
}
var builder = new StringBuilder(segments[0]).Append(',');
for (var index = 1; index < segments.Length; index++)
{
var material = segments[index].Trim();
if (material.Length == 0)
{
continue;
}
builder.Append(material).Append(',');
}
return builder.ToString();
}
public static bool HasEmptyMaterialSlot(string? description)
{
if (string.IsNullOrWhiteSpace(description))
{
return false;
}
var segments = description.Trim().Split(',');
for (var index = 1; index < segments.Length - 1; index++)
{
if (segments[index].Length == 0)
{
return true;
}
}
return false;
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace RadicadeServer.RecNet.Avatar;
public sealed class AvatarItemV4(AvatarItem item)
{
public int AvatarItemId { get; } = item.AvatarItemId;
public string AvatarItemDesc { get; } = item.AvatarItemDesc;
public int AvatarItemType { get; } = item.AvatarItemType;
public int PlatformMask { get; } = item.PlatformMask;
public string FriendlyName { get; } = item.FriendlyName ?? string.Empty;
public string Tooltip { get; } = item.Tooltip ?? string.Empty;
public int Rarity { get; } = item.Rarity;
public string TagList { get; } = item.TagList ?? string.Empty;
public bool IsBaseAvatarItem { get; } = item.IsBaseAvatarItem;
public string ThumbnailImage { get; } = item.ThumbnailImage ?? string.Empty;
public DateTime CreatedAt { get; } = item.CreatedAt;
}
+256
View File
@@ -0,0 +1,256 @@
using RadicadeServer.Extensions;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace RadicadeServer.RecNet.Avatar;
public static class AvatarUnlockCatalog
{
public static string LoadStarterJson()
{
using var stream = typeof(AvatarUnlockCatalog).Assembly.GetManifestResourceStream("StarterAvatarItems.json")
?? throw new InvalidOperationException("The starter avatar inventory resource is missing.");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
public static List<AvatarItem> GetStarterItems()
{
var starters = EnrichWithMetadata(ParseCatalog(LoadStarterJson()), DBConfig.DefaultAvatarCatalog);
starters.AddRange(ParseCatalog(DBConfig.DefaultAvatarCatalog).Where(item => item.IsBaseAvatarItem));
return KeepClientAvailableItems(KeepResolvableItems(starters))
.DistinctBy(item => item.AvatarItemDesc, StringComparer.OrdinalIgnoreCase).ToList();
}
private static readonly JsonSerializerOptions CatalogOptions = new()
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
NumberHandling = JsonNumberHandling.AllowReadingFromString
};
public static List<AvatarItem> ParseCatalog(string? json)
{
var items = new List<AvatarItem>();
if (string.IsNullOrWhiteSpace(json)) return items;
try
{
using var document = JsonDocument.Parse(json, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
});
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
return items;
}
var index = 0;
foreach (var entry in document.RootElement.EnumerateArray())
{
try
{
var item = entry.Deserialize<AvatarItem>(CatalogOptions);
if (item != null && !string.IsNullOrWhiteSpace(item.AvatarItemDesc))
items.Add(item);
}
catch (JsonException)
{
}
index++;
}
}
catch (JsonException)
{
}
return KeepSupportedItems(items);
}
public static bool NeedsMetadataEnrichment(IEnumerable<AvatarItem>? items)
{
return items != null && items.Any(item =>
item == null || item.AvatarItemId <= 0 || string.IsNullOrWhiteSpace(item.ThumbnailImage));
}
public static List<AvatarItem> EnrichWithMetadata(IEnumerable<AvatarItem> items, string? metadataJson)
{
var metadata = ParseCatalog(metadataJson).ToDictionary(x => x.AvatarItemDesc, StringComparer.OrdinalIgnoreCase);
return items.Select(item => metadata.TryGetValue(item.AvatarItemDesc, out var full)
? new AvatarItem
{
AvatarItemDesc = item.AvatarItemDesc, UnlockedLevel = item.UnlockedLevel,
PlatformMask = item.PlatformMask, FriendlyName = full.FriendlyName,
Tooltip = full.Tooltip, Rarity = full.Rarity, AvatarItemType = full.AvatarItemType,
AvatarItemId = full.AvatarItemId, IsBaseAvatarItem = full.IsBaseAvatarItem,
TagList = full.TagList, CreatedAt = full.CreatedAt, ThumbnailImage = full.ThumbnailImage
}
: item).ToList();
}
private static readonly string[] KnownUnsafeDescriptionParts =
[
"07116e9b-b182-40b4-90c2-99738e7aedd6",
"11b3413f-2c18-41cb-8ef2-55fb825ed476",
"UjSxxUK7z0GGdjvI_NsJ3Q",
"X0nzvhAtgEKqzjQPb9d3qQ",
"6taO0KRM_kuKoaTnOIsHRg",
"KUAMuM41hk-YLZoqTiKncA,LjQ1UKN2xkqJkok40Dxaew",
"5aa7be74-19a9-4dd8-b525-46c5aef34866",
];
private static readonly Lazy<List<AvatarItem>> SafeDefaultItems = new(() =>
DBConfig.SafeDefaultAvatarUnlocks2023.FromJson<List<AvatarItem>>()
?? new List<AvatarItem>());
private static readonly Lazy<HashSet<string>> SafeDefaultDescriptions = new(() =>
SafeDefaultItems.Value
.Where(item => !string.IsNullOrWhiteSpace(item.AvatarItemDesc))
.Select(item => item.AvatarItemDesc)
.ToHashSet(StringComparer.OrdinalIgnoreCase));
public static List<AvatarItem> GetSafeDefaultItems()
{
var catalogJson = string.Equals(DBConfig.DefaultAvatarCatalog, DBConfig.SafeDefaultAvatarUnlocks2023,
StringComparison.Ordinal) ? DBConfig.DefaultAvatarUnlocks : DBConfig.DefaultAvatarCatalog;
var items = ParseCatalog(catalogJson);
if (items.Count == 0)
items = ParseCatalog(DBConfig.DefaultAvatarUnlocks);
var supportedItems = KeepResolvableItems(items);
var candidates = supportedItems.Count > 0
? supportedItems
: SafeDefaultItems.Value.Select(Clone).ToList();
var available = KeepClientAvailableItems(candidates);
return available.Count > 0 ? available : candidates;
}
public static List<AvatarItem> KeepClientAvailableItems(IEnumerable<AvatarItem>? items)
{
if (items == null)
{
return new List<AvatarItem>();
}
var cutoff = DBConfig.AvatarItemReleaseCutoffUtc;
if (cutoff == default)
{
return items.Where(item => item != null).ToList();
}
return items.Where(item => item != null && IsAvailableToClient(item, cutoff)).ToList();
}
public static bool IsAvailableToClient(AvatarItem item)
{
return IsAvailableToClient(item, DBConfig.AvatarItemReleaseCutoffUtc);
}
private static bool IsAvailableToClient(AvatarItem? item, DateTime cutoffUtc)
{
if (item == null || item.CreatedAt == default)
{
return true;
}
var createdAt = item.CreatedAt.Kind == DateTimeKind.Unspecified
? DateTime.SpecifyKind(item.CreatedAt, DateTimeKind.Utc)
: item.CreatedAt.ToUniversalTime();
return createdAt < cutoffUtc;
}
public static List<AvatarItem> KeepSupportedItems(IEnumerable<AvatarItem>? items)
{
if (items == null)
{
return new List<AvatarItem>();
}
return items
.Where(item =>
item != null &&
!string.IsNullOrWhiteSpace(item.AvatarItemDesc) &&
!IsKnownUnsafe(item.AvatarItemDesc))
.Select(Clone)
.ToList();
}
public static List<AvatarItem> KeepResolvableItems(IEnumerable<AvatarItem>? items)
{
var supportedItems = KeepSupportedItems(items);
var resolvable = DBConfig.ResolvableAvatarItemDescs;
if (resolvable == null || resolvable.Count == 0)
{
return supportedItems;
}
return supportedItems.Where(item => IsClientResolvableDescription(item.AvatarItemDesc)).ToList();
}
public static bool IsClientResolvableDescription(string? description)
{
if (string.IsNullOrWhiteSpace(description)) return false;
var resolvable = DBConfig.ResolvableAvatarItemDescs;
return resolvable == null || resolvable.Count == 0 ||
resolvable.Contains(AvatarItemDescription.Normalize(description));
}
public static AvatarItem? FindItem(string? avatarItemDesc, string? catalogueJson = null)
{
if (string.IsNullOrWhiteSpace(avatarItemDesc)) return null;
var wanted = AvatarItemDescription.Normalize(avatarItemDesc);
if (string.IsNullOrWhiteSpace(wanted)) return null;
var json = string.IsNullOrWhiteSpace(catalogueJson) ? DBConfig.DefaultAvatarCatalog : catalogueJson;
return CatalogueByDescription(json).TryGetValue(wanted, out var item) ? Clone(item) : null;
}
private static string? _byDescriptionCacheKey;
private static Dictionary<string, AvatarItem>? _byDescriptionCache;
private static Dictionary<string, AvatarItem> CatalogueByDescription(string? json)
{
if (_byDescriptionCache != null && string.Equals(_byDescriptionCacheKey, json, StringComparison.Ordinal))
return _byDescriptionCache;
var byDescription = new Dictionary<string, AvatarItem>(StringComparer.OrdinalIgnoreCase);
foreach (var item in ParseCatalog(json))
{
if (item == null || string.IsNullOrWhiteSpace(item.AvatarItemDesc)) continue;
if (!byDescription.ContainsKey(item.AvatarItemDesc)) byDescription[item.AvatarItemDesc] = item;
}
_byDescriptionCache = byDescription;
_byDescriptionCacheKey = json;
return byDescription;
}
public static bool IsKnownSafe(string avatarItemDesc)
{
return SafeDefaultDescriptions.Value.Contains(avatarItemDesc);
}
public static bool IsKnownUnsafe(string avatarItemDesc)
{
return KnownUnsafeDescriptionParts.Any(part =>
avatarItemDesc.Contains(part, StringComparison.OrdinalIgnoreCase));
}
private static AvatarItem Clone(AvatarItem item)
{
return new AvatarItem
{
AvatarItemDesc = AvatarItemDescription.Normalize(item.AvatarItemDesc),
UnlockedLevel = item.UnlockedLevel,
PlatformMask = item.PlatformMask,
FriendlyName = item.FriendlyName,
Tooltip = item.Tooltip,
Rarity = item.Rarity,
AvatarItemType = item.AvatarItemType,
AvatarItemId = item.AvatarItemId,
IsBaseAvatarItem = item.IsBaseAvatarItem,
TagList = item.TagList,
CreatedAt = item.CreatedAt,
ThumbnailImage = item.ThumbnailImage
};
}
}
+42
View File
@@ -0,0 +1,42 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.Utils.Attributes;
namespace RadicadeServer.API.Avatars.V1
{
[Route("api/avatar/v1")]
[Route("econ/api/avatar/v1")]
public class AvatarV1 : Controller
{
RecDBContext db;
public AvatarV1(RecDBContext context)
{
this.db = context;
}
[HttpGet("defaultunlocked")]
public List<AvatarItem> GetDefaultUnlocks()
{
return AvatarUnlockCatalog.GetStarterItems();
}
[HttpGet("defaultbaseavataritems")]
public IActionResult GetDefaultBaseAvatarItems()
{
try
{
return Ok(AvatarUnlockCatalog.GetSafeDefaultItems()
.Where(item => item.IsBaseAvatarItem).ToList());
}
catch (Exception)
{
return Ok(Array.Empty<AvatarItem>());
}
}
}
}
+546
View File
@@ -0,0 +1,546 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using RadicadeServer.API.Storefront;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.Utils;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.RecNet.Enums.Store;
using RadicadeServer.RecNet.Progression;
using RadicadeServer.RecNet.Store;
namespace RadicadeServer.API.Avatars.V2;
[Route("api/avatar/v2")]
[Route("econ/api/avatar/v2")]
public class AvatarV2(RecDBContext db) : Controller
{
[HttpGet(""), Authorize]
public async Task<Avatar?> GetLocalAvatar()
{
var userId = User.GetUserId();
if (userId == null) return null;
var avatar = db.Avatars.FirstOrDefault(x => x.AccountId == userId);
if (avatar != null)
{
if (NormalizeSavedAvatar(avatar))
{
await db.SaveChangesAsync();
}
return avatar.DeserializeAvatar();
}
avatar = new RadicadeServer.RecNet.Accounts.Local.PlayerAvatar
{
AccountId = userId.Value,
Avatar = DBConfig.DefaultAvatar,
Outfits = "[]"
};
await db.Avatars.AddAsync(avatar);
await db.SaveChangesAsync();
return avatar.DeserializeAvatar();
}
[HttpGet("{accountId:long}")]
public async Task<Avatar> GetAvatarForAccount(long accountId)
{
var saved = await db.Avatars.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == accountId);
var avatar = saved?.DeserializeAvatar();
if (avatar != null) return avatar;
return DBConfig.DefaultAvatar.FromJson<Avatar>() ?? new Avatar();
}
[HttpPost("set"), Authorize]
public async Task<Avatar?> SetLocalAvatar([FromBody] Avatar newAvatar)
{
var userId = User.GetUserId();
if (userId == null) return null;
var avatar = await db.Avatars.FirstOrDefaultAsync(x => x.AccountId == userId);
if (avatar == null)
{
avatar = new RadicadeServer.RecNet.Accounts.Local.PlayerAvatar
{
AccountId = userId.Value,
Avatar = DBConfig.DefaultAvatar,
Outfits = "[]"
};
await db.Avatars.AddAsync(avatar);
}
avatar.Avatar = newAvatar.ToJson();
await db.SaveChangesAsync();
return newAvatar;
} [HttpGet("gifts"), Authorize]
public async Task<IActionResult> GetGifts()
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var gifts = await StorefrontShared.GetPendingGiftsAsync(db, userId.Value);
return Ok(gifts.Select(g => g.ForClient()).ToList());
}
[HttpPost("gifts/claim"), Authorize]
public async Task<IActionResult> ClaimGifts()
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var requestedIds = await ReadGiftIdsFromBodyAsync();
var gifts = await StorefrontShared.GetPendingGiftsAsync(db, userId.Value);
var toClaim = requestedIds.Count > 0
? gifts.Where(g => requestedIds.Contains(g.GiftDropId)).ToList()
: gifts;
if (toClaim.Count == 0)
{
return GiftClaimResponse(new List<Gift>(), await GetPlayerLevelAsync(userId.Value));
}
var claimed = new List<Gift>();
var levelUpGifts = new List<Gift>();
var balanceGrants = new List<StorefrontShared.BalanceGrant>();
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var tx = await db.Database.BeginTransactionAsync();
try
{
foreach (var gift in toClaim)
{
var grant = await GrantGift(userId.Value, gift);
if (grant != null) balanceGrants.Add(grant);
await StorefrontShared.RemovePendingGiftAsync(db, userId.Value, gift.GiftDropId);
claimed.Add(gift);
}
levelUpGifts.AddRange(await LevelUpGiftService.DeliverLevelUpGiftsAsync(db, userId.Value, recomputeFromXp: true, notify: false));
await tx.CommitAsync();
await AnnounceBalancesAsync(userId.Value, balanceGrants, StorefrontBalanceAddTypes.FromGiftBox);
}
catch
{
await tx.RollbackAsync();
throw;
}
});
foreach (var gift in levelUpGifts)
await StorefrontShared.SendGiftPackageNotificationAsync(userId.Value, gift);
return GiftClaimResponse(claimed, await GetPlayerLevelAsync(userId.Value));
}
[HttpPost("gifts/generate"), Authorize]
public async Task<IActionResult> GenerateGift()
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var fields = await ReadGiftGenerateFieldsAsync();
if (!TryReadGiftContext(fields, "MFPELEJLJLD", "GiftContext", out var giftContext) ||
!TryReadOptionalGiftContext(fields, "CIEHFJEIOJJ", "AlternateGiftContext", out var alternateContext))
return BadRequest(new { error = "invalid_gift_context" });
fields.TryGetValue("Message", out var message);
if (message == null && fields.TryGetValue("DPAJDIEBNKM", out var opaqueMessage))
message = opaqueMessage;
var xp = TryFindField(fields, out var rawXp, "Xp", "XP") && int.TryParse(rawXp, out var parsedXp)
? parsedXp
: 0;
var context = (GiftContext)giftContext;
if (context is GiftContext.All_Weekly_Challenge_Complete or GiftContext.Weekly_Challenge_Complete)
{
var mapId = WeeklyChallenges.CreateMap(DateTime.UtcNow).ChallengeMapId;
var state = await db.WeeklyChallenges.AsNoTracking().SingleOrDefaultAsync(x =>
x.PlayerId == userId.Value && x.ChallengeMapId == mapId);
var pendingWeekly = await StorefrontShared.GetPendingGiftsAsync(db, userId.Value);
var weeklyGift = pendingWeekly.FirstOrDefault(x => x.GiftDropId == state?.RewardGiftDropId);
return weeklyGift == null
? Conflict(new { error = "weekly_reward_not_pending" })
: Ok(weeklyGift.ForClient());
}
void Trace(string line) => RewardTrace.Log(userId.Value, line);
var room = RroRewardPools.RoomForContext(context);
var paysReward = room != null && RroRewardPools.IsRewardActivity(room);
RewardTrace.Log(userId.Value, $"stage=Generate claimed={RewardTrace.Context(context)} " +
$"room={(room ?? "<none>")} isActivityReward={paysReward} xp={xp} message=\"{message}\"");
var gift = await GameRewardRedemption.TakeOutstandingAsync(db, userId.Value, context, message, Trace);
var source = gift == null ? null : "outstanding reward";
if (gift == null)
{
gift = await GameRewardRedemption.TakePendingBoxAsync(db, userId.Value, context, Trace);
if (gift != null) source = "box the selection route already handed over";
}
if (gift == null)
{
gift = await GameRewardRedemption.TakeRecentlyHandedOverAsync(
db, userId.Value, context, TimeSpan.FromMinutes(10), Trace);
if (gift != null) source = "box the server already handed over for this reward";
}
if (gift == null && paysReward)
{
gift = await GameRewardRedemption.BuildRoomRewardAsync(db, userId.Value, context, message, Trace);
if (gift != null)
source = "the activity's own reward, selected by the server";
else
{
var sets = string.Join(", ", RroRewardPools.SetsFor(context)
.Select(x => string.IsNullOrEmpty(x.Colour) ? x.Family : $"{x.Family} {x.Colour}"));
RewardTrace.Error(userId.Value,
"Unable to resolve the reward for this context. " +
$"Activity={room} Context={RewardTrace.Context(context)} ExpectedPool={sets} " +
$"EligibleItems={RroRewardPools.Resolve(context).Count} " +
"Reason=the activity's own pool, its lower ranks and the non-exclusive pool all " +
"resolved to nothing.");
}
}
if (gift == null)
{
RewardTrace.Log(userId.Value, $"stage=Fallback claimed={RewardTrace.Context(context)} " +
"is not an activity this server pays a reward for; generating the ordinary box.");
gift = BuildGeneratedGift(userId.Value, context, message, xp);
source = "generated box (no activity reward)";
}
await StorefrontShared.PersistGiftBoxAsync(db, userId.Value, gift, notify: false);
RewardTrace.Log(userId.Value, $"stage=Grant source={source} box={RewardTrace.Box(gift)}");
return Ok(gift.ForClient());
}
private async Task<Dictionary<string, string>> ReadGiftGenerateFieldsAsync()
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var field in form) fields[field.Key] = field.Value.FirstOrDefault() ?? string.Empty;
return fields;
}
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var raw = (await reader.ReadToEndAsync()).Trim();
Request.Body.Position = 0;
if (raw.Length == 0) return fields;
if (raw.StartsWith('{'))
{
try
{
using var document = JsonDocument.Parse(raw);
foreach (var property in document.RootElement.EnumerateObject())
fields[property.Name] = property.Value.ValueKind == JsonValueKind.String
? property.Value.GetString() ?? string.Empty
: property.Value.ToString();
}
catch (JsonException) { }
}
else
{
foreach (var pair in raw.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var parts = pair.Split('=', 2);
fields[Uri.UnescapeDataString(parts[0].Replace('+', ' '))] =
parts.Length > 1 ? Uri.UnescapeDataString(parts[1].Replace('+', ' ')) : string.Empty;
}
}
return fields;
}
private static bool TryReadGiftContext(IReadOnlyDictionary<string, string> fields, string opaqueName, string friendlyName, out int value)
{
value = 0;
if (!TryFindField(fields, out var raw, opaqueName, friendlyName)) return false;
if (!int.TryParse(raw, out value)) return false;
return Enum.IsDefined(typeof(GiftContext), value) && (GiftContext)value != GiftContext.None;
}
private static bool TryReadOptionalGiftContext(IReadOnlyDictionary<string, string> fields, string opaqueName, string friendlyName, out int? value)
{
value = null;
if (!TryFindField(fields, out var raw, opaqueName, friendlyName) || string.IsNullOrWhiteSpace(raw)) return true;
if (!int.TryParse(raw, out var parsed) || !Enum.IsDefined(typeof(GiftContext), parsed)) return false;
value = parsed;
return true;
}
private static bool TryFindField(IReadOnlyDictionary<string, string> fields, out string raw, params string[] names)
{
foreach (var name in names)
{
if (fields.TryGetValue(name, out var value))
{
raw = value;
return true;
}
}
raw = string.Empty;
return false;
}
private static Gift BuildGeneratedGift(long playerId, GiftContext context, string? message, int xp)
{
int[] tokenAmounts = [10, 25, 50, 100, 250, 500];
return new Gift
{
GiftDropId = StorefrontShared.NewGiftDropId(),
FromPlayerId = playerId,
Tooltip = message ?? string.Empty,
CurrencyType = CurrencyType.RecCenterTokens,
Currency = tokenAmounts[Random.Shared.Next(tokenAmounts.Length)],
Xp = xp,
Context = context,
BalanceType = 0,
Rarity = Rarity.Rare
};
}
[HttpPost("gifts/consume/{giftDropId:long}"), Authorize]
public async Task<IActionResult> ConsumeGift(long giftDropId)
=> await ConsumePlayerGift(giftDropId, await ReadGiftIdsFromBodyAsync());
[HttpPost("gifts/consume"), Authorize]
public async Task<IActionResult> ConsumeGiftBody()
=> await ConsumePlayerGift(null, await ReadGiftIdsFromBodyAsync());
private async Task<List<long>> ReadGiftIdsFromBodyAsync()
{
var ids = new List<long>();
try
{
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var raw = (await reader.ReadToEndAsync()).Trim();
Request.Body.Position = 0;
if (raw.Length == 0) return ids;
if (raw.StartsWith('{'))
{
using var doc = JsonDocument.Parse(raw);
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!string.Equals(prop.Name, "Id", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(prop.Name, "giftDropId", StringComparison.OrdinalIgnoreCase))
continue;
if (TryReadGiftId(prop.Value, out var v)) ids.Add(v);
}
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!string.Equals(prop.Name, "giftDropIds", StringComparison.OrdinalIgnoreCase) ||
prop.Value.ValueKind != JsonValueKind.Array)
continue;
foreach (var el in prop.Value.EnumerateArray())
if (TryReadGiftId(el, out var v)) ids.Add(v);
}
}
else
{
foreach (var pair in raw.Split('&'))
{
var kv = pair.Split('=', 2);
var key = Uri.UnescapeDataString(kv[0]).Trim();
if (!string.Equals(key, "Id", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(key, "giftDropId", StringComparison.OrdinalIgnoreCase))
continue;
var val = kv.Length > 1 ? Uri.UnescapeDataString(kv[1]).Trim() : "";
if (long.TryParse(val, out var v)) ids.Add(v);
}
if (ids.Count == 0 && long.TryParse(raw, out var bare)) ids.Add(bare);
}
}
catch (Exception)
{
}
return ids.Distinct().ToList();
}
private static bool TryReadGiftId(JsonElement el, out long v)
{
if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out v)) return true;
if (el.ValueKind == JsonValueKind.String && long.TryParse(el.GetString(), out v)) return true;
v = 0;
return false;
}
private async Task<IActionResult> ConsumePlayerGift(long? giftDropId, List<long> bodyIds)
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var requestedIds = new List<long>();
if (giftDropId is > 0) requestedIds.Add(giftDropId.Value);
if (bodyIds is { Count: > 0 }) requestedIds.AddRange(bodyIds);
var gifts = await StorefrontShared.GetPendingGiftsAsync(db, userId.Value);
var toConsume = requestedIds.Count > 0
? gifts.Where(g => requestedIds.Contains(g.GiftDropId)).ToList()
: gifts;
if (toConsume.Count == 0)
{
var delivered = requestedIds.Count > 0
? await HandedOverBoxIdsAsync(userId.Value, requestedIds)
: new List<long>();
if (delivered.Count > 0)
{
return GiftClaimResponse(new List<Gift>(), await GetPlayerLevelAsync(userId.Value));
}
return NotFound(new { error = "unable_to_consume_missing_or_used_gift", giftDropId });
}
var consumed = new List<Gift>();
var levelUpGifts = new List<Gift>();
var balanceGrants = new List<StorefrontShared.BalanceGrant>();
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var tx = await db.Database.BeginTransactionAsync();
try
{
foreach (var gift in toConsume)
{
var grant = await GrantGift(userId.Value, gift);
if (grant != null) balanceGrants.Add(grant);
await StorefrontShared.RemovePendingGiftAsync(db, userId.Value, gift.GiftDropId);
consumed.Add(gift);
}
levelUpGifts.AddRange(await LevelUpGiftService.DeliverLevelUpGiftsAsync(db, userId.Value, recomputeFromXp: true, notify: false));
await tx.CommitAsync();
await AnnounceBalancesAsync(userId.Value, balanceGrants, StorefrontBalanceAddTypes.FromGiftBox);
}
catch
{
await tx.RollbackAsync();
throw;
}
});
foreach (var gift in levelUpGifts)
await StorefrontShared.SendGiftPackageNotificationAsync(userId.Value, gift);
if (consumed.Count > 0)
await ProgressionAwardService.AwardXpAsync(db, userId.Value,
consumed.Count * ProgressionAwardService.XpPerGiftBoxOpened, "gift box");
return GiftClaimResponse(consumed, await GetPlayerLevelAsync(userId.Value));
}
private async Task<int> GetPlayerLevelAsync(long playerId)
{
var progression = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == playerId);
return progression?.Level ?? 1;
}
private async Task<List<long>> HandedOverBoxIdsAsync(long playerId, List<long> requestedIds)
{
var data = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == playerId);
var rewards = data?.GetProgressionJson()?.PendingGameRewards;
if (rewards == null) return new List<long>();
return rewards
.Where(r => r.ConsumedGiftDropId != 0 && requestedIds.Contains(r.ConsumedGiftDropId))
.Select(r => r.ConsumedGiftDropId)
.Distinct()
.ToList();
}
private IActionResult GiftClaimResponse(List<Gift> claimed, int unlockedLevel)
{
var body = new Dictionary<string, object?>
{
["consumed"] = claimed.Select(g => g.ForClient()).ToList(),
["UnlockedLevel"] = unlockedLevel
};
var first = claimed.FirstOrDefault();
if (first != null)
{
foreach (var kv in first.ForClient().ToJson().FromJson<Dictionary<string, object?>>() ?? new Dictionary<string, object?>())
body[kv.Key] = kv.Value;
}
return Ok(body);
}
private async Task<StorefrontShared.BalanceGrant?> GrantGift(long playerId, Gift gift)
{
return await StorefrontShared.GrantGiftAsync(db, playerId, gift);
}
private static async Task AnnounceBalancesAsync(long playerId, List<StorefrontShared.BalanceGrant> grants, StorefrontBalanceAddTypes addType)
{
foreach (var grant in grants)
await StorefrontShared.SendBalanceUpdateAsync(playerId, grant.CurrencyType, grant.Balance, grant.Granted, addType);
}
private static bool NormalizeSavedAvatar(RadicadeServer.RecNet.Accounts.Local.PlayerAvatar avatar)
{
if (string.IsNullOrWhiteSpace(avatar.Avatar))
{
avatar.Avatar = DBConfig.DefaultAvatar;
return true;
}
var original = avatar.Avatar;
avatar.Avatar = RepairKnownUnsafeOutfitSelections(avatar.Avatar);
return avatar.Avatar != original;
}
private static string RepairKnownUnsafeOutfitSelections(string avatarJson)
{
const string body = "d0a9262f-5504-46a7-bb10-7507503db58e";
const string accessory = "95e4cc30-cb68-473d-a395-feadf5b51512";
const string repairedOutfitSelection = body + "," + accessory
+ ",0440f08f-ef1d-49d8-942b-523056e8bb45,,1";
string[] shortOutfitSelections =
[
body + ",,,,1",
body + "," + accessory + ",,,1"
];
foreach (var shortOutfitSelection in shortOutfitSelections)
{
avatarJson = avatarJson.Replace(
shortOutfitSelection,
repairedOutfitSelection,
StringComparison.OrdinalIgnoreCase);
}
return avatarJson;
}
}
public class ClaimGiftsRequest
{
public List<long>? GiftDropIds { get; set; }
public long? GiftDropId { get; set; }
}
public class GenerateGiftRequest
{
public long? RecipientPlayerId { get; set; }
public long? RecipientId { get; set; }
public int? PurchasableItemId { get; set; }
public int? ItemId { get; set; }
public long? GiftDropId { get; set; }
public StorefrontTypes? Storefront { get; set; }
public CurrencyType? Currency { get; set; }
public long? Price { get; set; }
public string? Message { get; set; }
}
+50
View File
@@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RadicadeServer.API.Avatars.V2;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Avatar;
namespace RadicadeServer.API.Avatars.V3;
[Route("api/avatar/v3")]
[Route("econ/api/avatar/v3")]
public class AvatarV3(RecDBContext db) : Controller
{
[HttpGet("saved"), Authorize]
public async Task<List<Outfit>> GetSavedOutfits()
{
var userId = User.GetUserId();
if (userId == null) return new List<Outfit>();
return await SavedOutfits.GetAsync(db, userId.Value);
}
[HttpGet("saved/{accountId:long}"), Authorize]
public async Task<List<Outfit>> GetSavedOutfitsForAccount(long accountId)
{
return await SavedOutfits.GetAsync(db, accountId);
}
[HttpPost("saved/set"), Authorize]
public async Task<IActionResult> SetSavedOutfit([FromBody] Outfit? outfit)
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var saved = await SavedOutfits.SaveAsync(db, userId.Value, outfit);
if (saved == null) return BadRequest(new { error = "invalid_slot" });
return Ok(saved);
}
[HttpPost("gifts/generate"), Authorize]
public Task<IActionResult> GenerateGift()
{
var compatibilityController = new AvatarV2(db)
{
ControllerContext = ControllerContext
};
return compatibilityController.GenerateGift();
}
}
+65
View File
@@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.RecNet.Enums.Players;
namespace RadicadeServer.API.Avatars.V4;
[Route("api/avatar/v4")]
[Route("econ/api/avatar/v4")]
public class AvatarV4(RecDBContext db) : Controller
{
[HttpGet("items")]
public async Task<ActionResult<List<AvatarItemV4>>> GetLocalAvatarItems()
{
var userId = User.GetUserId();
List<AvatarItem> defaultItems;
try
{
defaultItems = LockerAccess.HasFullInventory(db, userId)
? AvatarUnlockCatalog.GetSafeDefaultItems()
: AvatarUnlockCatalog.GetStarterItems();
}
catch (Exception)
{
defaultItems = new List<AvatarItem>();
}
var playerItems = new List<AvatarItem>();
if (userId.HasValue)
{
var playerUnlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == userId);
var unlockedItems = playerUnlocks?.GetAvatarItems();
playerItems = User.FindFirst("rn.plat")?.Value == ((int)Platforms.Standalone).ToString()
? AvatarUnlockCatalog.KeepResolvableItems(unlockedItems)
: AvatarUnlockCatalog.KeepSupportedItems(unlockedItems);
}
var items = defaultItems
.Concat(playerItems)
.Where(x => !string.IsNullOrWhiteSpace(x.AvatarItemDesc))
.GroupBy(x => x.AvatarItemDesc, StringComparer.OrdinalIgnoreCase)
.Select(x => x.First())
.ToList();
return Ok(items.Select(item => new AvatarItemV4(item)).ToList());
}
[HttpPost("saved/set"), Authorize]
public async Task<IActionResult> SetSavedOutfit([FromBody] Outfit? outfit)
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var saved = await SavedOutfits.SaveAsync(db, userId.Value, outfit);
if (saved == null) return BadRequest(new { error = "invalid_slot" });
return Ok(new { Success = true, Slot = saved.Slot });
}
}
+27
View File
@@ -0,0 +1,27 @@
using RadicadeServer.RecNet.Enums.Store;
namespace RadicadeServer.RecNet.Avatar
{
public class Consumable
{
public long Id { get; set; }
public string ConsumableItemDesc { get; set; } = null!;
public int Count { get; set; }
public DateTime CreatedAt { get; set; }
public int UnlockedLevel { get; set; }
public int InitialCount { get; set; }
public bool IsActive { get; set; }
public int ActiveDurationMinutes { get; set; }
public ConsumableCategories Category { get; set; }
public int PlatformMask => -1;
}
}
+171
View File
@@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.Linq;
using RadicadeServer.Extensions;
namespace RadicadeServer.RecNet.Avatar
{
public static class ConsumableCatalog
{
private static readonly Dictionary<string, string> FriendlyToToken =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "20% Off Coupon", "50oCPkzd3EerBd7nYNdkCw" },
{ "87 Flavor Cake", "xwQWBB_fekmTqRc2LB92cg" },
{ "Assorted Donuts", "ZuvkidodzkuOfGLDnTOFyg" },
{ "Black & White Camera Film", "frOMH6WxDEG1fBqC4_83vg" },
{ "Bubble Tea", "JxAl1wVJ1UGAjoT3m3ilug" },
{ "Bubbly", "iiGTvhOCHkOTNJhb16Zbyw" },
{ "Bucket Orange Hair Dye", "xr45B5QC4EyKXThtwZto_A" },
{ "Butterfly Blue Hair Dye", "JwJeh15cjkOc9WWaGRibDQ" },
{ "Cake Pops", "Efd3_dy4ZEqWr7i6j5x0aA" },
{ "Candy Apples", "EmPvh3I6L0uK_1i8Wy_ylQ" },
{ "Cauldron Crimson Hair Dye", "3R1bzI35fkChoFFRFbponQ" },
{ "Celebration Cake", "Sk_1sm88ZU2zpWn01Lv7hw" },
{ "Cheese Pizza", "5hIAZ9wg5EyG1cILf4FS2A" },
{ "Chocolate Cake", "BbJb1_0g4EGJP_CeNrjpew" },
{ "Chocolate Frosted Donuts", "mMCGPgK3tki5S_15q2Z81A" },
{ "Chocolate Popcorn", "lWN_g4OcN0S0C4n6cSZq4g" },
{ "Chocolate Variety Box", "K9RbXo-4U0q6NbGu8VL1Sw" },
{ "Circuit Cerulean Hair Dye", "x9ntSHto50GpNWPwYCLUEg" },
{ "Dawn Camera Film", "m0bVLwWGj0GuIBSb6wCk6Q" },
{ "Dracula Film", "oG7CdvW7p0-S8sVe9w5vRw" },
{ "EDM Emerald Hair Dye", "4L3JoaqkAUa1kz98nciPXw" },
{ "Ego Potion", "Tpxqe_lycUelySRHM8B0Vw" },
{ "Flaming Popcorn", "uswFSANaZES0EJMLfOgNSw" },
{ "Frontier Forest Hair Dye", "YaVzcoefhk6zjbNDELLx8A" },
{ "Ghost Beard Film", "1c0Djlp090uBDczEobYNQw" },
{ "Gizmo Grape Hair Dye", "iEtWDLmv7ES87J4NE_TGJQ" },
{ "Glazed Donuts", "7OZ5AE3uuUyqa0P-2W1ptg" },
{ "Goblin Green Hair Dye", "jA3zzS31zEitBxtRCk7oew" },
{ "Goblin King Film", "6SyCoJCgo0Wd6qlPlnMOtg" },
{ "H2Oasis Blue Hair Dye", "P7HixsdkskmzgFMdFJ-WdA" },
{ "Hawaiian Pizza", "_jnjYGBcyEWY5Ub4OezXcA" },
{ "High Five O'Gold", "-hy0qD-iUk-v4NHxNzanmg" },
{ "High Five O'Gunpowder", "YEfbJTnsR0yT_p7e7tb_kQ" },
{ "High Five O'Lasers", "xHOwjwpXd0GDkvBz2VqieA" },
{ "High Five O'Spells", "VQSgL2pTLkWx4B3kwYG7UA" },
{ "High Five O'Whip", "lag2tZyB90W04lQ7ol4vMw" },
{ "Invasion Donuts", "-_VNAj3P10aWCCJZoVTF4Q" },
{ "Jumbotron Film", "Il4VmrnjDkqjmjzddqoIEw" },
{ "Jungle Jade Hair Dye", "5Ugg8k19n0WH8GPFCDWTCQ" },
{ "KO - Bear Claw", "ZtZnYBpKkECJlhHmkj4MiA" },
{ "KO - Bees", "g3kxdlJv5kO8PuBXveM48w" },
{ "KO - Cola", "wx--2TPTdEuGAqLCjs9Qag" },
{ "KO - Fangs", "XOZcxx-Klkyhe-MDbTqiwA" },
{ "KO - FryingPan", "uGVFydNSokCXFAmXu3aceQ" },
{ "KO - Grenade", "EAhk3ZZdXEmH5wRAXXT24Q" },
{ "KO - Ranger", "Av-wvjXvvkmNVSz7ZZnTiA" },
{ "KO - Shield", "5AJin8T2iEG7BzOPOgx2HA" },
{ "KO - Skull Wings", "U38Qe6rhEk6mFvArHfYjng" },
{ "KO - Star Power", "yvqSbK2czkS2sUCRdrGaEw" },
{ "KO - Tire Track", "J1WqFNUWo0OBi4LGKPDHWw" },
{ "KREQ Red Hair Dye", "P1kCuPlRc0-NAg6JUlEH_g" },
{ "Latte", "P15H1ONBhk-5DYYjid1ttg" },
{ "Lounge Lavender Hair Dye", "Hh_O_RejGE-_PubyM8xHaw" },
{ "Maker Mauve Hair Dye", "s2pxukIEt0uDeGVJmZayOw" },
{ "Mousebot Magenta Hair Dye", "vXL0rrAk70SFxJlaJk0uqQ" },
{ "PVPeach Hair Dye", "AQTYAh1it0GIGdgGO5sUHQ" },
{ "Paula's Periwinkle Hair Dye", "UAOH6ccEbEmehJg8XW_D8w" },
{ "Pepperoni Pizza", "mq23W-RSP0G8iGNLdrcpUw" },
{ "Pirate Gold Hair Dye", "o0hG5O_eaUO0R8vPyW-Hvg" },
{ "Popcorn", "QRx0aSTT9keMFdAJMQHdTg" },
{ "Propulsion Purple Hair Dye", "xRtKWR-D40GUW43R0EFpNg" },
{ "Ranger Roy-Al Blue Hair Dye", "8vlVslaWaUyFS-iDHhxW9g" },
{ "Rebel Rose Hair Dye", "STFKahjHJ0SQJPfoDe4S1g" },
{ "Recreational Raspberry Hair Dye", "eJh_BQ5y4UWwiMJzBKcwMQ" },
{ "Red Apples", "uMHrUPLYFk2rJOW_uop5Aw" },
{ "Red Velvet Cake", "LwaotjVEBUir0-w8126n_g" },
{ "Root Beer", "JfnVXFmilU6ysv-VbTAe3A" },
{ "Salted Pretzels", "InQ25wQMGkG_bvuD5rf2Ag" },
{ "Sepia Camera Film", "A5M-yf9tgUihq1uab3v58g" },
{ "Snowman Donuts", "jdrRJ7deWk2P9aRRrPy3zw" },
{ "Sparklers", "hRdK6NGfAkGBmYSs_xGzXg" },
{ "Standee Sandy Hair Dye", "inIuPzhhOEmz1RI8mZtSLg" },
{ "Supreme Pizza", "wUCIKdJSvEmiQHYMyx4X4w" },
{ "Sushi", "4My42w1D1Uu3-98pXB-x_Q" },
{ "VR Cola", "YpL6eN6sKEquOYIYnbhvew" },
{ "Witch Film", "mL3zCEuy2UWZxAV4V-OsMA" },
};
public static string ToClientDesc(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return value ?? string.Empty;
var parts = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return string.Join(",", parts.Select(p =>
FriendlyToToken.TryGetValue(p, out var token) ? token : p));
}
public const int DefaultCount = 5;
private const long GrantedIdBase = 800_000_000;
private const long GrantedIdSpan = 100_000_000;
private static readonly DateTime GrantedAt = new(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static readonly System.Text.Json.JsonSerializerOptions CatalogReadOptions = new()
{
PropertyNameCaseInsensitive = true,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
public static List<ConsumableItem> ParseItems(string? catalogJson)
{
if (string.IsNullOrWhiteSpace(catalogJson)) return new List<ConsumableItem>();
try
{
return catalogJson.FromJson<List<ConsumableItem>>(CatalogReadOptions) ?? new List<ConsumableItem>();
}
catch (System.Text.Json.JsonException)
{
return new List<ConsumableItem>();
}
}
public static List<Consumable> DefaultInstances(string? catalogJson)
{
var catalog = ParseItems(catalogJson);
if (catalog.Count == 0) return new List<Consumable>();
var instances = new List<Consumable>();
var taken = new HashSet<long>();
foreach (var item in catalog)
{
var desc = ToClientDesc(item.ConsumableItemDesc);
if (string.IsNullOrWhiteSpace(desc)) continue;
var id = StableId(desc);
while (!taken.Add(id)) id++;
instances.Add(new Consumable
{
Id = id,
ConsumableItemDesc = desc,
Count = DefaultCount,
InitialCount = DefaultCount,
CreatedAt = GrantedAt,
UnlockedLevel = 0,
IsActive = false,
ActiveDurationMinutes = 0
});
}
return instances;
}
private static long StableId(string token)
{
uint hash = 2166136261;
foreach (var c in token)
{
hash ^= c;
hash *= 16777619;
}
return GrantedIdBase + (hash % GrantedIdSpan);
}
}
}
+19
View File
@@ -0,0 +1,19 @@
using RadicadeServer.RecNet.Enums.Store;
namespace RadicadeServer.RecNet.Avatar
{
public class ConsumableItem
{
public string ConsumableItemDesc { get; set; } = "";
public string FriendlyName { get; set; } = "";
public string Tooltip { get; set; } = "";
public Rarity Rarity { get; set; } = Rarity.Common;
}
}
+428
View File
@@ -0,0 +1,428 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.IO;
using System.Text.Json;
using RadicadeServer.API.Notifications;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Avatar;
using RadicadeServer.API.Storefront;
using RadicadeServer.RecNet.Enums.Store;
using RadicadeServer.RecNet.Responses;
namespace RadicadeServer.API.Avatars.V2
{
[Route("api/consumables/v2")]
[Route("econ/api/consumables/v2")]
[Route("api/consumables/v1")]
[Route("econ/api/consumables/v1")]
public class ConsumablesV2 : Controller
{
RecDBContext db;
public ConsumablesV2(RecDBContext context)
{
this.db = context;
}
[HttpGet("getUnlocked"), Authorize]
public async Task<List<BulkConsumableResponse>?> GetUnlockedConsumables()
{
var userId = User.GetUserId();
var consumables = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == userId);
var owned = consumables?.GetConsumables() ?? new List<Consumable>();
var merged = new Dictionary<string, BulkConsumableResponse>(StringComparer.OrdinalIgnoreCase);
if (LockerAccess.HasFullInventory(db, userId))
{
foreach (var item in ConsumableCatalog.DefaultInstances(DBConfig.DefaultConsumableItems))
Add(merged, item);
foreach (var item in owned)
merged.Remove(ConsumableCatalog.ToClientDesc(item.ConsumableItemDesc));
}
foreach (var instance in owned)
Add(merged, instance);
return merged.Values.ToList();
}
/// <summary>
/// The free gift button's list: the consumables that may be handed to the named player.
/// The client asks for this before it opens the box, so a 404 leaves the button sitting on
/// "Processing" with nothing behind it and the screen never resolves. The body is the same
/// bulk consumable the unlocked list serves - only IsTransferable differs, because that is
/// the member the button filters on - and it answers even for an account that owns nothing,
/// which is what keeps the box offered to a player who has never opened one.
/// </summary>
[HttpGet("getTransferable/{playerId:long}"), Authorize]
[HttpGet("getTransferable"), Authorize]
public async Task<List<BulkConsumableResponse>?> GetTransferable(long playerId = 0)
{
var owner = playerId > 0 ? playerId : User.GetUserId() ?? 0;
var merged = new Dictionary<string, BulkConsumableResponse>(StringComparer.OrdinalIgnoreCase);
foreach (var item in ConsumableCatalog.DefaultInstances(DBConfig.DefaultConsumableItems))
Add(merged, item);
if (owner > 0)
{
var unlocks = await db.Unlocks.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == owner);
var owned = unlocks?.GetConsumables() ?? new List<Consumable>();
foreach (var item in owned)
merged.Remove(ConsumableCatalog.ToClientDesc(item.ConsumableItemDesc));
foreach (var item in owned)
Add(merged, item);
}
var transferable = merged.Values.ToList();
foreach (var entry in transferable)
entry.IsTransferable = true;
return transferable;
}
/// <summary>
/// Hands one of the caller's consumables to another player - the half of the free gift
/// button that getTransferable only sets up. The client posts the entry it showed and then
/// waits for this answer; with no route the request 404s and the button stays on
/// "Processing" with the gift already looking sent. One unit leaves the sender's stack (the
/// entry is dropped when that was the last of it) and reaches the recipient as a gift
/// package, the same box the friend box arrives in, so it appears on their watch without a
/// relog. The answer is the two obfuscated members the client actually reads, not a
/// PascalCase body it would treat as a failure.
/// </summary>
[HttpPost("transfer"), Authorize]
public async Task<IActionResult> TransferConsumable()
{
var senderId = User.GetUserId();
if (senderId == null) return Unauthorized();
var fields = await ReadBodyFieldsAsync();
var toPlayerId = PickLong(fields, "toPlayerId", "playerId", "playerIdTo", "recipientId", "targetPlayerId", "accountId");
if (toPlayerId is not > 0)
return Ok(ClientAction.Failure("No player was named to receive the item."));
if (toPlayerId == senderId.Value)
return Ok(ClientAction.Failure("You cannot hand a consumable to yourself."));
if (!await db.Players.AsNoTracking().AnyAsync(player => player.AccountId == toPlayerId.Value))
return Ok(ClientAction.Failure($"Player {toPlayerId.Value} does not exist."));
var sender = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == senderId.Value);
var owned = sender?.GetConsumables() ?? new List<Consumable>();
var target = MatchConsumable(owned, fields);
if (target == null || target.Count <= 0)
return Ok(ClientAction.Failure("You do not own that consumable."));
var message = Pick(fields, "message", "tooltip", "note");
var box = new Gift
{
FriendlyName = target.ConsumableItemDesc,
Tooltip = string.IsNullOrWhiteSpace(message) ? "A gift for you <3" : message.Trim(),
FromPlayerId = senderId.Value,
ConsumableItemDesc = ConsumableCatalog.ToClientDesc(target.ConsumableItemDesc),
Context = GiftContext.Consumable
};
if (target.InitialCount > 1)
{
target.InitialCount -= 1;
target.Count = target.InitialCount;
}
else
{
owned.Remove(target);
}
sender!.SetConsumables(owned);
await StorefrontShared.PersistGiftBoxAsync(db, toPlayerId.Value, box, notify: true);
await Notify.SendAccountUpdate(senderId.Value);
return Ok(ClientAction.Ok());
}
/// <summary>
/// Marks which of the player's consumables is the active one. The client asks for this as it
/// enters a room, once per entry it wants switched on, and a 404 leaves the effect that the
/// consumable bought (a hair dye, a rank-up potion) unapplied. Only the named entry is turned
/// on, and turning one on is what turns the others off, so a second activation never leaves
/// two effects claiming the same slot. The answer has no body, which is what the route's own
/// wrapper returns.
/// </summary>
[HttpPost("updateActive"), Authorize]
public async Task<IActionResult> UpdateActive()
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var fields = await ReadBodyFieldsAsync();
var activates = PickBool(fields, "isActive", "active", "activate", "enabled") ?? true;
var unlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == userId.Value);
if (unlocks == null) return Ok();
var consumables = unlocks.GetConsumables();
var target = MatchConsumable(consumables, fields);
if (target == null) return Ok();
if (activates)
{
var targetDesc = ConsumableCatalog.ToClientDesc(target.ConsumableItemDesc);
foreach (var other in consumables)
{
if (!string.Equals(ConsumableCatalog.ToClientDesc(other.ConsumableItemDesc), targetDesc,
StringComparison.OrdinalIgnoreCase))
other.IsActive = false;
}
}
target.IsActive = activates;
unlocks.SetConsumables(consumables);
await db.SaveChangesAsync();
return Ok();
}
/// <summary>
/// The entry the request names, by the id the unlocked list handed out and - when the client
/// only echoed the description back - by its client token, alone or as one member of a list.
/// </summary>
private static Consumable? MatchConsumable(List<Consumable> owned, Dictionary<string, string> fields)
{
var id = PickLong(fields, "id", "consumableItemId", "consumableId", "itemId", "consumableMappingId");
if (id is > 0)
{
var byId = owned.FirstOrDefault(c => c.Id == id.Value);
if (byId != null) return byId;
}
var desc = Pick(fields, "consumableItemDesc", "desc", "descriptions", "consumableItem", "itemDesc");
if (string.IsNullOrWhiteSpace(desc)) return null;
var wanted = ConsumableCatalog.ToClientDesc(desc);
var candidates = wanted
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(part => !string.IsNullOrWhiteSpace(part))
.ToList();
if (candidates.Count == 0) candidates.Add(wanted);
foreach (var candidate in candidates)
{
var byDesc = owned.FirstOrDefault(c =>
string.Equals(ConsumableCatalog.ToClientDesc(c.ConsumableItemDesc), candidate, StringComparison.OrdinalIgnoreCase)
|| string.Equals(c.ConsumableItemDesc, candidate, StringComparison.OrdinalIgnoreCase));
if (byDesc != null) return byDesc;
}
return null;
}
/// <summary>
/// The request's members as a flat map. The body is a serialized request DTO whose members
/// are not published, and the entry the client names sits one level down in a nested
/// consumable, so every leaf is folded in by name instead of binding to a guessed shape; a
/// form-encoded body is read the same way.
/// </summary>
private async Task<Dictionary<string, string>> ReadBodyFieldsAsync()
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var raw = (await reader.ReadToEndAsync()).Trim();
if (Request.Body.CanSeek) Request.Body.Position = 0;
if (raw.Length == 0) return fields;
if (raw.StartsWith('{') || raw.StartsWith('['))
{
using var document = JsonDocument.Parse(raw);
Flatten(document.RootElement, fields);
return fields;
}
foreach (var pair in raw.Split('&'))
{
var kv = pair.Split(new[] { '=' }, 2);
if (kv.Length < 2) continue;
fields[Uri.UnescapeDataString(kv[0])] = Uri.UnescapeDataString(kv[1]);
}
}
catch (Exception)
{
}
return fields;
}
private static void Flatten(JsonElement element, Dictionary<string, string> into)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
foreach (var property in element.EnumerateObject())
{
if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
Flatten(property.Value, into);
else
into[property.Name] = property.Value.ValueKind == JsonValueKind.String
? property.Value.GetString() ?? string.Empty
: property.Value.ToString();
}
break;
case JsonValueKind.Array:
foreach (var item in element.EnumerateArray()) Flatten(item, into);
break;
}
}
private static string Pick(Dictionary<string, string> fields, params string[] names)
{
foreach (var name in names)
if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value))
return value.Trim();
return string.Empty;
}
private static long? PickLong(Dictionary<string, string> fields, params string[] names)
{
var raw = Pick(fields, names);
return long.TryParse(raw, out var parsed) ? parsed : null;
}
private static bool? PickBool(Dictionary<string, string> fields, params string[] names)
{
var raw = Pick(fields, names);
if (raw.Length == 0) return null;
return bool.TryParse(raw, out var parsed) ? parsed : null;
}
public class ConsumeRequest
{
public long Id { get; set; }
public int DeltaCount { get; set; } = 1;
}
[HttpPost("consume"), Authorize]
public async Task<IActionResult> ConsumeConsumable()
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
var dto = await ReadConsumeRequestAsync();
if (dto == null || dto.Id <= 0) return Ok();
if (dto.DeltaCount <= 0) dto.DeltaCount = 1;
var unlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == userId.Value);
if (unlocks == null)
{
unlocks = new PlayerUnlocks { AccountId = userId.Value };
await db.Unlocks.AddAsync(unlocks);
}
var consumables = unlocks.GetConsumables();
var target = consumables.FirstOrDefault(c => c.Id == dto.Id);
if (target == null)
{
return Ok();
}
if (target.InitialCount > dto.DeltaCount)
{
target.InitialCount -= dto.DeltaCount;
target.Count = target.InitialCount;
}
else
{
consumables.Remove(target);
}
unlocks.SetConsumables(consumables);
await db.SaveChangesAsync();
return Ok();
}
private async Task<ConsumeRequest?> ReadConsumeRequestAsync()
{
try
{
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var raw = (await reader.ReadToEndAsync()).Trim();
Request.Body.Position = 0;
if (raw.Length == 0) return null;
var dto = new ConsumeRequest();
if (raw.StartsWith('{'))
{
using var doc = JsonDocument.Parse(raw);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (prop.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) && long.TryParse(prop.Value.GetRawText(), out var id))
dto.Id = id;
if (prop.Name.Equals("DeltaCount", StringComparison.OrdinalIgnoreCase) && int.TryParse(prop.Value.GetRawText(), out var delta))
dto.DeltaCount = delta;
}
return dto;
}
foreach (var pair in raw.Split('&'))
{
var kv = pair.Split(new[] { '=' }, 2);
if (kv.Length < 2) continue;
var key = Uri.UnescapeDataString(kv[0]);
var value = Uri.UnescapeDataString(kv[1]);
if (key.Equals("Id", StringComparison.OrdinalIgnoreCase) && long.TryParse(value, out var id))
dto.Id = id;
if (key.Equals("DeltaCount", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var delta))
dto.DeltaCount = delta;
}
return dto;
}
catch (Exception)
{
return null;
}
}
private static void Add(Dictionary<string, BulkConsumableResponse> merged, Consumable instance)
{
var desc = ConsumableCatalog.ToClientDesc(instance.ConsumableItemDesc);
if (string.IsNullOrWhiteSpace(desc)) return;
if (!merged.TryGetValue(desc, out var entry))
{
entry = new BulkConsumableResponse { ConsumableItemDesc = desc };
merged[desc] = entry;
}
entry.Ids.Add(instance.Id);
entry.CreatedAts.Add(instance.CreatedAt);
entry.Count += instance.Count;
entry.InitialCount += instance.InitialCount;
entry.IsActive |= instance.IsActive;
entry.ActiveDurationMinutes = Math.Max(
entry.ActiveDurationMinutes ?? 0, instance.ActiveDurationMinutes);
}
public class BulkConsumableResponse
{
public List<long> Ids { get; set; } = new List<long>();
public List<DateTime> CreatedAts { get; set; } = new List<DateTime>();
public string ConsumableItemDesc { get; set; } = "";
public int Count { get; set; }
public int InitialCount { get; set; }
public bool IsActive { get; set; }
public int? ActiveDurationMinutes { get; set; }
public bool IsTransferable { get; set; }
}
}
}
+16
View File
@@ -0,0 +1,16 @@
using RadicadeServer.RecNet.Enums.Store;
namespace RadicadeServer.RecNet.Avatar
{
public class Equipment
{
public string PrefabName { get; set; } = null!;
public string ModificationGuid { get; set; } = null!;
public int UnlockedLevel { get; set; }
public bool Favorited { get; set; }
public int PlatformMask => -1;
public string FriendlyName { get; set; } = null!;
public string Tooltip { get; set; } = null!;
public Rarity Rarity { get; set; }
}
}
+19
View File
@@ -0,0 +1,19 @@
using RadicadeServer.RecNet.Enums.Store;
namespace RadicadeServer.RecNet.Avatar
{
public class EquipmentItem
{
public string EquipmentPrefabName { get; set; } = "";
public string FriendlyName { get; set; } = "";
public string Tooltip { get; set; } = "";
public Rarity Rarity { get; set; } = Rarity.Common;
}
}
+123
View File
@@ -0,0 +1,123 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Enums.Store;
namespace RadicadeServer.RecNet.Avatar
{
public class EquipmentSkinItem
{
[JsonPropertyName("prefab_name")]
public string PrefabName { get; set; } = "";
[JsonPropertyName("modification_guid")]
public string ModificationGuid { get; set; } = "";
[JsonPropertyName("display_name")]
public string DisplayName { get; set; } = "";
[JsonPropertyName("tooltip")]
public string Tooltip { get; set; } = "";
[JsonPropertyName("rarity")]
public Rarity Rarity { get; set; } = Rarity.Common;
[JsonPropertyName("storefront")]
public string Storefront { get; set; } = "";
[JsonPropertyName("price")]
public long Price { get; set; }
[JsonPropertyName("image_name")]
public string ImageName { get; set; } = "";
}
public class EquipmentSkinFile
{
[JsonPropertyName("items")]
public List<EquipmentSkinItem> Items { get; set; } = new();
}
public static class EquipmentSkinCatalog
{
private static readonly JsonSerializerOptions ReadOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public static List<EquipmentSkinItem> Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new List<EquipmentSkinItem>();
try
{
var file = JsonSerializer.Deserialize<EquipmentSkinFile>(json, ReadOptions);
return (file?.Items ?? new List<EquipmentSkinItem>())
.Where(item => item != null
&& !string.IsNullOrWhiteSpace(item.PrefabName)
&& !string.IsNullOrWhiteSpace(item.ModificationGuid))
.ToList();
}
catch (JsonException)
{
return new List<EquipmentSkinItem>();
}
}
public static List<Equipment> ToEquipment(IEnumerable<EquipmentSkinItem> skins)
{
return skins.Select(skin => new Equipment
{
PrefabName = skin.PrefabName.Trim(),
ModificationGuid = skin.ModificationGuid.Trim(),
UnlockedLevel = 0,
Favorited = false,
FriendlyName = string.IsNullOrWhiteSpace(skin.DisplayName)
? skin.PrefabName.Trim()
: skin.DisplayName.Trim(),
Tooltip = skin.Tooltip ?? "",
Rarity = skin.Rarity
}).ToList();
}
public static string MergeInto(string? equipmentJson, string? skinsJson)
{
var skins = ToEquipment(Parse(skinsJson));
if (skins.Count == 0) return equipmentJson ?? "[]";
var equipment = ParseEquipment(equipmentJson);
if (equipment == null) return equipmentJson ?? "[]";
var known = new HashSet<string>(
equipment.Select(Key), StringComparer.OrdinalIgnoreCase);
var added = 0;
foreach (var skin in skins)
{
if (!known.Add(Key(skin))) continue;
equipment.Add(skin);
added++;
}
return added == 0 ? equipmentJson ?? "[]" : equipment.ToJson();
}
private static List<Equipment>? ParseEquipment(string? equipmentJson)
{
if (string.IsNullOrWhiteSpace(equipmentJson)) return new List<Equipment>();
try
{
return equipmentJson.FromJson<List<Equipment>>(ReadOptions) ?? new List<Equipment>();
}
catch (JsonException)
{
return null;
}
}
private static string Key(Equipment item) =>
$"{(item.PrefabName ?? "").Trim()}\u001f{(item.ModificationGuid ?? "").Trim()}";
}
}
+168
View File
@@ -0,0 +1,168 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Avatar;
namespace RadicadeServer.API.Avatars.V1;
[Route("api/equipment/v1")]
[Route("econ/api/equipment/v1")]
[Authorize]
public class EquipmentV1(RecDBContext db) : Controller
{
[HttpPost("update")]
[HttpPut("update")]
public async Task<IActionResult> UpdateEquipment([FromBody] JsonElement body)
{
var userId = User.GetUserId();
if (userId == null) return Unauthorized();
if (!TryParse(body, out var entries))
return BadRequest();
var slots = entries.Where(entry => entry.HasSlot).ToList();
var favourites = entries.Where(entry => entry.Favorited.HasValue).ToList();
if (slots.Count == 0 && favourites.Count == 0)
return Ok();
if (slots.Count > 0)
{
var avatar = await db.Avatars.FirstOrDefaultAsync(x => x.AccountId == userId);
if (avatar != null)
{
avatar.EquipmentSlots = slots.Select(entry => (object)new
{
slotIndex = entry.SlotIndex,
equipmentPrefabName = entry.PrefabName ?? string.Empty,
equipmentModificationGuid = entry.ModificationGuid ?? string.Empty
}).ToList().ToJson();
}
}
if (favourites.Count > 0)
await ApplyFavouritesAsync(userId.Value, favourites);
await db.SaveChangesAsync();
return Ok();
}
private async Task ApplyFavouritesAsync(long userId, List<EquipmentUpdate> favourites)
{
var unlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == userId);
if (unlocks == null)
{
unlocks = new PlayerUnlocks { AccountId = userId };
db.Unlocks.Add(unlocks);
}
List<Equipment> equipment;
try
{
equipment = unlocks.GetEquipment() ?? new List<Equipment>();
}
catch (JsonException)
{
equipment = new List<Equipment>();
}
foreach (var update in favourites)
{
if (string.IsNullOrWhiteSpace(update.ModificationGuid)) continue;
var row = equipment.FirstOrDefault(item => string.Equals(
item.ModificationGuid, update.ModificationGuid, StringComparison.OrdinalIgnoreCase));
if (row == null)
{
continue;
}
row.Favorited = update.Favorited == true;
}
unlocks.Equipment = equipment.ToJson();
}
private sealed class EquipmentUpdate
{
public string? PrefabName { get; set; }
public string? ModificationGuid { get; set; }
public bool? Favorited { get; set; }
public int SlotIndex { get; set; }
public bool HasSlot { get; set; }
}
private static bool TryParse(JsonElement body, out List<EquipmentUpdate> entries)
{
entries = new List<EquipmentUpdate>();
JsonElement array;
if (body.ValueKind == JsonValueKind.Array)
{
array = body;
}
else if (body.ValueKind == JsonValueKind.Object)
{
var found = false;
array = default;
foreach (var prop in body.EnumerateObject())
{
if (string.Equals(prop.Name, "equipmentSlots", StringComparison.OrdinalIgnoreCase)
&& prop.Value.ValueKind == JsonValueKind.Array)
{
array = prop.Value;
found = true;
break;
}
}
if (!found) return false;
}
else
{
return false;
}
foreach (var item in array.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object) continue;
var entry = new EquipmentUpdate();
foreach (var prop in item.EnumerateObject())
{
if (string.Equals(prop.Name, "slotIndex", StringComparison.OrdinalIgnoreCase)
|| string.Equals(prop.Name, "slot", StringComparison.OrdinalIgnoreCase)
|| string.Equals(prop.Name, "index", StringComparison.OrdinalIgnoreCase))
{
if (prop.Value.ValueKind == JsonValueKind.Number) entry.SlotIndex = prop.Value.GetInt32();
entry.HasSlot = true;
}
else if (string.Equals(prop.Name, "favorited", StringComparison.OrdinalIgnoreCase)
|| string.Equals(prop.Name, "favourite", StringComparison.OrdinalIgnoreCase))
{
if (prop.Value.ValueKind == JsonValueKind.True) entry.Favorited = true;
else if (prop.Value.ValueKind == JsonValueKind.False) entry.Favorited = false;
}
else if (string.Equals(prop.Name, "modificationGuid", StringComparison.OrdinalIgnoreCase)
|| string.Equals(prop.Name, "equipmentModificationGuid", StringComparison.OrdinalIgnoreCase))
{
if (prop.Value.ValueKind == JsonValueKind.String) entry.ModificationGuid = prop.Value.GetString();
}
else if (string.Equals(prop.Name, "prefabName", StringComparison.OrdinalIgnoreCase)
|| string.Equals(prop.Name, "equipmentPrefabName", StringComparison.OrdinalIgnoreCase)
|| string.Equals(prop.Name, "equipment", StringComparison.OrdinalIgnoreCase))
{
if (prop.Value.ValueKind == JsonValueKind.String) entry.PrefabName = prop.Value.GetString();
}
}
entries.Add(entry);
}
return true;
}
}
+83
View File
@@ -0,0 +1,83 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Accounts;
using RadicadeServer.RecNet.Avatar;
namespace RadicadeServer.API.Avatars.V2
{
[Route("api/equipment/v2")]
[Route("econ/api/equipment/v2")]
public class EquipmentV2 : Controller
{
RecDBContext db;
public EquipmentV2(RecDBContext context)
{
this.db = context;
}
[HttpGet("getUnlocked"), Authorize]
public List<Equipment> GetUnlockedEquipment()
{
var userId = User.GetUserId();
var items = db.Unlocks.FirstOrDefault(x => x.AccountId == userId);
var defaultEquipment = TryReadEquipment(LockerAccess.HasFullInventory(db, userId)
? EquipmentSkinCatalog.MergeInto(DBConfig.DefaultEquipmentItems, DBConfig.DefaultEquipmentSkinItems)
: DBConfig.DefaultEquipmentUnlocks);
var playerEquipment = GetPlayerEquipment(items);
return MergeEquipment(defaultEquipment, playerEquipment);
}
private static List<Equipment> TryReadEquipment(string json)
{
try
{
return (json.FromJson<List<Equipment>>() ?? new List<Equipment>())
.Where(x => !string.IsNullOrWhiteSpace(x.PrefabName))
.Select(NormalizeEquipment)
.ToList();
}
catch
{
return new List<Equipment>();
}
}
private static List<Equipment> GetPlayerEquipment(PlayerUnlocks? items)
{
try
{
return items?.GetEquipment() ?? new List<Equipment>();
}
catch
{
return new List<Equipment>();
}
}
private static List<Equipment> MergeEquipment(IEnumerable<Equipment> defaults, IEnumerable<Equipment> playerEquipment)
{
var merged = new Dictionary<string, Equipment>();
foreach (var item in defaults.Concat(playerEquipment).Select(NormalizeEquipment))
{
if (string.IsNullOrWhiteSpace(item.PrefabName)) continue;
merged[$"{item.PrefabName}\u001f{item.ModificationGuid}"] = item;
}
return merged.Values.ToList();
}
private static Equipment NormalizeEquipment(Equipment item)
{
item.PrefabName ??= "";
item.ModificationGuid ??= "";
item.FriendlyName = string.IsNullOrWhiteSpace(item.FriendlyName) ? item.PrefabName : item.FriendlyName;
item.Tooltip ??= "";
return item;
}
}
}
+93
View File
@@ -0,0 +1,93 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Newtonsoft.Json;
using RadicadeServer.RecNet.Enums.Players.Avatar;
using RadicadeServer.RecNet.Enums.Store;
using RadicadeServer.Radicade.Gifts.Enums;
namespace RadicadeServer.RecNet.Avatar
{
public class Gift
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long GiftDropId { get; set; }
public long ItemSetId { get; set; } = 0;
public string ItemSetFriendlyName { get; set; } = string.Empty;
public string FriendlyName { get; set; } = string.Empty;
public string Tooltip { get; set; } = string.Empty;
public bool IsQuery { get; set; } = false;
public bool Unique { get; set; } = false;
public bool SubscribersOnly { get; set; } = false;
public string ConsumableItemDesc { get; set; } = string.Empty;
public string AvatarItemDesc { get; set; } = string.Empty;
public AvatarItemType AvatarItemType { get; set; }
public string EquipmentPrefabName { get; set; } = string.Empty;
public string EquipmentModificationGuid { get; set; } = string.Empty;
public CurrencyType CurrencyType { get; set; } = CurrencyType.RecCenterTokens;
public int Currency { get; set; } = 0;
public int Xp { get; set; } = 0;
public int Platform { get; set; } = 0;
public int PlatformsToSpawnOn { get; set; } = 0;
public long FromPlayerId { get; set; } = 0;
public int? BalanceType { get; set; } = null;
public RadicadeServer.RecNet.Enums.Store.GiftContext Context { get; set; } = RadicadeServer.RecNet.Enums.Store.GiftContext.Commerce_Purchase;
public Rarity Rarity { get; set; } = Rarity.Common;
[JsonIgnore] public bool IsFeatured { get; set; }
[JsonIgnore] public ItemAccessibility Accessibility { get; set; } = ItemAccessibility.Purchasable;
[System.Text.Json.Serialization.JsonPropertyName("Id")]
[Newtonsoft.Json.JsonProperty("Id")]
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
public long Id { get => GiftDropId; set => GiftDropId = value; }
[System.Text.Json.Serialization.JsonPropertyName("Message")]
[Newtonsoft.Json.JsonProperty("Message")]
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
public string Message { get => Tooltip; set => Tooltip = value ?? string.Empty; }
[System.Text.Json.Serialization.JsonPropertyName("GiftRarity")]
[Newtonsoft.Json.JsonProperty("GiftRarity")]
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
public int GiftRarity { get => (int)Rarity; set => Rarity = (Rarity)value; }
[System.Text.Json.Serialization.JsonPropertyName("GiftContext")]
[Newtonsoft.Json.JsonProperty("GiftContext")]
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
public int GiftContext
{
get => (int)Context;
set => Context = (RadicadeServer.RecNet.Enums.Store.GiftContext)value;
}
[System.Text.Json.Serialization.JsonPropertyName("GiftPackageId")]
[Newtonsoft.Json.JsonProperty("GiftPackageId")]
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
public long GiftPackageId { get => GiftDropId; set => GiftDropId = value; }
public Gift Clone() => (Gift)MemberwiseClone();
public Gift ForClient()
{
var copy = (Gift)MemberwiseClone();
copy.ConsumableItemDesc = ConsumableCatalog.ToClientDesc(ConsumableItemDesc);
copy.Platform = -1;
copy.PlatformsToSpawnOn = -1;
return copy;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
using RadicadeServer.DB;
namespace RadicadeServer.RecNet.Avatar;
public static class LockerAccess
{
public static bool HasFullInventory(RecDBContext db, long? accountId) =>
accountId.HasValue && db.Profiles.FirstOrDefault(p => p.accountId == accountId.Value)
?.HasRole("unlockedlocker") == true;
}
+10
View File
@@ -0,0 +1,10 @@
namespace RadicadeServer.RecNet.Avatar
{
public class Outfit : Avatar
{
public int? Slot { get; set; }
public string PreviewImageName { get; set; } = null!;
public string OutfitSelectionsV2 { get; set; } = null!;
public object[] CustomAvatarItems { get; set; } = null!;
}
}
+54
View File
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RadicadeServer.API.Avatars.V2;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
namespace RadicadeServer.API.Avatars;
[Route("outfits")]
[Authorize]
public class OutfitsAPI(RecDBContext db) : Controller
{
[HttpGet("me")]
public async Task<IActionResult> GetCurrentOutfit()
{
if (User.GetUserId() == null) return Unauthorized();
var avatarApi = new AvatarV2(db) { ControllerContext = ControllerContext };
var avatar = await avatarApi.GetLocalAvatar();
if (avatar == null) return NotFound();
return Ok(new CurrentOutfitResponse
{
LegacyData = new LegacyOutfitData
{
SelectionsV1 = avatar.OutfitSelections,
HairColor = avatar.HairColor,
SkinColor = avatar.SkinColor,
FaceFeatures = avatar.FaceFeatures
}
});
}
}
public sealed class CurrentOutfitResponse
{
public long DataVersion { get; init; }
public LegacyOutfitData LegacyData { get; init; } = new();
public string? CustomizationSettings { get; init; }
public object[] Selections { get; init; } = [];
public int Slot { get; init; }
public string? Name { get; init; }
public int Accessibility { get; init; }
public string? ThumbnailFileName { get; init; }
}
public sealed class LegacyOutfitData
{
public string? SelectionsV1 { get; init; }
public string? SelectionsV2 { get; init; }
public string? FaceFeatures { get; init; }
public string? SkinColor { get; init; }
public string? HairColor { get; init; }
}
+61
View File
@@ -0,0 +1,61 @@
using System.Text.Json.Serialization;
using RadicadeServer.RecNet.Enums.Store;
namespace RadicadeServer.RecNet.Avatar
{
public class PurchasableItem
{
public enum PurchasableItemType
{
GiftDrop = 0,
SeasonTier = 1,
SeasonEliteUpgrade = 2
}
public int PurchasableItemId { get; set; }
public PurchasableItemType Type { get; set; } = PurchasableItemType.GiftDrop;
public List<PurchasablePrice> Prices { get; set; } = new();
public List<PurchasablePrice> SubscriberPrices { get; set; } = new();
public bool IsFeatured { get; set; }
public DateTime? EndDate { get; set; }
public DateTime? NewUntil { get; set; }
}
public class PurchasablePrice
{
public CurrencyType CurrencyType { get; set; }
public int Price { get; set; }
public int? PurchaseCurrencyId { get; set; }
public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; }
}
public class PurchasableGiftDrop : PurchasableItem
{
public List<Gift> GiftDrops { get; set; } = new();
[JsonPropertyName("GiftDrop")]
public Gift? GiftDrop
{
get => GiftDrops.FirstOrDefault();
set
{
GiftDrops = value == null ? new List<Gift>() : new List<Gift> { value };
}
}
public PurchasableGiftDrop() {}
public PurchasableGiftDrop(List<Gift> giftDrops) {
GiftDrops = giftDrops;
}
}
}
+60
View File
@@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore;
using RadicadeServer.DB;
using RadicadeServer.Extensions;
using RadicadeServer.RecNet.Accounts.Local;
namespace RadicadeServer.RecNet.Avatar;
public static class SavedOutfits
{
public static async Task<List<Outfit>> GetAsync(RecDBContext db, long accountId)
{
var avatar = await db.Avatars.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == accountId);
return Parse(avatar?.Outfits);
}
public static async Task<Outfit?> SaveAsync(RecDBContext db, long accountId, Outfit? outfit)
{
if (outfit?.Slot == null)
return null;
var avatar = await db.Avatars.FirstOrDefaultAsync(x => x.AccountId == accountId);
if (avatar == null)
{
avatar = new PlayerAvatar
{
AccountId = accountId,
Avatar = DBConfig.DefaultAvatar,
Outfits = "[]"
};
db.Avatars.Add(avatar);
}
var outfits = Parse(avatar.Outfits);
var matchingSlot = outfits.FindIndex(x => x.Slot == outfit.Slot);
if (matchingSlot >= 0)
outfits[matchingSlot] = outfit;
else
outfits.Add(outfit);
avatar.Outfits = outfits.ToJson();
await db.SaveChangesAsync();
return outfit;
}
private static List<Outfit> Parse(string? stored)
{
try
{
var outfits = (stored ?? string.Empty).FromJson<List<Outfit>>();
return outfits?.Where(x => x != null).ToList() ?? new List<Outfit>();
}
catch
{
return new List<Outfit>();
}
}
}
+1226
View File
@@ -0,0 +1,1226 @@
[
{
"AvatarItemType": 0,
"AvatarItemDesc": "5d13a7a2-8213-40e6-90a6-efdd76a3fdcb,,,",
"PlatformMask": -1,
"FriendlyName": "Flowing Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "1d27b674-f9e2-4ffc-9d8c-a58a1be06457,,,",
"PlatformMask": -1,
"FriendlyName": "Afro Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "d84c0ff9-8fbe-4ed8-abf3-7996e81888ab,,,",
"PlatformMask": -1,
"FriendlyName": "Large Afro Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "e5b83dfc-b2e1-4dcb-a4ab-9d3a4c8a34ae,,,",
"PlatformMask": -1,
"FriendlyName": "Long Wavy Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "7dd6f7b0-7ba0-429f-a04f-e32d3a79ee61,,,",
"PlatformMask": -1,
"FriendlyName": "Short Wavy Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "eb9611c6-bb50-41a2-93e9-7f959815a846,,,",
"PlatformMask": -1,
"FriendlyName": "Dreads Long Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "1fd69ef8-0b74-4962-af5a-67f0bf0358f2,,,",
"PlatformMask": -1,
"FriendlyName": "Ponytail Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "a12f724f-4a73-4ab8-aad4-6bfc662b4dd6,,,",
"PlatformMask": -1,
"FriendlyName": "Undercut Long Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "0753d7a4-8247-4fca-a6fc-359c26086140,,,",
"PlatformMask": -1,
"FriendlyName": "Fonzie Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "77d3c585-4928-4471-a425-89036efe7299,,,",
"PlatformMask": -1,
"FriendlyName": "Spiky Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "92302d9d-c527-418c-ac5d-1fa869727505,,,",
"PlatformMask": -1,
"FriendlyName": "Part Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "f9dd08f8-16d3-4c39-af4f-89f7bb6e80d3,,,",
"PlatformMask": -1,
"FriendlyName": "Undercut Short Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "b148cb1e-df81-442f-aea6-ab1727aad00e,,,",
"PlatformMask": -1,
"FriendlyName": "Chunky Afro Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "e36bcd98-7e85-43fa-89f8-57e4ec33823a,,,",
"PlatformMask": -1,
"FriendlyName": "Bob with Bangs Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "880a3cc0-7407-4b61-b759-f9dd890fe9e5,,,",
"PlatformMask": -1,
"FriendlyName": "Bob Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "21599b51-c50f-43d8-ac5f-62c30cd02ca5,,,",
"PlatformMask": -1,
"FriendlyName": "Lori Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "193a3bf9-abc0-4d78-8d63-92046908b1c5,,,",
"PlatformMask": -1,
"FriendlyName": "Emo Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "79b90274-6eec-4664-acfb-4a123334661e,,,",
"PlatformMask": -1,
"FriendlyName": "Pig Tails Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "da4e7b34-2095-4a9e-801e-4f409039e0dd,,,",
"PlatformMask": -1,
"FriendlyName": "Buzz Cut Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "9d9fadb6-97eb-480e-a224-4e0179082071,,,",
"PlatformMask": -1,
"FriendlyName": "Meatball Buns Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "d8280c0c-d803-4513-be10-a0ba96d8821e,,,",
"PlatformMask": -1,
"FriendlyName": "Flowhawk Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "e286863c-2967-4d00-b837-b49487b9484a,,,",
"PlatformMask": -1,
"FriendlyName": "Fauxhawk Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "2cb4f372-3372-4583-8b57-c4e3988e3c28,,,",
"PlatformMask": -1,
"FriendlyName": "Punky Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,,,",
"PlatformMask": -1,
"FriendlyName": "Cat-Eye Glasses (Red)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "c70005d5-6276-4a98-acb3-6a77bc19379a,,,",
"PlatformMask": -1,
"FriendlyName": "Glasses (Teal)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "8d10cc78-6b00-45f3-affb-205e9cc5b03f,,,",
"PlatformMask": -1,
"FriendlyName": "Beard (Close)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "cc96f8a5-bc5b-4f89-83b7-ecd53905ada7,,,",
"PlatformMask": -1,
"FriendlyName": "Beard (Thick)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "c6c08eb5-381a-4193-9722-80da95d62abe,,,",
"PlatformMask": -1,
"FriendlyName": "Business Tie (Black)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "4d507dfa-4a99-4ac0-8537-229e9dc0eb4a,,,",
"PlatformMask": -1,
"FriendlyName": "Rec Room Tank Top (Orange)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,,,",
"PlatformMask": -1,
"FriendlyName": "Rec Room Shirt (Crew Neck, White)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,",
"PlatformMask": -1,
"FriendlyName": "Rec Room T-Shirt (Crew Neck, Orange)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,,,",
"PlatformMask": -1,
"FriendlyName": "Collared Shirt (Plaid, Blue)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,,,",
"PlatformMask": -1,
"FriendlyName": "T-Shirt",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "8aa79563-ace1-4ba7-ad0c-f3210a78142f,,,",
"PlatformMask": -1,
"FriendlyName": "Rec Room Shirt (V-Neck, White)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "8aa79563-ace1-4ba7-ad0c-f3210a78142f,95e4cc30-cb68-473d-a395-feadf5b51512,05f0ee6e-c824-470e-9178-5ed576c6fe0c,",
"PlatformMask": -1,
"FriendlyName": "Rec Room T-Shirt (V-Neck, Orange)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "21caa68e-c3fa-474c-af5e-af1e742b7a60,,,",
"PlatformMask": -1,
"FriendlyName": "Tennis Skirt (Blue)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "21caa68e-c3fa-474c-af5e-af1e742b7a60,c5deba2a-6e35-4b13-8e94-8ba5457f39df,b75ef67d-00c3-4ac1-9b72-212032460294,",
"PlatformMask": -1,
"FriendlyName": "Tennis Skirt (Yellow)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "21caa68e-c3fa-474c-af5e-af1e742b7a60,758752bd-db2f-43d2-b580-55b3e1efffd5,b75ef67d-00c3-4ac1-9b72-212032460294,",
"PlatformMask": -1,
"FriendlyName": "Tennis Skirt (Red)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,,,",
"PlatformMask": -1,
"FriendlyName": "Zipper Dress (Yellow)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,,,",
"PlatformMask": -1,
"FriendlyName": "Wristbands (White)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "71921831-ba6f-408b-a00e-2fd97663636f,,,",
"PlatformMask": -1,
"FriendlyName": "Wrist Tape (White)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,55901f12-d5b5-4fa8-b4c8-e479689ee39d,f600037d-c9c0-43fa-b45b-02f456f9dd5f,",
"PlatformMask": -1,
"FriendlyName": "Collared Shirt (Denim)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,bf82f2f6-9af8-431e-a296-0890dea48ba7,d015cae7-a905-49e4-8823-6dec069689a6,",
"PlatformMask": -1,
"FriendlyName": "Collared Shirt (Argyle)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,EfdMcnfHt0mr0PQ_maaYOg,DRJcNhkqvkKFEaZpOguR6w,",
"PlatformMask": -1,
"FriendlyName": "Collared Shirt (Flowers, Green)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,6d703981-2734-4c45-8983-cdd5f328902f,a0271cd0-e172-4d3f-aa2f-9806f21a82d2,",
"PlatformMask": -1,
"FriendlyName": "Tank Top (Camo)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,5c4a2b35-0e1c-44de-8c3a-96d4a6458b1b,9c03f381-7357-4d0f-8cda-8737d4c43d25,",
"PlatformMask": -1,
"FriendlyName": "Tank Top (Rainbow)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,d2a692e6-e1a9-4cfe-8154-10b52be7f8c8,",
"PlatformMask": -1,
"FriendlyName": "Jersey (Orange)",
"Tooltip": "",
"Rarity": 10
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,ad61c418-6d77-4a99-8ac5-9f10f5a3d42f,b292eb4b-07e3-4a48-99b5-3c6587a1e02e,",
"PlatformMask": -1,
"FriendlyName": "Tank Top (Dots)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,48abd952-214f-48b2-a8f1-1146f6f69aa2,b78008e8-abbd-4ece-be34-9a911f721fcc,",
"PlatformMask": -1,
"FriendlyName": "Tank Top (Zebra)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Baseball Cap (Red)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Baseball Cap (Blue)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Headband (Cream)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Headband (Black)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,018a5c07-e956-457d-a540-a5e2cd68da09,",
"PlatformMask": -1,
"FriendlyName": "Headband (Orange, White)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,018a5c07-e956-457d-a540-a5e2cd68da09,",
"PlatformMask": -1,
"FriendlyName": "Headband (Pink, White)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,018a5c07-e956-457d-a540-a5e2cd68da09,",
"PlatformMask": -1,
"FriendlyName": "Headband (Blue, White)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,6dd95046-acf8-42fe-ab78-80a334096a9d,56a92c8d-af53-413e-929e-4a9a3cfad780,",
"PlatformMask": -1,
"FriendlyName": "Headband (Red, White, Blue)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Wristbands (Blue)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Wristbands (Black)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Wristbands (Orange)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Wristbands (Pink)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Wristbands (Red)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "fcfcaf63-deb4-45f7-b711-c051c9ea45cb,,,",
"PlatformMask": -1,
"FriendlyName": "Top Bun Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,,,",
"PlatformMask": -1,
"FriendlyName": "Blazer (Blue, White)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,6f2e74bf-1e95-463d-97db-d5d1a53b2c28,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
"PlatformMask": -1,
"FriendlyName": "Blazer (Black, White)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,9374bf66-2ee5-493b-8439-efce4b201904,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
"PlatformMask": -1,
"FriendlyName": "Blazer (Grey, Black)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,272fe8eb-5061-4729-a7a8-414ff667a82f,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
"PlatformMask": -1,
"FriendlyName": "Blazer (Grey, White)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,0ffad843-d6c9-425a-8686-7217009c867e,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
"PlatformMask": -1,
"FriendlyName": "Blazer (Green, Black)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,e0397982-c2c2-4733-9a40-46e18675b5af,dafa658e-753b-46cb-bd85-85c1de5e6ea7,",
"PlatformMask": -1,
"FriendlyName": "Button Top (Orange)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,,,",
"PlatformMask": -1,
"FriendlyName": "Button Top (Pink)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,49f5864f-9d40-497c-88c8-e87f64d41d74,dafa658e-753b-46cb-bd85-85c1de5e6ea7,",
"PlatformMask": -1,
"FriendlyName": "Button Top (Tan)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,c5deba2a-6e35-4b13-8e94-8ba5457f39df,dafa658e-753b-46cb-bd85-85c1de5e6ea7,",
"PlatformMask": -1,
"FriendlyName": "Button Top (Yellow)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,f750de46-3758-4f7d-9709-0a84b1027009,2c8924aa-68f8-4912-9759-18992f72f08a,",
"PlatformMask": -1,
"FriendlyName": "Collared Dress (Blue)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,d66aa400-aa5a-4539-a25d-5f8ce94dc281,2c8924aa-68f8-4912-9759-18992f72f08a,",
"PlatformMask": -1,
"FriendlyName": "Collared Dress (Green)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,6564acf1-4d70-4f92-92ac-08e2b76dbb6b,2c8924aa-68f8-4912-9759-18992f72f08a,",
"PlatformMask": -1,
"FriendlyName": "Collared Dress (Purple)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,,,",
"PlatformMask": -1,
"FriendlyName": "Collared Dress (Red)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "241506f6-bf88-4b46-b5fe-513a225421f4,,,",
"PlatformMask": -1,
"FriendlyName": "Half Up Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,d6edbc00-3c1d-4f49-8412-3ef8c7c5f4c2,cf119781-5bd9-4b85-9a0b-12e82e988c23,",
"PlatformMask": -1,
"FriendlyName": "Long Scarf (Blue)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,6d703981-2734-4c45-8983-cdd5f328902f,cfabdefe-0890-436e-b2a3-b5c712e22955,",
"PlatformMask": -1,
"FriendlyName": "Zipper Dress (Green)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,830be2fa-60a5-48cc-931f-34b670eae4bd,cfabdefe-0890-436e-b2a3-b5c712e22955,",
"PlatformMask": -1,
"FriendlyName": "Zipper Dress (Purple)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,bbfa08e3-8e6b-4e0f-b264-1b398d7cd44a,cfabdefe-0890-436e-b2a3-b5c712e22955,",
"PlatformMask": -1,
"FriendlyName": "Zipper Dress (White)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Wristbands (Light Blue)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,f8b0cfe8-e129-4578-8bb5-f60af5d38599,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Wristbands (Green)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Wristbands (Gold)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Tank Top (Black)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Tank Top (Blue)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Tank Top (Orange)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Tank Top (Red)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,5c4a2b35-0e1c-44de-8c3a-96d4a6458b1b,cf119781-5bd9-4b85-9a0b-12e82e988c23,",
"PlatformMask": -1,
"FriendlyName": "Long Scarf (Purple)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,6dd95046-acf8-42fe-ab78-80a334096a9d,cf119781-5bd9-4b85-9a0b-12e82e988c23,",
"PlatformMask": -1,
"FriendlyName": "Long Scarf (White)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Headband (Blue)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,f8b0cfe8-e129-4578-8bb5-f60af5d38599,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Headband (Green)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Headband (Red)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,,,",
"PlatformMask": -1,
"FriendlyName": "Headband (White)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Headband (Yellow)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "24a240f4-1574-420b-b898-a7e91f170759,,,",
"PlatformMask": -1,
"FriendlyName": "Back Bun Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "c45ed7b8-99bd-4a4b-a9ff-e16edf5d7a18,,,",
"PlatformMask": -1,
"FriendlyName": "High Pony Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Baseball Cap (Light Blue)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Baseball Cap (Black)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Baseball Cap (Orange)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,,,",
"PlatformMask": -1,
"FriendlyName": "Baseball Cap (White)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Baseball Cap (Yellow)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "896c2491-2f96-4986-9cbd-b3b31ef5d8c5,,,",
"PlatformMask": -1,
"FriendlyName": "Equestrian Coat (Black)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "896c2491-2f96-4986-9cbd-b3b31ef5d8c5,55901f12-d5b5-4fa8-b4c8-e479689ee39d,d344b8cc-85a8-4ace-9f92-38c84f396e99,",
"PlatformMask": -1,
"FriendlyName": "Equestrian Coat (Blue)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "896c2491-2f96-4986-9cbd-b3b31ef5d8c5,4828b50c-95b6-466a-bb25-514891d78202,d344b8cc-85a8-4ace-9f92-38c84f396e99,",
"PlatformMask": -1,
"FriendlyName": "Equestrian Coat (Grey)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "896c2491-2f96-4986-9cbd-b3b31ef5d8c5,d6823e01-69f0-4f85-b94a-74894356a2cf,d344b8cc-85a8-4ace-9f92-38c84f396e99,",
"PlatformMask": -1,
"FriendlyName": "Equestrian Coat (Maroon)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "09177621-9ecd-4f6a-b6a5-64490139141d,,,",
"PlatformMask": -1,
"FriendlyName": "Flat Top Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "95ab7a7c-c35d-4da5-9955-0921064470b6,,,",
"PlatformMask": -1,
"FriendlyName": "Gekko Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,0ecb8a2a-cffc-47db-aeda-fb0684aef1e5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Headband (Grey)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Headband (Light Blue)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Headband (Orange)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Headband (Pink)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,8377ab96-c908-457f-9fee-b784c9a759f3,018a5c07-e956-457d-a540-a5e2cd68da09,",
"PlatformMask": -1,
"FriendlyName": "Headband (Red, White)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "62ce4109-8dee-4895-bf1b-bfa143db4c7e,,,",
"PlatformMask": -1,
"FriendlyName": "Slim Blazer (Teal)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "62ce4109-8dee-4895-bf1b-bfa143db4c7e,cd5d7285-202d-42d0-b93f-04245875793e,0f36bb97-c61b-4281-929f-ff1d0d11be86,",
"PlatformMask": -1,
"FriendlyName": "Slim Blazer (Green)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "62ce4109-8dee-4895-bf1b-bfa143db4c7e,ad61c418-6d77-4a99-8ac5-9f10f5a3d42f,0f36bb97-c61b-4281-929f-ff1d0d11be86,",
"PlatformMask": -1,
"FriendlyName": "Slim Blazer (Blue)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "8b9f1413-e786-4a30-946c-9292f207875a,,,",
"PlatformMask": -1,
"FriendlyName": "Pulp Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "5cd08cfb-c729-4c30-96d9-6a99bb934d91,,,",
"PlatformMask": -1,
"FriendlyName": "Rec Room Sash",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "1a71064b-794f-40fa-9109-8ad36602b6e1,,,",
"PlatformMask": -1,
"FriendlyName": "Shagg Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,761a3193-60f0-4190-80c7-285b8192e794,91a451c1-b285-4c48-b14d-59ded8cc006f,",
"PlatformMask": -1,
"FriendlyName": "Stoll Dress (Blue)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,a819f49b-6c7a-49d3-9e6a-d9d79ef5019f,91a451c1-b285-4c48-b14d-59ded8cc006f,",
"PlatformMask": -1,
"FriendlyName": "Stoll Dress (Green)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,64850553-cdfe-455a-ac00-dafbe63d613e,91a451c1-b285-4c48-b14d-59ded8cc006f,",
"PlatformMask": -1,
"FriendlyName": "Stoll Dress (Orange)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,,,",
"PlatformMask": -1,
"FriendlyName": "Stoll Dress (Pink)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "71921831-ba6f-408b-a00e-2fd97663636f,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Wrist Tape (Black)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "71921831-ba6f-408b-a00e-2fd97663636f,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Wrist Tape (Cream)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,0ecb8a2a-cffc-47db-aeda-fb0684aef1e5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Wristbands (Grey)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"PlatformMask": -1,
"FriendlyName": "Wristbands (Cream)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,,,",
"PlatformMask": -1,
"FriendlyName": "Long Scarf (Red)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,0iSsaY-HgkmLaRHCn5vEdw,PioQ0o3yP0a6szPZ4EKs2A,",
"PlatformMask": -1,
"FriendlyName": "Collared Shirt (Blue)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,jGj28vhq8EGwP2RuM074aQ,PioQ0o3yP0a6szPZ4EKs2A,",
"PlatformMask": -1,
"FriendlyName": "Collared Shirt (Yellow)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,kmj5zOjcwku_WWKroCeiVQ,PioQ0o3yP0a6szPZ4EKs2A,",
"PlatformMask": -1,
"FriendlyName": "Collared Shirt (Pink)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,FAviMCQ_EE2Mpt6QPo5OEw,PioQ0o3yP0a6szPZ4EKs2A,",
"PlatformMask": -1,
"FriendlyName": "Collared Shirt (Red)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,MFrcSQ1DYUm8imvy4ypgvw,PioQ0o3yP0a6szPZ4EKs2A,",
"PlatformMask": -1,
"FriendlyName": "Collared Shirt (White)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,05ac07e1-67f0-486c-abf5-a62866475abb,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
"PlatformMask": -1,
"FriendlyName": "Blazer (Black, Cream)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "0088603e-ec3b-4478-8694-e6fb1989b3f2,,,",
"PlatformMask": -1,
"FriendlyName": "Angled Bob Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "ffea7a65-613f-4835-921e-6dd15f357b7e,,,",
"PlatformMask": -1,
"FriendlyName": "Long Bangs Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "45f5e714-8a5f-4385-a97f-675066167011,,,",
"PlatformMask": -1,
"FriendlyName": "Seventies Stache",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "9bf5d259-7774-4cbe-a90f-7f188cc0dce7,,,",
"PlatformMask": -1,
"FriendlyName": "Thick Goatee",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "a6cbfe76-534a-4655-a8a8-3fed13d001c7,,,",
"PlatformMask": -1,
"FriendlyName": "Bald Top Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "CTcrvbo3OEepIV4oW8bx4w,,,",
"PlatformMask": -1,
"FriendlyName": "Receding Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "-twtjyBdQ02EAdOfBGTiEw,,,",
"PlatformMask": -1,
"FriendlyName": "Van Dyke Beard",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "45eaab67-19c2-4601-8f80-3565a4dceba4,,,",
"PlatformMask": -1,
"FriendlyName": "Pompadour Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "c855dcc3-96cb-470d-b159-d37a025a47d1,,,",
"PlatformMask": -1,
"FriendlyName": "Dutch Braid Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "d7730a9e-78a1-4356-bc09-6b066615850b,,,",
"PlatformMask": -1,
"FriendlyName": "Afro Updo Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "8c35c804-e8d5-49d2-8d5a-ea19fb70bfa6,,,",
"PlatformMask": -1,
"FriendlyName": "Pencil Bun Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "5beeb4c4-f276-4eae-87aa-9302e45b05b7,,,",
"PlatformMask": -1,
"FriendlyName": "Cornrows Hair",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,sxUE0iOSZEmezm54T7xI3Q,tlpa7195x0CkmSjpR1RArQ,",
"PlatformMask": -1,
"FriendlyName": "Rec Room Hoodie - Pride (Rainbow Pride)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "fe15ca53-c5b8-4acf-9309-ff3f4e610fc9,,,",
"PlatformMask": -1,
"FriendlyName": "Winged Hat - Pride (Rainbow Pride)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,D_Xmo0rOzkS-kgq1CYXt3g,tnCJp2eDI0SwjVfJMhk3LQ,",
"PlatformMask": -1,
"FriendlyName": "Rec Room Hoodie - Pride (Trans Pride)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "fe15ca53-c5b8-4acf-9309-ff3f4e610fc9,knXPidb-Rkayfc3kSHfZeQ,1yMyo6oTjU-VAygoeWaohQ,",
"PlatformMask": -1,
"FriendlyName": "Winged Hat - Pride (Trans Pride)",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "88b6ddeb-a455-460d-91d9-a4569ef6903c,,,",
"PlatformMask": -1,
"FriendlyName": "Square Earrings ",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "0abb6b08-20ce-444f-879e-0d1344df096c,,,",
"PlatformMask": -1,
"FriendlyName": "Round Earrings",
"Tooltip": "",
"Rarity": 0
},
{
"AvatarItemType": 0,
"AvatarItemDesc": "9b5bde11-7408-4798-9fcb-c7ec175444df,,,",
"PlatformMask": -1,
"FriendlyName": "Hoop Earrings",
"Tooltip": "",
"Rarity": 0
}
]