Files
2026-10-14 06:59:51 +02:00

320 lines
14 KiB
C#

using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// 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.
/// </summary>
public static class StoreEdits
{
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>The admin row for this id in this store (added or replacing), cloned; null if none.</summary>
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;
}
/// <summary>Every admin row in any store with this id (store it belongs to first).</summary>
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;
}
/// <summary>(added, edited, removed) counts for one store; "edited" = rows replacing a base id.</summary>
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<int> RemovedIds(int store)
{
lock (_lock)
return new HashSet<int>((StoreNode(store, false)["Removed"] as JArray ?? new JArray()).Select(x => (int?)x ?? 0));
}
public static List<JObject> 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 -------------------------------------------
/// <summary>Apply this store's edits to a store object in place: drop removed rows, swap in edited ones, append added ones.</summary>
public static void ApplyTo(int store, JObject root)
{
List<JObject> rows; HashSet<int> removed;
lock (_lock)
{
var s = StoreNode(store, false);
rows = (s["Rows"] as JArray ?? new JArray()).Select(r => (JObject)r.DeepClone()).ToList();
removed = new HashSet<int>((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<int>();
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<string, string> _applied = new();
/// <summary>The served store JSON with this store's edits applied (unchanged string when it has none). Cached.</summary>
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;
}
}
/// <summary>Store a row (added or replacing a base row), and un-remove its id.</summary>
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();
}
}
/// <summary>Put several rows in one write (set assignment / rename).</summary>
public static void PutMany(int store, IEnumerable<JObject> 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();
}
}
/// <summary>Take an id off sale: an added row is deleted, a base row is listed as removed.</summary>
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();
}
}
/// <summary>Undo every edit to a base id: back on sale, original price and set.</summary>
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();
}
}
}
}