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

436 lines
22 KiB
C#

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
{
/// <summary>
/// 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.
/// </summary>
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<int> 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,
};
/// <summary>The store's own rows before edits (the Watch store as generated). Do not mutate the Watch rows.</summary>
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<JObject>()) 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;
/// <summary>The row as the store now sells it (edit first, else base), cloned; null if neither.</summary>
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<JObject> 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<object>();
foreach (int id in Enum.GetValues(typeof(StorefrontType)).Cast<int>().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<CurrencyType>()
.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<object>();
var basePids = new HashSet<int>();
foreach (var b in baseRows.OfType<JObject>())
{
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<JObject>().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 ---------------------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
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<JObject>().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<ContentResult> 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<JObject>().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<ContentResult> 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<ContentResult> 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<ContentResult> 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." });
}
/// <summary>Put items in a set by its id, and/or rename that set everywhere in the store.</summary>
[HttpPost("/admin/api/stores/{store:int}/set")]
public async Task<ContentResult> 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<JObject>();
// 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<JObject>().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." });
}
}
}