Files
Radicade/Server/Avatars/AvatarUnlockCatalog.cs
2026-10-16 23:33:13 -07:00

257 lines
8.3 KiB
C#

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
};
}
}