mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 23:49:02 -07:00
Radicade Public Code
This commit is contained in:
@@ -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!;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user