using System.Text.Json; using System.Text.Json.Nodes; namespace Mocha2023.Classes { public static class PlayerInventoryStore { private static readonly object InventoryLock = new(); private static readonly JsonSerializerOptions ReadOptions = new() { PropertyNameCaseInsensitive = true }; private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true }; public sealed class AvatarItemOwnership { public long AvatarItemId { get; set; } public string AvatarItemDesc { get; set; } = string.Empty; public string FriendlyName { get; set; } = string.Empty; } public sealed class ConsumableOwnership { public long ConsumableItemId { get; set; } public string ConsumableItemDesc { get; set; } = string.Empty; public string FriendlyName { get; set; } = string.Empty; public int Quantity { get; set; } } public static string GetAvatarItemsPath(long accountId) { string directory = Path.Combine( Program.dataDir, "AvatarItemsUnlocked", accountId.ToString()); Directory.CreateDirectory(directory); return Path.Combine(directory, "AvatarItems.json"); } public static string GetConsumablesPath(long accountId) { string directory = Path.Combine( Program.dataDir, "ConsumablesUnlocked", accountId.ToString()); Directory.CreateDirectory(directory); return Path.Combine(directory, "Consumables.json"); } public static void EnsureInitialized( long accountId, IEnumerable? legacyAvatarDescriptors = null) { EnsureAvatarItemsInitialized(accountId, legacyAvatarDescriptors); EnsureConsumablesInitialized(accountId); } public static List GetAvatarCatalog() => LoadAvatarCatalog(); public static List GetConsumableCatalog() => LoadConsumableCatalog(); public static void EnsureAvatarItemsInitialized( long accountId, IEnumerable? legacyAvatarDescriptors = null) { lock (InventoryLock) { string playerPath = GetAvatarItemsPath(accountId); if (File.Exists(playerPath)) return; List owned = LoadAvatarCatalog() .Where(item => !IsSpecialAvatarItem(item)) .ToList(); HashSet legacy = (legacyAvatarDescriptors ?? Array.Empty()) .Where(value => !string.IsNullOrWhiteSpace(value)) .ToHashSet(StringComparer.OrdinalIgnoreCase); if (legacy.Count > 0) { foreach (AvatarItemOwnership item in LoadAvatarCatalog()) { if (!legacy.Contains(item.AvatarItemDesc)) continue; if (owned.Any(value => SameAvatar(value, item))) continue; owned.Add(item); } foreach (string descriptor in legacy) { if (owned.Any(value => string.Equals( value.AvatarItemDesc, descriptor, StringComparison.OrdinalIgnoreCase))) continue; owned.Add(new AvatarItemOwnership { AvatarItemDesc = descriptor, FriendlyName = descriptor }); } } SaveAvatarItemsUnsafe(playerPath, owned); } } public static void EnsureConsumablesInitialized(long accountId) { lock (InventoryLock) { string playerPath = GetConsumablesPath(accountId); if (File.Exists(playerPath)) return; SaveConsumablesUnsafe(playerPath, LoadConsumableCatalog()); } } public static List GetAvatarItems( long accountId, IEnumerable? legacyAvatarDescriptors = null) { lock (InventoryLock) { EnsureAvatarItemsInitialized(accountId, legacyAvatarDescriptors); try { return JsonSerializer.Deserialize>( File.ReadAllText(GetAvatarItemsPath(accountId)), ReadOptions) ?? new List(); } catch (JsonException) { return new List(); } } } public static List GetConsumables(long accountId) { lock (InventoryLock) { EnsureConsumablesInitialized(accountId); try { return JsonSerializer.Deserialize>( File.ReadAllText(GetConsumablesPath(accountId)), ReadOptions) ?? new List(); } catch (JsonException) { return new List(); } } } public static bool OwnsAvatarItem( long accountId, string descriptor, long avatarItemId = 0, IEnumerable? legacyAvatarDescriptors = null) { if (string.IsNullOrWhiteSpace(descriptor) && avatarItemId <= 0) return false; return GetAvatarItems(accountId, legacyAvatarDescriptors).Any(item => (!string.IsNullOrWhiteSpace(descriptor) && string.Equals( item.AvatarItemDesc, descriptor, StringComparison.OrdinalIgnoreCase)) || (avatarItemId > 0 && item.AvatarItemId == avatarItemId)); } public static bool SetAvatarItemOwned( long accountId, string descriptor, long avatarItemId, string? friendlyName, bool owned, IEnumerable? legacyAvatarDescriptors = null) { if (string.IsNullOrWhiteSpace(descriptor) && avatarItemId <= 0) return false; lock (InventoryLock) { List items = GetAvatarItems( accountId, legacyAvatarDescriptors); int index = items.FindIndex(item => (!string.IsNullOrWhiteSpace(descriptor) && string.Equals( item.AvatarItemDesc, descriptor, StringComparison.OrdinalIgnoreCase)) || (avatarItemId > 0 && item.AvatarItemId == avatarItemId)); if (owned) { if (index < 0) { items.Add(new AvatarItemOwnership { AvatarItemId = avatarItemId, AvatarItemDesc = descriptor?.Trim() ?? string.Empty, FriendlyName = string.IsNullOrWhiteSpace(friendlyName) ? descriptor?.Trim() ?? $"Avatar Item {avatarItemId}" : friendlyName.Trim() }); } else { if (avatarItemId > 0) items[index].AvatarItemId = avatarItemId; if (!string.IsNullOrWhiteSpace(descriptor)) items[index].AvatarItemDesc = descriptor.Trim(); if (!string.IsNullOrWhiteSpace(friendlyName)) items[index].FriendlyName = friendlyName.Trim(); } } else if (index >= 0) { items.RemoveAt(index); } SaveAvatarItemsUnsafe(GetAvatarItemsPath(accountId), items); return true; } } public static int GetConsumableQuantity( long accountId, string descriptor, long consumableItemId = 0) { if (string.IsNullOrWhiteSpace(descriptor) && consumableItemId <= 0) return 0; return SumConsumableQuantity( GetConsumables(accountId).Where(value => SameConsumable(value, descriptor, consumableItemId))); } public static int SetConsumableQuantity( long accountId, string descriptor, long consumableItemId, string? friendlyName, int quantity) { quantity = Math.Clamp(quantity, 0, 100_000); if (string.IsNullOrWhiteSpace(descriptor) && consumableItemId <= 0) return 0; lock (InventoryLock) { List items = GetConsumables(accountId); List matchingIndexes = items .Select((value, index) => new { value, index }) .Where(entry => SameConsumable( entry.value, descriptor, consumableItemId)) .Select(entry => entry.index) .ToList(); if (quantity <= 0) { for (int match = matchingIndexes.Count - 1; match >= 0; match--) items.RemoveAt(matchingIndexes[match]); } else if (matchingIndexes.Count == 0) { items.Add(new ConsumableOwnership { ConsumableItemId = consumableItemId, ConsumableItemDesc = descriptor?.Trim() ?? string.Empty, FriendlyName = string.IsNullOrWhiteSpace(friendlyName) ? descriptor?.Trim() ?? $"Consumable {consumableItemId}" : friendlyName.Trim(), Quantity = quantity }); } else { int index = matchingIndexes[0]; items[index].Quantity = quantity; if (consumableItemId > 0) items[index].ConsumableItemId = consumableItemId; if (!string.IsNullOrWhiteSpace(descriptor)) items[index].ConsumableItemDesc = descriptor.Trim(); if (!string.IsNullOrWhiteSpace(friendlyName)) items[index].FriendlyName = friendlyName.Trim(); for (int match = matchingIndexes.Count - 1; match >= 1; match--) items.RemoveAt(matchingIndexes[match]); } SaveConsumablesUnsafe(GetConsumablesPath(accountId), items); return quantity; } } public static int AddConsumable( long accountId, string descriptor, long consumableItemId, string? friendlyName, int amount) { if (amount <= 0) return GetConsumableQuantity(accountId, descriptor, consumableItemId); lock (InventoryLock) { int current = GetConsumableQuantity( accountId, descriptor, consumableItemId); int next = (int)Math.Min(100_000L, (long)current + amount); return SetConsumableQuantity( accountId, descriptor, consumableItemId, friendlyName, next); } } public static bool TryConsumeConsumable( long accountId, string descriptor, long consumableItemId, int amount, out int previousQuantity, out int remainingQuantity) { previousQuantity = 0; remainingQuantity = 0; if (amount <= 0 || (string.IsNullOrWhiteSpace(descriptor) && consumableItemId <= 0)) { return false; } lock (InventoryLock) { List items = GetConsumables(accountId); List matchingIndexes = items .Select((value, index) => new { value, index }) .Where(entry => SameConsumable( entry.value, descriptor, consumableItemId)) .Select(entry => entry.index) .ToList(); previousQuantity = SumConsumableQuantity( matchingIndexes.Select(index => items[index])); if (matchingIndexes.Count == 0 || previousQuantity <= 0) return false; remainingQuantity = Math.Max(0, previousQuantity - amount); if (remainingQuantity == 0) { for (int match = matchingIndexes.Count - 1; match >= 0; match--) items.RemoveAt(matchingIndexes[match]); } else { int index = matchingIndexes[0]; items[index].Quantity = remainingQuantity; for (int match = matchingIndexes.Count - 1; match >= 1; match--) items.RemoveAt(matchingIndexes[match]); } SaveConsumablesUnsafe(GetConsumablesPath(accountId), items); return true; } } private static List LoadAvatarCatalog() { string path = Path.Combine( Program.dataDir, "APIS", "Items", "AvatarItemsDefault.json"); if (!File.Exists(path)) return new List(); try { JsonNode? root = JsonNode.Parse(File.ReadAllText(path)); if (root is not JsonArray array) return new List(); var results = new List(); foreach (JsonNode? node in array) { if (node is not JsonObject item) continue; long id = ReadLong(item, "AvatarItemId", "ItemId", "Id"); string descriptor = ReadString( item, "AvatarItemDesc", "AvatarItemDescriptor", "ItemDesc"); string name = ReadString(item, "FriendlyName", "Name"); if (id <= 0 && string.IsNullOrWhiteSpace(descriptor)) continue; results.Add(new AvatarItemOwnership { AvatarItemId = id, AvatarItemDesc = descriptor, FriendlyName = string.IsNullOrWhiteSpace(name) ? descriptor : name }); } return results .GroupBy(item => string.IsNullOrWhiteSpace(item.AvatarItemDesc) ? $"id:{item.AvatarItemId}" : $"desc:{item.AvatarItemDesc}", StringComparer.OrdinalIgnoreCase) .Select(group => group.First()) .ToList(); } catch { return new List(); } } private static List LoadConsumableCatalog() { string path = Path.Combine( Program.dataDir, "APIS", "Items", "ConsumablesDefault.json"); if (!File.Exists(path)) return new List(); try { JsonNode? root = JsonNode.Parse(File.ReadAllText(path)); if (root is not JsonArray array) return new List(); var results = new List(); foreach (JsonNode? node in array) { if (node is not JsonObject item) continue; long id = ReadLong(item, "ConsumableItemId", "ItemId", "Id"); if (id <= 0 && item["Ids"] is JsonArray ids) { foreach (JsonNode? idNode in ids) { if (TryLong(idNode, out long candidate) && candidate > 0) { id = candidate; break; } } } string descriptor = ReadString( item, "ConsumableItemDesc", "ConsumableDesc", "ItemDesc"); string name = ReadString(item, "FriendlyName", "Name"); if (id <= 0 && string.IsNullOrWhiteSpace(descriptor)) continue; long configuredQuantity = ReadLong( item, "Count", "Quantity", "InitialCount"); int quantity = configuredQuantity > 0 ? (int)Math.Min(100_000L, configuredQuantity) : item["Ids"] is JsonArray itemIds ? Math.Max(1, itemIds.Count) : 1; results.Add(new ConsumableOwnership { ConsumableItemId = id, ConsumableItemDesc = descriptor, FriendlyName = string.IsNullOrWhiteSpace(name) ? descriptor : name, Quantity = quantity }); } return results .GroupBy(item => string.IsNullOrWhiteSpace(item.ConsumableItemDesc) ? $"id:{item.ConsumableItemId}" : $"desc:{item.ConsumableItemDesc}", StringComparer.OrdinalIgnoreCase) .Select(group => group.First()) .ToList(); } catch { return new List(); } } private static bool IsSpecialAvatarItem(AvatarItemOwnership item) => string.Equals(item.FriendlyName, "Alpaca Shirt", StringComparison.OrdinalIgnoreCase); private static bool SameAvatar( AvatarItemOwnership left, AvatarItemOwnership right) => (!string.IsNullOrWhiteSpace(left.AvatarItemDesc) && string.Equals(left.AvatarItemDesc, right.AvatarItemDesc, StringComparison.OrdinalIgnoreCase)) || (left.AvatarItemId > 0 && left.AvatarItemId == right.AvatarItemId); private static bool SameConsumable( ConsumableOwnership item, string descriptor, long consumableItemId) => (!string.IsNullOrWhiteSpace(descriptor) && string.Equals( item.ConsumableItemDesc, descriptor, StringComparison.OrdinalIgnoreCase)) || (consumableItemId > 0 && item.ConsumableItemId == consumableItemId); private static int SumConsumableQuantity( IEnumerable items) { long total = items.Sum(item => (long)Math.Max(0, item.Quantity)); return (int)Math.Min(100_000L, total); } private static void SaveAvatarItemsUnsafe( string path, List items) => File.WriteAllText(path, JsonSerializer.Serialize(items, WriteOptions)); private static void SaveConsumablesUnsafe( string path, List items) => File.WriteAllText(path, JsonSerializer.Serialize(items, WriteOptions)); private static string ReadString(JsonObject item, params string[] names) { foreach (string name in names) { JsonNode? node = item[name]; if (node == null) continue; try { string? value = node.GetValue(); if (!string.IsNullOrWhiteSpace(value)) return value.Trim(); } catch { string value = node.ToJsonString().Trim('"'); if (!string.IsNullOrWhiteSpace(value) && value != "null") return value; } } return string.Empty; } private static long ReadLong(JsonObject item, params string[] names) { foreach (string name in names) if (TryLong(item[name], out long value)) return value; return 0; } private static bool TryLong(JsonNode? node, out long value) { value = 0; if (node == null) return false; try { if (node is JsonValue jsonValue && jsonValue.TryGetValue(out value)) return true; } catch { } return long.TryParse(node.ToJsonString().Trim('"'), out value); } } }