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

481 lines
28 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json.Linq;
namespace Campus_live_rewrite.api.server
{
/// <summary>
/// The Watch store (storefront 3) as the 2025 client expects it - ported from the reference
/// server's generated `sf3-2025.json` (econ/src/catalog-load.ts + catalogStoreItems).
///
/// The reference serves builds after 20230414 a MERGED store: the captured catalog's rows
/// that have no avatar item behind them (consumables, the random boxes) plus ONE GENERATED
/// ROW PER SELLABLE AVATAR ITEM in the item catalog, numbered from 10000 upward so nothing
/// can collide with a captured PurchasableItemId (every real one is 2764 or below).
///
/// Why it matters here, and why the store looked broken without it:
///
/// * The store pages are algorithmic-list rows that answer BARE PurchasableItemIds, and the
/// client resolves each one against the storefront it loaded. Our 3.json was a 1161-row
/// 2023 capture; the reference's is 2950 rows in a different id space. Any row that named
/// an id our file didn't hold rendered nothing - and an id our file held under a
/// DIFFERENT item showed that item when clicked. Generating from the catalog makes the
/// ids and the storefront one thing, so a click shows what was clicked.
/// * Only 1161 of ~3300 sellable items were for sale at all.
/// * Prices: a generated row is priced by rarity from ONE table (PRICE_BY_RARITY), which is
/// the same table the purchase check uses - the reference's point being that a store which
/// prices a buy differently from the file it listed refuses every purchase as "Price has
/// changed".
///
/// One deliberate difference from the reference: captured avatar-item rows whose descriptor is
/// NOT in avatar_items.json are KEPT rather than dropped. That is the 19 permanent hair dyes,
/// which are what unlock hair colours (see HairGrants) - the reference loses them.
///
/// Ids are assigned by catalog ORDER, so they are stable across restarts for a given
/// avatar_items.json and change only if the catalog itself is re-ordered - the same
/// load-order surrogate the reference uses. Nothing may persist one.
/// </summary>
public static class StoreGenerator
{
public const int WatchStorefront = 3;
/// <summary>First generated id. Above every captured PurchasableItemId (the real Watch store
/// runs to 33691 - ImportRealStore.py; the old synthetic file topped out at 2764).</summary>
public const int CatalogIdBase = 100_000;
/// <summary>The developer / unreleased tier. In the catalog, never in a store.</summary>
public static bool IsSellableRarity(int rarity) => rarity != -1;
// Reference PRICE_BY_RARITY: 0/10/30/50 were specified; 20 sits between its neighbours.
// A rarity nobody priced costs the floor rather than vanishing - an unpriced item
// silently missing from the store is harder to notice than one that turns up cheap.
private static readonly Dictionary<int, int> PriceByRarity = new()
{
[0] = 500, [10] = 600, [20] = 700, [30] = 800, [50] = 3000 // clothing never under 500 (owner rule)
};
public const int DefaultPrice = 500;
public static int PriceForRarity(int rarity) => PriceByRarity.TryGetValue(rarity, out var p) ? p : DefaultPrice;
/// <summary>What Rec Room Plus pays: 10% off, floored - the same band StoreCatalog.SubscriberPriceFor allows.</summary>
public static int SubscriberPriceFor(int price) => (int)Math.Floor(price * (100 - StoreCatalog.RRPlusDiscountPercent) / 100.0);
private static readonly object _lock = new();
private static JObject? _merged;
private static Dictionary<int, StoreCatalog.GiftDropInfo>? _generated;
// The downloadable twin a captured row NAMED (AvatarItemInfo.DownloadableAvatarItemId),
// by PurchasableItemId - kept only so OutfitTypeOf can still read the twin's wardrobe slot
// after ShapeRow has taken AvatarItemInfo off the wire (see ShapeRow for why it must go).
private static Dictionary<int, string> _twinByPurchasable = new();
private static string _stamp = "";
private static string StorePath => Path.Combine(StoreCatalog.StoreDirectory, $"{WatchStorefront}.json");
private static string CatalogPath => Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "avatar_items.json");
private static string EquipmentPath => Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "Equipments.json");
/// <summary>First generated SKIN id - its own range above the avatar-item rows.</summary>
public const int SkinIdBase = 140_000;
/// <summary>PurchasableItemIds of every skin row in the Watch store (captured + generated), for the Skins carousel.</summary>
public static List<int> SkinPurchasableIds()
{
var store = MergedWatchStore()["StoreItems"] as JArray ?? new JArray();
return store.Where(it => !string.IsNullOrEmpty((string?)it["GiftDrop"]?["EquipmentPrefabName"]))
.Select(it => (int?)it["PurchasableItemId"] ?? 0).Where(i => i > 0).ToList();
}
private static string Stamp()
{
static string T(string p) => File.Exists(p) ? File.GetLastWriteTimeUtc(p).Ticks.ToString() : "-";
string shown = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "ShownItems.json");
string defaults = Path.Combine(Environment.CurrentDirectory, "Data", "APIS", "items", "DefaultAvatarItems.json");
return T(StorePath) + "|" + T(CatalogPath) + "|" + T(EquipmentPath) + "|" + (ConfigCampus.HiddenItemsStorefronts ? "H" + T(shown) + T(defaults) : "-");
}
/// <summary>The merged Watch store, rebuilt when either source file changes on disk.</summary>
public static JObject MergedWatchStore()
{
string stamp = Stamp();
if (_merged != null && stamp == _stamp) return _merged;
lock (_lock)
{
if (_merged != null && stamp == _stamp) return _merged;
Build();
_stamp = stamp;
return _merged!;
}
}
public static string MergedWatchStoreJson() => MergedWatchStore().ToString(Newtonsoft.Json.Formatting.None);
/// <summary>A generated row by its PurchasableItemId (10000+), or null. What buyItem resolves against.</summary>
public static StoreCatalog.GiftDropInfo? FindGenerated(int purchasableItemId)
{
if (purchasableItemId < CatalogIdBase) return null;
MergedWatchStore();
return _generated != null && _generated.TryGetValue(purchasableItemId, out var info) ? info : null;
}
/// <summary>
/// Bring a captured row up to the wire's rules whatever capture it came from: AvatarItemType
/// is NULL unless the row sells an avatar item (a skin / consumable / box with 0 there is
/// opened as an avatar item with nothing to load), and the modern keys exist.
///
/// AvatarItemInfo is ALWAYS null on the wire. A real capture (Watch_EnumValue_3.json) carries
/// { LegacyAvatarItemDesc, DownloadableAvatarItemId } on 2,300+ legacy rows, and that twin id
/// is what makes the 20250718 client fetch the item's CAI assetbundle from this server
/// instead of drawing the legacy item from its own built-in db - and every such download
/// dies inside the client's bundle loader (RemoteProviderException / NullReferenceException
/// in CBACIMLIBPF.PNKGBHGAHHH, identical bytes and headers to the reference CDN), so the
/// item comes up "Can't load item". The reference server never emits AvatarItemInfo at all:
/// its 2025 store (sf3-2025.json, `runx storefront build`) is generated from the legacy item
/// catalog with AvatarItemDesc only, and its legacy-saves endpoint answers an empty map -
/// the client renders every store item from what it shipped with. Same here.
/// </summary>
public static void ShapeRow(JObject row)
{
if (row["GiftDrop"] is not JObject gd) return;
bool avatar = !string.IsNullOrWhiteSpace((string?)gd["AvatarItemDesc"]) || !string.IsNullOrWhiteSpace((string?)gd["CustomAvatarItemId"]);
if (!avatar) gd["AvatarItemType"] = null;
else if (gd["AvatarItemType"] == null || gd["AvatarItemType"]!.Type == JTokenType.Null) gd["AvatarItemType"] = 0;
gd["AvatarItemInfo"] = null;
gd["CustomAvatarItemId"] ??= null;
gd["ThumbnailImageName"] ??= null;
gd["TagList"] ??= null;
if (gd["AvatarItemId"] == null) gd["AvatarItemId"] = 0;
if (gd["EquipmentItemId"] == null) gd["EquipmentItemId"] = 0;
if (gd["ItemCount"] == null) gd["ItemCount"] = 1;
if (row["OnlyAvailableThroughCv2"] == null) row["OnlyAvailableThroughCv2"] = false;
}
/// <summary>
/// The wardrobe slot (RecRoom.Avatars.OutfitType) a Watch row sells into, or -1 when the
/// catalog does not record it. A custom item records its OutfitType; a legacy row that
/// carries AvatarItemInfo names its downloadable twin, which records it too. This is what
/// the store's category carousels sort by (APIController.GetAlgorithmicList); rows without
/// a slot fall back to the name needles.
/// </summary>
public static int OutfitTypeOf(JToken row)
{
var gd = row["GiftDrop"];
if (gd == null) return -1;
string? caid = (string?)gd["CustomAvatarItemId"];
if (string.IsNullOrWhiteSpace(caid) && gd["AvatarItemInfo"] is JObject info) caid = (string?)info["DownloadableAvatarItemId"];
if (string.IsNullOrWhiteSpace(caid) && (int?)row["PurchasableItemId"] is int pid)
{
MergedWatchStore();
_twinByPurchasable.TryGetValue(pid, out caid);
}
if (string.IsNullOrWhiteSpace(caid)) return -1;
return CustomItemsCache.GetRec(caid)?.OutfitType ?? -1;
}
private static void Build()
{
var root = new JObject
{
["NewUntil"] = null,
["NextUpdate"] = "2222-02-22T22:22:00Z",
["StoreItems"] = new JArray(),
["StorefrontName"] = null,
["StorefrontType"] = WatchStorefront,
["SubscriberDiscountPercent"] = StoreCatalog.RRPlusDiscountPercent
};
var items = (JArray)root["StoreItems"]!;
var generated = new Dictionary<int, StoreCatalog.GiftDropInfo>();
var twins = new Dictionary<int, string>();
try
{
// ---- the catalog: every avatar item, in file order ------------------------------
var catalog = File.Exists(CatalogPath) ? JArray.Parse(File.ReadAllText(CatalogPath)) : new JArray();
var inCatalog = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var e in catalog)
{
string k = dbs.PlayerDB.NormalizeAvatarItemDesc((string?)e["AvatarItemDesc"]);
if (k.Length > 0) inCatalog.Add(k);
}
// ---- captured rows worth keeping -----------------------------------------------
// Kept: anything with no avatar item behind it (consumables, IsQuery random boxes),
// and an avatar item the catalog does not know (the hair dyes). Dropped: an avatar
// item the catalog DOES know - it is regenerated below under a catalog id, so the
// same item cannot be listed twice under two ids at two prices.
int kept = 0, dropped = 0, hidden = 0;
var capturedSkins = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Descriptors the captured store already sells. A REAL capture (ImportRealStore.py)
// carries the real PurchasableItemId, price, thumbnail and AvatarItemInfo for an
// item, which beats a row generated from the catalog - so those rows are KEPT and
// the catalog only fills in what the capture does not sell. (The old synthetic
// 3.json was the other way round: its rows were dropped and regenerated.)
var capturedDescs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (File.Exists(StorePath))
{
var captured = JObject.Parse(File.ReadAllText(StorePath));
if (root["NewUntil"] != null) root["NewUntil"] = captured["NewUntil"];
foreach (var it in captured["StoreItems"] as JArray ?? new JArray())
{
string rawDesc = (string?)it["GiftDrop"]?["AvatarItemDesc"];
string desc = dbs.PlayerDB.NormalizeAvatarItemDesc(rawDesc);
if (desc.Length > 0 && inCatalog.Contains(desc))
{
if (!ConfigCampus.PreferCapturedStoreRows || !capturedDescs.Add(desc)) { dropped++; continue; }
}
if (!Classes.ItemGuids.StoreCanList(rawDesc)) { hidden++; continue; }
// A custom-item row is art the client fetches from this server too - the
// same TEMPORARY gate as the descriptor rows (ConfigCampus.HiddenItemsStorefronts).
if (ConfigCampus.HiddenItemsStorefronts && !string.IsNullOrWhiteSpace((string?)it["GiftDrop"]?["CustomAvatarItemId"])) { hidden++; continue; }
var row = (JObject)it.DeepClone();
// Remember the twin BEFORE ShapeRow drops it from the wire (OutfitTypeOf).
if (it["GiftDrop"]?["AvatarItemInfo"] is JObject twinInfo && (int?)it["PurchasableItemId"] is int twinPid
&& !string.IsNullOrWhiteSpace((string?)twinInfo["DownloadableAvatarItemId"]))
twins[twinPid] = (string)twinInfo["DownloadableAvatarItemId"]!;
ShapeRow(row);
// Skin rows: spell the guid as the UUID Equipments.json uses, so the client's
// owned check and the purchase both resolve the same skin.
if (row["GiftDrop"] is JObject gdRow)
{
string? eg = (string?)gdRow["EquipmentModificationGuid"];
if (!string.IsNullOrWhiteSpace(eg))
{
gdRow["EquipmentModificationGuid"] = Classes.ItemGuids.NormalizeGuid(eg) ?? eg;
capturedSkins.Add(((string?)gdRow["EquipmentModificationGuid"] ?? "").ToLowerInvariant());
}
}
// Token-priced rows can be sent as a gift (the store's gift button reads
// this; the level gate is at the till - ConfigCampus.GiftMinLevel).
row["CanBeGifted"] = StoreCatalog.PricedInTokens(row);
items.Add(row);
kept++;
}
}
// ---- one generated row per sellable catalog item ---------------------------------
// Numbered by catalog POSITION (base + index over every row, sellable or not) so an
// item's id does not shift when a neighbour's rarity changes.
int made = 0;
for (int i = 0; i < catalog.Count; i++)
{
var e = catalog[i];
string desc = (string?)e["AvatarItemDesc"] ?? "";
if (string.IsNullOrWhiteSpace(desc)) continue;
int rarity = (int?)e["Rarity"] ?? 0;
if (!IsSellableRarity(rarity)) continue;
if (capturedDescs.Contains(dbs.PlayerDB.NormalizeAvatarItemDesc(desc))) continue; // the capture sells it
if (!Classes.ItemGuids.StoreCanList(desc)) { hidden++; continue; } // HiddenItemsStorefronts
int id = CatalogIdBase + i;
int price = PriceForRarity(rarity);
int sub = SubscriberPriceFor(price);
string name = (string?)e["FriendlyName"] ?? "";
int type = (int?)e["AvatarItemType"] ?? 0;
items.Add(new JObject
{
["GiftDrop"] = new JObject
{
["AvatarItemDesc"] = desc, // RAW - the client parses this
["AvatarItemType"] = type,
["AvatarItemId"] = (int?)e["AvatarItemId"] ?? 0,
["AvatarItemInfo"] = null, // legacy path: the client's own item db
["ConsumableItemDesc"] = "",
["Context"] = 0,
["Currency"] = 0,
["CurrencyType"] = 0,
["CustomAvatarItemId"] = null,
["EquipmentItemId"] = 0,
["EquipmentModificationGuid"] = "",
["EquipmentPrefabName"] = "",
["FriendlyName"] = name,
["GiftDropId"] = id,
["IsQuery"] = false,
["ItemCount"] = 1,
["ItemSetFriendlyName"] = "",
["ItemSetId"] = null,
["QueryRedirectContext"] = null,
["QueryRedirectRarity"] = null,
["QueryRedirectTag"] = null,
["Rarity"] = rarity,
["SubscribersOnly"] = false,
["TagList"] = null,
["ThumbnailImageName"] = (string?)e["ThumbnailImage"],
["Tooltip"] = (string?)e["Tooltip"] ?? "",
["Unique"] = false
},
["IsFeatured"] = false,
["CanBeGifted"] = true,
["Prices"] = new JArray { new JObject { ["CurrencyType"] = (int)Classes.CurrencyType.RecCenterTokens, ["Price"] = price, ["StorefrontSaleData"] = null, ["Type"] = 0 } },
["PurchasableItemId"] = id,
["SubscriberPrices"] = new JArray { new JObject { ["CurrencyType"] = (int)Classes.CurrencyType.RecCenterTokens, ["Price"] = sub, ["StorefrontSaleData"] = null, ["Type"] = 0 } },
["Type"] = 0
});
generated[id] = new StoreCatalog.GiftDropInfo
{
Cost = price,
Currency = (int)Classes.CurrencyType.RecCenterTokens,
SubscriberCost = sub,
SubscriberCurrency = (int)Classes.CurrencyType.RecCenterTokens,
Reward = desc,
Name = name,
Rarity = rarity,
GiftDropId = id,
ConsumableItemDesc = "",
AvatarItemType = type,
GdCurrencyType = 0,
GdCurrency = 0,
EquipmentPrefabName = "",
EquipmentModificationGuid = "",
ThumbnailImageName = (string?)e["ThumbnailImage"] ?? "",
AvatarItemId = (int?)e["AvatarItemId"] ?? 0,
StoreFile = "generated",
PurchasableItemId = id,
StorefrontType = WatchStorefront
};
made++;
}
// ---- one generated row per equipment SKIN the captured store does not sell ------
// Equipments.json is the whole skin inventory (340); the captured Watch store only
// sells 187 of them. The rest are listed here under SkinIdBase + index, priced by
// rarity like the avatar items, so the Skins carousel and the locker agree.
int skins = 0;
if (File.Exists(EquipmentPath))
{
var equip = JArray.Parse(File.ReadAllText(EquipmentPath));
for (int i = 0; i < equip.Count; i++)
{
var e = equip[i];
string prefab = (string?)e["PrefabName"] ?? "";
string guid = Classes.ItemGuids.NormalizeGuid((string?)e["ModificationGuid"]) ?? ((string?)e["ModificationGuid"] ?? "");
if (prefab.Length == 0 || guid.Length == 0) continue;
if (capturedSkins.Contains(guid)) continue;
int rarity = (int?)e["Rarity"] ?? 0;
if (!IsSellableRarity(rarity)) continue;
string name = (string?)e["FriendlyName"] ?? prefab;
int id = SkinIdBase + i;
int price = PriceForRarity(rarity);
int sub = SubscriberPriceFor(price);
items.Add(new JObject
{
["GiftDrop"] = new JObject
{
["AvatarItemDesc"] = "",
["AvatarItemType"] = null, // a skin is NOT an avatar item (see StoreCatalog.AvatarTypeFor)
["AvatarItemId"] = 0,
["AvatarItemInfo"] = null,
["ConsumableItemDesc"] = "",
["Context"] = 0,
["Currency"] = 0,
["CurrencyType"] = 0,
["CustomAvatarItemId"] = null,
["EquipmentItemId"] = 0,
["EquipmentModificationGuid"] = guid,
["EquipmentPrefabName"] = prefab,
["FriendlyName"] = name,
["GiftDropId"] = id,
["IsQuery"] = false,
["ItemCount"] = 1,
["ItemSetFriendlyName"] = "",
["ItemSetId"] = null,
["QueryRedirectContext"] = null,
["QueryRedirectRarity"] = null,
["QueryRedirectTag"] = null,
["Rarity"] = rarity,
["SubscribersOnly"] = false,
["TagList"] = "",
["ThumbnailImageName"] = null,
["Tooltip"] = (string?)e["Tooltip"] ?? "",
["Unique"] = false
},
["IsFeatured"] = false,
["CanBeGifted"] = true,
["Prices"] = new JArray { new JObject { ["CurrencyType"] = (int)Classes.CurrencyType.RecCenterTokens, ["Price"] = price, ["StorefrontSaleData"] = null, ["Type"] = 0 } },
["PurchasableItemId"] = id,
["SubscriberPrices"] = new JArray { new JObject { ["CurrencyType"] = (int)Classes.CurrencyType.RecCenterTokens, ["Price"] = sub, ["StorefrontSaleData"] = null, ["Type"] = 0 } },
["Type"] = 0
});
generated[id] = new StoreCatalog.GiftDropInfo
{
Cost = price,
Currency = (int)Classes.CurrencyType.RecCenterTokens,
SubscriberCost = sub,
SubscriberCurrency = (int)Classes.CurrencyType.RecCenterTokens,
Reward = "",
Name = name,
Rarity = rarity,
GiftDropId = id,
ConsumableItemDesc = "",
AvatarItemType = 0,
GdCurrencyType = 0,
GdCurrency = 0,
EquipmentPrefabName = prefab,
EquipmentModificationGuid = guid,
StoreFile = "generated-skin",
PurchasableItemId = id,
StorefrontType = WatchStorefront
};
skins++;
}
}
Console.WriteLine($"[store] Watch store: {kept} captured row(s) kept, {dropped} replaced by catalog rows, {made} generated from the catalog, {skins} skin(s) from Equipments.json" +
(hidden > 0 ? $", {hidden} hidden (HiddenItemsStorefronts)" : "") + $" -> {items.Count} for sale");
}
catch (Exception ex)
{
Console.WriteLine($"[store] could not generate the Watch store ({ex.Message}) - serving the captured file as-is");
try
{
if (File.Exists(StorePath))
{
root = JObject.Parse(File.ReadAllText(StorePath));
generated.Clear();
}
}
catch { }
}
// Hair dye is free (HairGrants) - applied to the merged object so the badge and the
// till agree here exactly as they do for the other store files.
// LOCAL EXPERIMENT (HoldNewAccountCreation): the client's starter avatar wears two custom items we have no record of and logs
// "[Economy.Store] Can't get purchase info for item <id>" for each - the exception that follows stops the creation screen's
// clothing tabs and its DONE button. A free store row each gives the Store something to answer with.
if (ConfigCampus.HoldNewAccountCreation)
{
int pid = 9900001;
foreach (var cid in Campus_live_rewrite.api.Controllers.APIController.StarterCustomItemIds)
{
items.Add(new JObject
{
["GiftDrop"] = new JObject
{
["GiftDropId"] = pid, ["FriendlyName"] = "(Starter item)", ["Tooltip"] = "", ["TagList"] = "", ["ConsumableItemDesc"] = "",
["AvatarItemDesc"] = "", ["CustomAvatarItemId"] = cid, ["AvatarItemType"] = 0, ["EquipmentPrefabName"] = "", ["EquipmentModificationGuid"] = "",
["IsQuery"] = false, ["QueryRedirectContext"] = null, ["QueryRedirectTag"] = null, ["QueryRedirectRarity"] = null, ["Unique"] = false,
["SubscribersOnly"] = false, ["Rarity"] = 0, ["Context"] = 1003, ["Currency"] = 0, ["CurrencyType"] = 0, ["ItemCount"] = 1, ["ItemSetId"] = null,
["ItemSetFriendlyName"] = "", ["AvatarItemId"] = 0, ["EquipmentItemId"] = 0, ["ThumbnailImageName"] = null, ["AvatarItemInfo"] = null
},
["PurchasableItemId"] = pid, ["Type"] = 0,
["Prices"] = new JArray(new JObject { ["CurrencyType"] = 2, ["Price"] = 0, ["StorefrontSaleData"] = null }),
["SubscriberPrices"] = new JArray(new JObject { ["CurrencyType"] = 2, ["Price"] = 0, ["StorefrontSaleData"] = null }),
["IsFeatured"] = false, ["NewUntil"] = "2020-01-01T00:00:00Z", ["AvailableAt"] = "2020-01-01T00:00:00Z", ["AvailableUntil"] = null,
["CanBeGifted"] = false, ["OnlyAvailableThroughCv2"] = false
});
pid++;
}
Console.WriteLine("[store] local experiment: 2 free starter-item rows added for the creation screen");
}
HairGrants.ZeroDyePrices(root);
_merged = root;
_generated = generated;
_twinByPurchasable = twins;
}
}
}