using Newtonsoft.Json.Linq; namespace Campus_live_rewrite.api.server { /// /// Admin-panel edits to the gift-drop storefronts (Stores tab), kept OUT of the store files in /// Data/APIS/store-edits.json and laid over them wherever a store is served or bought from: /// - GET /api/storefronts/v3/giftdropstore/{id} (ApplyJson), /// - StoreCatalog.FindStoreItem, so an added row is buyable at the admin's price, /// - the Wishlist rows. /// The store files themselves (3.json is re-uploaded by UploadUpdate.bat on every deploy) /// are never written, so a deploy can't wipe the edits. /// /// Per storefront: "Rows" = full store rows (the client's own StoreItem shape) that are either /// NEW (their own PurchasableItemId, from AdminIdBase up) or REPLACE the base row with the same /// id (a price / set / featured edit), and "Removed" = base PurchasableItemIds taken off sale. /// public static class StoreEdits { /// /// Ids for admin-added rows (PurchasableItemId and GiftDropId both). Far above the captured /// ids and the generated Watch ranges (StoreGenerator: catalog 100000+, skins 140000+), and /// unique across ALL storefronts, since FindStoreItem scans every store when the client /// does not say which one it is buying from. /// public const int AdminIdBase = 900_000; private static readonly string FilePath = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "store-edits.json"); private static readonly object _lock = new(); private static JObject? _root; private static int _version; public static int Version { get { Load(); return _version; } } private static JObject Load() { lock (_lock) { if (_root != null) return _root; try { _root = File.Exists(FilePath) ? JObject.Parse(File.ReadAllText(FilePath)) : new JObject(); } catch (Exception ex) { Console.WriteLine($"[store-edits] could not read store-edits.json ({ex.Message}) - starting empty; the bad file is kept as .broken"); try { File.Copy(FilePath, FilePath + ".broken", true); } catch { } _root = new JObject(); } if (_root["Stores"] is not JObject) _root["Stores"] = new JObject(); if (_root["NextId"]?.Type != JTokenType.Integer) _root["NextId"] = AdminIdBase; MigrateTo2025StoreIds(_root); _version++; return _root; } } /// /// One-time fix-up: the Stores tab first used the 2023 client's storefront ids, where /// 2000-2004 were "PopUpShop_1..5". In the 2025 client those are the progression-event /// shops and the pop-up shops are 1700-1704, so anything stocked there moves across. /// private static void MigrateTo2025StoreIds(JObject root) { if ((int?)root["StoreIds"] == 2025) return; // No file = nothing stocked yet: nothing to move, and writing one here would leave a // stray store-edits.json on a dev box for a full package to ship over the live copy. if (!File.Exists(FilePath)) { root["StoreIds"] = 2025; return; } var stores = (JObject)root["Stores"]!; int moved = 0; for (int i = 0; i < 5; i++) { if (stores[(2000 + i).ToString()] is not JObject from) continue; stores.Remove((2000 + i).ToString()); string to = (1700 + i).ToString(); if (stores[to] is not JObject dest) { stores[to] = from; } else { foreach (var r in (from["Rows"] as JArray ?? new JArray())) ((JArray)(dest["Rows"] ??= new JArray())).Add(r); foreach (var r in (from["Removed"] as JArray ?? new JArray())) ((JArray)(dest["Removed"] ??= new JArray())).Add(r); } moved += (from["Rows"] as JArray)?.Count ?? 0; Console.WriteLine($"[store-edits] moved store {2000 + i} edits to {to} (PopUpShop_{i + 1} is {to} in the 2025 client)"); } root["StoreIds"] = 2025; try { var tmp = FilePath + ".tmp"; if (File.Exists(FilePath)) File.Copy(FilePath, FilePath + ".bak-2023ids", true); File.WriteAllText(tmp, root.ToString(Newtonsoft.Json.Formatting.Indented)); File.Move(tmp, FilePath, true); } catch (Exception ex) { Console.WriteLine($"[store-edits] could not save the store id migration: {ex.Message}"); } if (moved > 0) Console.WriteLine($"[store-edits] {moved} pop-up shop item(s) moved to the 2025 store ids"); } private static void Save() { // caller holds _lock var tmp = FilePath + ".tmp"; File.WriteAllText(tmp, _root!.ToString(Newtonsoft.Json.Formatting.Indented)); File.Move(tmp, FilePath, true); _version++; lock (_applied) _applied.Clear(); } private static JObject StoreNode(int store, bool create) { var stores = (JObject)Load()["Stores"]!; if (stores[store.ToString()] is JObject s) return s; var n = new JObject { ["Removed"] = new JArray(), ["Rows"] = new JArray() }; if (create) stores[store.ToString()] = n; return n; } private static int Pid(JToken row) => (int?)row["PurchasableItemId"] ?? 0; // ---- reads -------------------------------------------------------------------------- public static bool HasEdits(int store) { lock (_lock) { var s = StoreNode(store, false); return ((s["Rows"] as JArray)?.Count ?? 0) > 0 || ((s["Removed"] as JArray)?.Count ?? 0) > 0; } } /// The admin row for this id in this store (added or replacing), cloned; null if none. public static JObject? RowFor(int store, int pid) { lock (_lock) { var rows = StoreNode(store, false)["Rows"] as JArray; return rows?.FirstOrDefault(r => Pid(r) == pid)?.DeepClone() as JObject; } } public static bool IsRemoved(int store, int pid) { lock (_lock) return (StoreNode(store, false)["Removed"] as JArray)?.Any(x => (int?)x == pid) == true; } /// Every admin row in any store with this id (store it belongs to first). public static List<(int store, JObject row)> RowsAnywhere(int pid) { var list = new List<(int, JObject)>(); lock (_lock) foreach (var p in (JObject)Load()["Stores"]!) if (int.TryParse(p.Key, out int st) && p.Value?["Rows"] is JArray rows) foreach (var r in rows) if (Pid(r) == pid) list.Add((st, (JObject)r.DeepClone())); return list; } /// (added, edited, removed) counts for one store; "edited" = rows replacing a base id. public static (int added, int edited, int removed) Counts(int store) { lock (_lock) { var s = StoreNode(store, false); int added = 0, edited = 0; foreach (var r in s["Rows"] as JArray ?? new JArray()) if (Pid(r) >= AdminIdBase) added++; else edited++; return (added, edited, (s["Removed"] as JArray)?.Count ?? 0); } } public static HashSet RemovedIds(int store) { lock (_lock) return new HashSet((StoreNode(store, false)["Removed"] as JArray ?? new JArray()).Select(x => (int?)x ?? 0)); } public static List Rows(int store) { lock (_lock) return (StoreNode(store, false)["Rows"] as JArray ?? new JArray()).Select(r => (JObject)r.DeepClone()).ToList(); } // ---- laying the edits over a served store ------------------------------------------- /// Apply this store's edits to a store object in place: drop removed rows, swap in edited ones, append added ones. public static void ApplyTo(int store, JObject root) { List rows; HashSet removed; lock (_lock) { var s = StoreNode(store, false); rows = (s["Rows"] as JArray ?? new JArray()).Select(r => (JObject)r.DeepClone()).ToList(); removed = new HashSet((s["Removed"] as JArray ?? new JArray()).Select(x => (int?)x ?? 0)); } if (rows.Count == 0 && removed.Count == 0) return; foreach (var r in rows) { StoreGenerator.ShapeRow(r); r["CanBeGifted"] = StoreCatalog.PricedInTokens(r); } var byPid = rows.GroupBy(Pid).ToDictionary(g => g.Key, g => g.Last()); var used = new HashSet(); var outRows = new JArray(); foreach (var it in root["StoreItems"] as JArray ?? new JArray()) { int pid = Pid(it); if (removed.Contains(pid)) continue; if (byPid.TryGetValue(pid, out var rep)) { outRows.Add(rep); used.Add(pid); } else outRows.Add(it); } foreach (var r in rows) if (used.Add(Pid(r))) outRows.Add(r); root["StoreItems"] = outRows; } private static readonly Dictionary _applied = new(); /// The served store JSON with this store's edits applied (unchanged string when it has none). Cached. public static string ApplyJson(int store, string json) { if (!HasEdits(store)) return json; string key = $"{store}|{_version}|{json.Length}|{json.GetHashCode()}"; lock (_applied) if (_applied.TryGetValue(key, out var hit)) return hit; try { var root = JObject.Parse(json); ApplyTo(store, root); string outJson = root.ToString(Newtonsoft.Json.Formatting.None); lock (_applied) { if (_applied.Count > 64) _applied.Clear(); _applied[key] = outJson; } return outJson; } catch (Exception ex) { Console.WriteLine($"[store-edits] could not apply edits to store {store}: {ex.Message}"); return json; } } // ---- writes (admin) ----------------------------------------------------------------- public static int NewId() { lock (_lock) { var root = Load(); int id = Math.Max(AdminIdBase, (int?)root["NextId"] ?? AdminIdBase); root["NextId"] = id + 1; Save(); return id; } } /// Store a row (added or replacing a base row), and un-remove its id. public static void Put(int store, JObject row) { lock (_lock) { var s = StoreNode(store, true); var rows = (JArray)s["Rows"]!; int pid = Pid(row); foreach (var old in rows.Where(r => Pid(r) == pid).ToList()) old.Remove(); rows.Add(row); foreach (var x in ((JArray)s["Removed"]!).Where(x => (int?)x == pid).ToList()) x.Remove(); Save(); } } /// Put several rows in one write (set assignment / rename). public static void PutMany(int store, IEnumerable newRows) { lock (_lock) { var s = StoreNode(store, true); var rows = (JArray)s["Rows"]!; var removed = (JArray)s["Removed"]!; foreach (var row in newRows) { int pid = Pid(row); foreach (var old in rows.Where(r => Pid(r) == pid).ToList()) old.Remove(); rows.Add(row); foreach (var x in removed.Where(x => (int?)x == pid).ToList()) x.Remove(); } Save(); } } /// Take an id off sale: an added row is deleted, a base row is listed as removed. public static void Remove(int store, int pid) { lock (_lock) { var s = StoreNode(store, true); foreach (var old in ((JArray)s["Rows"]!).Where(r => Pid(r) == pid).ToList()) old.Remove(); var removed = (JArray)s["Removed"]!; if (pid < AdminIdBase && !removed.Any(x => (int?)x == pid)) removed.Add(pid); Save(); } } /// Undo every edit to a base id: back on sale, original price and set. public static void Restore(int store, int pid) { lock (_lock) { var s = StoreNode(store, true); foreach (var old in ((JArray)s["Rows"]!).Where(r => Pid(r) == pid && pid < AdminIdBase).ToList()) old.Remove(); foreach (var x in ((JArray)s["Removed"]!).Where(x => (int?)x == pid).ToList()) x.Remove(); Save(); } } } }