using System; using System.Collections.Generic; using System.IO; using System.Linq; using Newtonsoft.Json.Linq; namespace Campus_live_rewrite.api.server { /// /// A searchable in-memory index of Data/APIS/items/avatar_items.json - every avatar item that /// exists, ~3.6k of them. /// /// The admin Gifts search only ever looked at the STOREFRONT catalogs /// (Data/APIS/GiftDropStores), which between them list a few thousand PURCHASABLE drops. Most /// of the avatar catalog is not sold in any storefront, so those items simply could not be /// found - searching "thief hat" returned the three that happened to be on the Watch store and /// nothing else. This index is what makes the rest giftable. /// /// Loaded once, lazily, and re-read if the file changes on disk (a Data deploy replaces it). /// The file is ~1.5 MB; parsing it per keystroke would be absurd once the search box is live. /// public static class AvatarCatalog { public sealed class Entry { public string Desc = ""; // AvatarItemDesc, RAW - the client parses this public string Name = ""; public int AvatarItemId; public int AvatarItemType; public int Rarity; public string? Thumbnail; } private static List? _items; private static DateTime _stamp; private static readonly object _lock = new(); private static string Path_ => Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "avatar_items.json"); public static IReadOnlyList All() { try { string path = Path_; if (!File.Exists(path)) { if (_items == null) Console.WriteLine($"[catalog] {path} not found - gift search falls back to storefronts only"); return _items ?? (IReadOnlyList)Array.Empty(); } var stamp = File.GetLastWriteTimeUtc(path); if (_items != null && stamp == _stamp) return _items; lock (_lock) { if (_items != null && stamp == _stamp) return _items; var list = new List(); foreach (var e in JArray.Parse(File.ReadAllText(path))) { string desc = (string?)e["AvatarItemDesc"] ?? ""; if (string.IsNullOrWhiteSpace(desc)) continue; list.Add(new Entry { Desc = desc, Name = (string?)e["FriendlyName"] ?? "", AvatarItemId = (int?)e["AvatarItemId"] ?? 0, AvatarItemType = (int?)e["AvatarItemType"] ?? 0, Rarity = (int?)e["Rarity"] ?? 0, Thumbnail = (string?)e["ThumbnailImage"] }); } _items = list; _stamp = stamp; Console.WriteLine($"[catalog] indexed {list.Count} avatar item(s) for the admin gift search"); return _items; } } catch (Exception ex) { Console.WriteLine($"[catalog] index failed: {ex.Message}"); return _items ?? (IReadOnlyList)Array.Empty(); } } /// /// Name or descriptor match, case-insensitive. Items whose name STARTS with the query come /// first - typing "thief" should lead with "Thief Hat", not with everything that merely /// contains the word - then the rest alphabetically so the order is stable between /// keystrokes rather than jumping around as results stream in. /// public static List Search(string query, int max) { if (string.IsNullOrWhiteSpace(query)) return new List(); string q = query.Trim().ToLowerInvariant(); return All() .Where(e => e.Name.ToLowerInvariant().Contains(q) || e.Desc.ToLowerInvariant().Contains(q)) .OrderBy(e => e.Name.ToLowerInvariant().StartsWith(q) ? 0 : 1) .ThenBy(e => e.Name, StringComparer.OrdinalIgnoreCase) .Take(Math.Clamp(max, 1, 500)) .ToList(); } /// How many items match, ignoring the page cap - so the UI can say "showing 60 of 214". public static int CountMatches(string query) { if (string.IsNullOrWhiteSpace(query)) return 0; string q = query.Trim().ToLowerInvariant(); return All().Count(e => e.Name.ToLowerInvariant().Contains(q) || e.Desc.ToLowerInvariant().Contains(q)); } /// Exact descriptor lookup (the id the gift form sends back for a catalog item). public static Entry? ByDesc(string? desc) { if (string.IsNullOrWhiteSpace(desc)) return null; string key = dbs.PlayerDB.NormalizeAvatarItemDesc(desc); return All().FirstOrDefault(e => string.Equals(dbs.PlayerDB.NormalizeAvatarItemDesc(e.Desc), key, StringComparison.OrdinalIgnoreCase)); } } }