using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; using Campus_live_rewrite.api.dbs; using Campus_live_rewrite.api.server; using Campus_live_rewrite.api.server.Classes; namespace Campus_live_rewrite.Controllers { /// /// Admin panel Stores tab: pick a storefront, see its items grouped by set (GiftDrop.ItemSetId /// / ItemSetFriendlyName - the sets the in-game store shows), add any item, change prices, /// sets and the featured flag, take items off sale. Every change is an overlay in /// Data/APIS/store-edits.json (StoreEdits) - the store files are never written. /// public class AdminStoresController : Controller { private ContentResult J(object o, int status = 200) => new ContentResult { Content = Newtonsoft.Json.JsonConvert.SerializeObject(o), ContentType = "application/json", StatusCode = status }; // Storefronts whose contents the server builds per player/room - not editable stock. private static readonly HashSet NotStock = new() { (int)StorefrontType.None, (int)StorefrontType.Wishlist, (int)StorefrontType.RoomKeys, (int)StorefrontType.Player_Profile, (int)StorefrontType.Room_Save, (int)StorefrontType.RoomCurrencies, (int)StorefrontType.RoomConsumables, (int)StorefrontType.CustomAvatarItems, (int)StorefrontType.ProgressionEventPurchasableXpBoosts, (int)StorefrontType.RoomOffers, }; /// The store's own rows before edits (the Watch store as generated). Do not mutate the Watch rows. private static JArray BaseRows(int store) { try { // Minus the items no store sells (StoreServe.Excluded), so the tab lists what // players actually see. if (store == StoreGenerator.WatchStorefront) return new JArray((StoreGenerator.MergedWatchStore()["StoreItems"] as JArray ?? new JArray()).Where(r => !StoreServe.Excluded(r))); var f = StoreCatalog.StoreFileForId(store); if (f.Length == 0) return new JArray(); var arr = JObject.Parse(System.IO.File.ReadAllText(f))["StoreItems"] as JArray ?? new JArray(); foreach (var r in arr.OfType()) StoreGenerator.ShapeRow(r); return new JArray(arr.Where(r => !StoreServe.Excluded(r))); } catch (Exception ex) { Console.WriteLine($"[admin-stores] could not read store {store}: {ex.Message}"); return new JArray(); } } private static int Pid(JToken r) => (int?)r["PurchasableItemId"] ?? 0; /// The row as the store now sells it (edit first, else base), cloned; null if neither. private static JObject? Effective(int store, int pid, JArray? baseRows = null) { var e = StoreEdits.RowFor(store, pid); if (e != null) return e; var b = (baseRows ?? BaseRows(store)).FirstOrDefault(r => Pid(r) == pid); return b?.DeepClone() as JObject; } private static string Kind(JObject gd) { if ((bool?)gd["IsQuery"] == true) return "random box"; if (!string.IsNullOrWhiteSpace((string?)gd["CustomAvatarItemId"])) return "custom item"; if (!string.IsNullOrWhiteSpace((string?)gd["AvatarItemDesc"])) return "avatar item"; if (!string.IsNullOrWhiteSpace((string?)gd["EquipmentModificationGuid"])) return "skin"; if (!string.IsNullOrWhiteSpace((string?)gd["ConsumableItemDesc"])) return "consumable"; return "other"; } private static object Dto(JObject r, string state) { var gd = r["GiftDrop"] as JObject ?? new JObject(); var p0 = (r["Prices"] as JArray)?.FirstOrDefault(); var s0 = (r["SubscriberPrices"] as JArray)?.FirstOrDefault(); return new { pid = Pid(r), giftDropId = (int?)gd["GiftDropId"] ?? 0, name = (string?)gd["FriendlyName"] ?? "", kind = Kind(gd), desc = (string?)gd["AvatarItemDesc"] ?? "", rarity = (int?)gd["Rarity"] ?? 0, price = (int?)p0?["Price"] ?? 0, currency = (int?)p0?["CurrencyType"] ?? 0, subPrice = (int?)s0?["Price"], setId = gd["ItemSetId"]?.Type == JTokenType.Integer ? (int?)gd["ItemSetId"] : null, setName = (string?)gd["ItemSetFriendlyName"] ?? "", featured = (bool?)r["IsFeatured"] ?? false, thumb = (string?)gd["ThumbnailImageName"] ?? "", state }; } private static string StoreName(int id) => Enum.IsDefined(typeof(StorefrontType), id) ? ((StorefrontType)id).ToString() : $"Store {id}"; private static int MainCurrency(JArray rows) { var c = rows.Select(r => (int?)(r["Prices"] as JArray)?.FirstOrDefault()?["CurrencyType"] ?? 0).Where(x => x > 0) .GroupBy(x => x).OrderByDescending(g => g.Count()).FirstOrDefault(); return c?.Key ?? (int)CurrencyType.RecCenterTokens; } private async Task Body() { using var r = new StreamReader(Request.Body); var s = await r.ReadToEndAsync(); return string.IsNullOrWhiteSpace(s) ? new JObject() : JObject.Parse(s); } private static void SetPrices(JObject row, int price, int currency, int? subPrice) { int sub = subPrice ?? StoreGenerator.SubscriberPriceFor(price); row["Prices"] = new JArray { new JObject { ["CurrencyType"] = currency, ["Price"] = price, ["StorefrontSaleData"] = null } }; row["SubscriberPrices"] = new JArray { new JObject { ["CurrencyType"] = currency, ["Price"] = sub, ["StorefrontSaleData"] = null } }; // When the price was set: the latest price set for an item, in any store, is what every // store sells it for (StorePrices). Stripped before the row reaches the client. row[StorePrices.PriceSetAtKey] = DateTime.UtcNow.ToString("o"); } private static void SetSet(JObject row, int? setId, string? setName) { var gd = (JObject)(row["GiftDrop"] ??= new JObject()); gd["ItemSetId"] = setId.HasValue ? setId.Value : JValue.CreateNull(); gd["ItemSetFriendlyName"] = setId.HasValue ? (setName ?? (string?)gd["ItemSetFriendlyName"] ?? "") : ""; } // ---- reads ---------------------------------------------------------------------------- [HttpGet("/admin/api/stores")] public ContentResult List() { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; var stores = new List(); foreach (int id in Enum.GetValues(typeof(StorefrontType)).Cast().Distinct().OrderBy(x => x)) { if (NotStock.Contains(id)) continue; // The game offers 10 pop-up shops; the enum reserves 20. 11-20 only show once stocked. if (id >= (int)StorefrontType.PopUpShop_11 && id <= (int)StorefrontType.PopUpShop_20 && !StoreEdits.HasEdits(id)) continue; bool hasFile = id == StoreGenerator.WatchStorefront || StoreCatalog.StoreFileForId(id).Length > 0; var (added, edited, removed) = StoreEdits.Counts(id); stores.Add(new { id, name = StoreName(id), hasFile, added, edited, removed }); } var currencies = Enum.GetValues(typeof(CurrencyType)).Cast() .Where(c => (int)c > 0).Select(c => new { id = (int)c, name = c.ToString() }); return J(new { success = true, stores, currencies }); } [HttpGet("/admin/api/stores/{store:int}")] public ContentResult Get(int store) { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; var baseRows = BaseRows(store); var removed = StoreEdits.RemovedIds(store); var edits = StoreEdits.Rows(store).GroupBy(Pid).ToDictionary(g => g.Key, g => g.Last()); var items = new List(); var basePids = new HashSet(); foreach (var b in baseRows.OfType()) { int pid = Pid(b); basePids.Add(pid); if (removed.Contains(pid)) items.Add(Dto(b, "removed")); else if (edits.TryGetValue(pid, out var e)) items.Add(Dto(e, "edited")); else items.Add(Dto(b, "base")); } foreach (var e in edits.Values) if (!basePids.Contains(Pid(e))) items.Add(Dto(e, "added")); // Sets: every ItemSetId in use, with its name and size. var effective = baseRows.OfType().Where(b => !removed.Contains(Pid(b))) .Select(b => edits.TryGetValue(Pid(b), out var e) ? e : b) .Concat(edits.Values.Where(e => !basePids.Contains(Pid(e)))).ToList(); var sets = effective.Select(r => r["GiftDrop"] as JObject).Where(g => g?["ItemSetId"]?.Type == JTokenType.Integer) .GroupBy(g => (int)g!["ItemSetId"]!) .Select(g => new { setId = g.Key, name = g.Select(x => (string?)x!["ItemSetFriendlyName"]).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)) ?? "", count = g.Count() }) .OrderBy(s => s.setId).ToList(); int nextSetId = Math.Max(1, (sets.Count > 0 ? sets.Max(s => s.setId) : 0) + 1); return J(new { success = true, store, name = StoreName(store), currency = MainCurrency(baseRows.Count > 0 ? baseRows : new JArray(effective)), items, sets, nextSetId }); } // ---- writes --------------------------------------------------------------------------- /// /// The row to copy for "add an item that another store sells", cloned. The item picker /// lists every store file raw, but the Watch store is SERVED generated: it drops captured /// rows whose item the catalog also sells and hides some items (StoreGenerator), so a /// picked row may not be in Effective(). Search wider rather than refuse: the store as /// served, then its raw file, then any admin row with that id, then every other store file. /// private static JObject? FindSourceRow(int store, int pid) { var row = Effective(store, pid); if (row != null) return row; JObject? FromFile(string f) { try { var hit = (JObject.Parse(System.IO.File.ReadAllText(f))["StoreItems"] as JArray)? .OfType().FirstOrDefault(r => Pid(r) == pid); if (hit != null) StoreGenerator.ShapeRow(hit); return hit; } catch { return null; } } var own = StoreCatalog.StoreFileForId(store); if (own.Length > 0 && (row = FromFile(own)) != null) return row; var anyEdit = StoreEdits.RowsAnywhere(pid).Select(x => x.row).FirstOrDefault(); if (anyEdit != null) return (JObject)anyEdit.DeepClone(); try { if (Directory.Exists(StoreCatalog.StoreDirectory)) foreach (var f in Directory.GetFiles(StoreCatalog.StoreDirectory, "*.json")) if (f != own && (row = FromFile(f)) != null) return row; } catch { } return null; } [HttpPost("/admin/api/stores/{store:int}/add")] public async Task Add(int store) { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; if (NotStock.Contains(store)) return J(new { success = false, message = "That storefront is built per player - it has no stock to edit." }, 400); var b = await Body(); int? sourceStore = (int?)b["sourceStore"]; int? sourcePid = (int?)b["sourcePid"]; string desc = ((string?)b["avatarItemDesc"] ?? "").Trim(); int price = Math.Max(0, (int?)b["price"] ?? 0); int currency = (int?)b["currency"] ?? (int)CurrencyType.RecCenterTokens; int? subPrice = (int?)b["subPrice"]; int? setId = b["setId"]?.Type == JTokenType.Integer ? (int?)b["setId"] : null; string? setName = (string?)b["setName"]; bool featured = (bool?)b["featured"] ?? false; bool allowDuplicate = (bool?)b["allowDuplicate"] ?? false; JObject row; bool keepGiftDropId = false; if (sourceStore is int ss && ss >= 0 && sourcePid is int sp && sp > 0) { // A row another store already sells (consumables, skins, boxes, captured items): // copied whole, so it opens exactly as it does there - including its GiftDropId, // which is what the gift box draws its art from. var src = FindSourceRow(ss, sp); if (src == null) return J(new { success = false, message = $"Store {ss} has no item {sp}." }, 404); row = src; keepGiftDropId = true; } else if (desc.Length > 0) { var e = AvatarCatalog.ByDesc(desc); row = new JObject { ["GiftDrop"] = new JObject { ["AvatarItemDesc"] = e?.Desc ?? desc, ["AvatarItemType"] = e?.AvatarItemType ?? 0, ["AvatarItemId"] = e?.AvatarItemId ?? 0, ["AvatarItemInfo"] = null, ["ConsumableItemDesc"] = "", ["Context"] = 0, ["Currency"] = 0, ["CurrencyType"] = 0, ["CustomAvatarItemId"] = null, ["EquipmentItemId"] = 0, ["EquipmentModificationGuid"] = "", ["EquipmentPrefabName"] = "", ["FriendlyName"] = e?.Name ?? "Item", ["IsQuery"] = false, ["ItemCount"] = 1, ["QueryRedirectContext"] = null, ["QueryRedirectRarity"] = null, ["QueryRedirectTag"] = null, ["Rarity"] = e?.Rarity ?? 0, ["SubscribersOnly"] = false, ["TagList"] = null, ["ThumbnailImageName"] = e?.Thumbnail, ["Tooltip"] = "", ["Unique"] = false }, ["Type"] = 0 }; } else return J(new { success = false, message = "Pick an item to add." }, 400); var gd = (JObject)(row["GiftDrop"] ??= new JObject()); // One item, one row per store (unless asked): the same avatar item twice at two prices // is what the generated Watch store exists to avoid. if (!allowDuplicate) { string key(JObject g) => PlayerDB.NormalizeAvatarItemDesc((string?)g["AvatarItemDesc"]) + "|" + ((string?)g["ConsumableItemDesc"] ?? "") + "|" + ((string?)g["EquipmentModificationGuid"] ?? "") + "|" + ((string?)g["CustomAvatarItemId"] ?? ""); string mine = key(gd); if (mine.Trim('|').Length > 0) { var removed = StoreEdits.RemovedIds(store); var edits = StoreEdits.Rows(store); var existing = BaseRows(store).OfType().Where(r => !removed.Contains(Pid(r))) .Select(r => edits.FirstOrDefault(e => Pid(e) == Pid(r)) ?? r) .Concat(edits.Where(e => Pid(e) >= StoreEdits.AdminIdBase)) .FirstOrDefault(r => r["GiftDrop"] is JObject g && key(g) == mine); if (existing != null) return J(new { success = false, duplicate = true, pid = Pid(existing), message = $"This store already sells it (item {Pid(existing)}). Edit that one, or add anyway." }, 409); } } int id = StoreEdits.NewId(); row["PurchasableItemId"] = id; if (!keepGiftDropId || (int?)gd["GiftDropId"] is null or 0) gd["GiftDropId"] = id; row["IsFeatured"] = featured; row["NewUntil"] = null; SetPrices(row, price, currency, subPrice); SetSet(row, setId, setName); StoreEdits.Put(store, row); Console.WriteLine($"[admin-stores] {account!.Username} added '{(string?)gd["FriendlyName"]}' to store {store} ({StoreName(store)}) as item {id}: {price} x{currency}, set {setId?.ToString() ?? "-"}"); return J(new { success = true, pid = id, message = $"Added \"{(string?)gd["FriendlyName"]}\" to {StoreName(store)}." }); } [HttpPost("/admin/api/stores/{store:int}/update")] public async Task Update(int store) { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; var b = await Body(); int pid = (int?)b["pid"] ?? 0; var row = Effective(store, pid); if (row == null) return J(new { success = false, message = $"No item {pid} in this store." }, 404); var gd = (JObject)(row["GiftDrop"] ??= new JObject()); var p0 = (row["Prices"] as JArray)?.FirstOrDefault(); int price = (int?)b["price"] ?? (int?)p0?["Price"] ?? 0; int currency = (int?)b["currency"] ?? (int?)p0?["CurrencyType"] ?? (int)CurrencyType.RecCenterTokens; bool priceChanged = b["price"] != null || b["currency"] != null || b["subPrice"] != null; if (priceChanged) SetPrices(row, Math.Max(0, price), currency, (int?)b["subPrice"]); if (b.ContainsKey("setId")) SetSet(row, b["setId"]?.Type == JTokenType.Integer ? (int?)b["setId"] : null, (string?)b["setName"]); else if (b["setName"] != null && gd["ItemSetId"]?.Type == JTokenType.Integer) gd["ItemSetFriendlyName"] = (string?)b["setName"] ?? ""; if (b["featured"] != null) row["IsFeatured"] = (bool?)b["featured"] ?? false; if (!string.IsNullOrWhiteSpace((string?)b["name"])) gd["FriendlyName"] = ((string)b["name"]!).Trim(); StoreEdits.Put(store, row); Console.WriteLine($"[admin-stores] {account!.Username} edited item {pid} in store {store}: {b.ToString(Newtonsoft.Json.Formatting.None)}"); return J(new { success = true, message = "Saved." }); } [HttpPost("/admin/api/stores/{store:int}/remove")] public async Task Remove(int store) { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; int pid = (int?)(await Body())["pid"] ?? 0; StoreEdits.Remove(store, pid); Console.WriteLine($"[admin-stores] {account!.Username} removed item {pid} from store {store}"); return J(new { success = true, message = pid >= StoreEdits.AdminIdBase ? "Deleted." : "Taken off sale (Restore puts it back)." }); } [HttpPost("/admin/api/stores/{store:int}/restore")] public async Task Restore(int store) { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; int pid = (int?)(await Body())["pid"] ?? 0; StoreEdits.Restore(store, pid); Console.WriteLine($"[admin-stores] {account!.Username} restored item {pid} in store {store}"); return J(new { success = true, message = "Restored to the original." }); } /// Put items in a set by its id, and/or rename that set everywhere in the store. [HttpPost("/admin/api/stores/{store:int}/set")] public async Task Set(int store) { var (account, error) = AuthHelper.ValidateTokenDev(HttpContext); if (error != null) return error; var b = await Body(); if (b["setId"]?.Type != JTokenType.Integer) return J(new { success = false, message = "Give the set id number." }, 400); int setId = (int)b["setId"]!; string? setName = (string?)b["setName"]; var pids = (b["pids"] as JArray ?? new JArray()).Select(x => (int?)x ?? 0).Where(x => x > 0).ToHashSet(); var baseRows = BaseRows(store); var removed = StoreEdits.RemovedIds(store); var changed = new List(); // the chosen items into the set foreach (int pid in pids) { var row = Effective(store, pid, baseRows); if (row == null) continue; SetSet(row, setId, setName); changed.Add(row); } // rename: every other item already in the set gets the new name if (!string.IsNullOrWhiteSpace(setName)) { var edits = StoreEdits.Rows(store); var all = baseRows.OfType().Where(r => !removed.Contains(Pid(r))).Select(r => Pid(r)) .Concat(edits.Where(e => Pid(e) >= StoreEdits.AdminIdBase).Select(Pid)).Distinct(); foreach (int pid in all) { if (pids.Contains(pid)) continue; var row = Effective(store, pid, baseRows); if (row?["GiftDrop"] is JObject g && (int?)g["ItemSetId"] == setId && (string?)g["ItemSetFriendlyName"] != setName) { g["ItemSetFriendlyName"] = setName; changed.Add(row); } } } if (changed.Count > 0) StoreEdits.PutMany(store, changed); Console.WriteLine($"[admin-stores] {account!.Username} set {setId} '{setName}' in store {store}: {changed.Count} item(s)"); return J(new { success = true, message = $"Set {setId}: {changed.Count} item(s) updated." }); } } }