using Microsoft.AspNetCore.Mvc; using DorkNet.Server.Services; using DorkNet.Server.Controllers.API.Avatar.V4; namespace DorkNet.Server.Controllers.Commerce; /// /// commerce.rec.net/api/catalog/* — the actual catalog endpoint the /// 2020 watch's Shop tab fetches when it loads. The previous /// implementation routed everything on commerce.rec.net through the /// generic StubController which always returned []; this /// controller wins on specific routes via ASP.NET Core's /// specific-over-wildcard precedence and returns the real /// catalog. /// /// Wire shape verified against the watch's Commerce.Catalog /// deserialiser logging "no items" when we returned an empty array /// vs populating the Shop UI when each item carries SkuId/Name/Cost /// keys. Each catalog DTO ships every common alias (PascalCase, /// camelCase, snake_case) so whichever JSON field the watch's /// strict-key reader expects, it finds. /// [ApiController] public class CommerceCatalogController(StoreService store, DomainConfig domain) : ControllerBase { [HttpGet("/api/catalog/v1/all")] [HttpGet("/api/catalog/v2/all")] [HttpGet("/api/catalog/v3/all")] public async Task All([FromQuery] bool onlyAvailableSkus = false) { var items = onlyAvailableSkus ? await store.GetAllActiveAsync() : await store.GetAllActiveAsync(); // same set; we don't distinguish yet return Ok(items.Select(i => ToCatalogDto(i, domain.Apex)).ToArray()); } [HttpGet("/api/catalog/v1/sku/{slug}")] [HttpGet("/api/catalog/v2/sku/{slug}")] public async Task Sku(string slug) { var item = await store.GetBySlugAsync(slug); if (item is null) return NotFound(); return Ok(ToCatalogDto(item, domain.Apex)); } /// POST /api/catalog/v1/byids — bulk lookup by id array. /// The watch sends {Ids:[1,2,3]} as form-urlencoded; accept both /// JSON and form bodies for safety. [HttpPost("/api/catalog/v1/byids")] [HttpPost("/api/catalog/v2/byids")] public async Task ByIds() { string? raw = null; if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); raw = form["Ids"].ToString(); if (string.IsNullOrWhiteSpace(raw)) raw = form["ids"].ToString(); } if (string.IsNullOrWhiteSpace(raw)) raw = Request.Query["Ids"].ToString(); if (string.IsNullOrWhiteSpace(raw)) raw = Request.Query["ids"].ToString(); if (string.IsNullOrWhiteSpace(raw)) return Ok(Array.Empty()); var ids = raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(s => long.TryParse(s, out var v) ? v : 0L) .Where(v => v > 0) .Distinct() .ToArray(); if (ids.Length == 0) return Ok(Array.Empty()); var all = await store.GetAllActiveAsync(); return Ok(all.Where(i => ids.Contains(i.Id)).Select(i => ToCatalogDto(i, domain.Apex)).ToArray()); } /// Catalog wire shape — every common key alias present /// (PascalCase + camelCase + snake_case) so the watch's /// strict-key deserialiser hits whichever name it's coded /// against without crashing on a missing field. /// /// Implemented as a Dictionary because System.Text.Json's /// case-insensitive collision check rejects anonymous types whose /// property names differ only in casing (SkuId vs /// skuId, etc.) — same trick we use in /// RelationshipsController.BuildRelationshipResponse. private static Dictionary ToCatalogDto(Data.Entities.StoreItemEntity i, string apex) { var available = i.IsActive && (i.AvailableUntil == null || i.AvailableUntil > DateTime.UtcNow); var imageUrl = string.IsNullOrEmpty(i.ImageName) ? "" : $"https://cdn.{apex}/{i.ImageName}"; // Avatar-item linkage — THE thing that makes wardrobe items appear in // the Shop's Clothing tab. The 2023 client does NOT categorize a SKU by // any field on the SKU itself; instead StoreItemListModel resolves the // SKU to the client's baked AvatarItem (by GUID) and reads that item's // OutfitType to place it in a tab. With no AvatarItemId the resolve // returns OutfitType = -1, the item matches no Clothing/Consumables // filter, and the whole Shop renders empty even though all 1531 SKUs // parsed fine. The GUID is the tail of the "wardrobe-{guid}" slug; ship // it (plus the OutfitType) under the keys the client reads // (AvatarItemId / AvatarItemType — verified vs 2023 ISIL store-item // context types EMBCEDNHFLB / CLIANCNHNIE). Coloured variants // (wardrobe-colored-*) resolve through a different descriptor path and // are left alone here. string? avatarItemId = null; int? avatarItemType = null; if (i.Slug.StartsWith("wardrobe-", StringComparison.Ordinal) && !i.Slug.StartsWith("wardrobe-colored-", StringComparison.Ordinal)) { var guid = i.Slug["wardrobe-".Length..]; avatarItemId = guid; if (AvatarItemsController.TryGetOutfitType(guid, out var ot)) avatarItemType = ot; } return new Dictionary { // Identity / naming — every alias the watch's strict // deserialiser might key on. // // CRITICAL: SkuId is typed int on // RecNet.Commerce+SKU // (Cpp2IL_CS/.../RecNet/Commerce.cs:80 // private int <SkuId>k__BackingField). // Util.GetKey<int>("SkuId") calls Convert.ChangeType, // which throws FormatException if the value isn't // parseable as an int. The previous shape shipped // i.Slug (a "wardrobe-<guid>" string) under // SkuId — that's the FormatException at // output_log.txt:1655 which corrupts the catalog // load and downstream destabilises the watch's player // object (manifests as the dorm-load destroy crash). // Ship the int row id under SkuId; keep the slug // under Slug/Sku aliases for any string // consumer. ["SkuId"] = i.Id, ["skuId"] = i.Id, ["Sku"] = i.Slug, ["sku"] = i.Slug, ["ItemId"] = i.Id, ["itemId"] = i.Id, ["Id"] = i.Id, ["id"] = i.Id, ["Slug"] = i.Slug, ["slug"] = i.Slug, // Avatar-item join keys (see resolve block above). Emitted both at // top level and inside Data so whichever the client reads, the // SKU→AvatarItem resolve succeeds and the item lands in a tab. ["AvatarItemId"] = avatarItemId, ["avatarItemId"] = avatarItemId, ["AvatarItemType"] = avatarItemType, ["avatarItemType"] = avatarItemType, ["Name"] = i.DisplayName, ["name"] = i.DisplayName, ["DisplayName"] = i.DisplayName, ["displayName"] = i.DisplayName, ["Title"] = i.DisplayName, ["title"] = i.DisplayName, ["Description"] = i.Description, ["description"] = i.Description, ["Category"] = i.Category, ["category"] = i.Category, ["ItemType"] = i.Category, ["itemType"] = i.Category, // Cost / price — flat + nested forms. ["Price"] = i.Price, ["price"] = i.Price, ["Cost"] = new Dictionary { ["Amount"] = i.Price, ["CurrencyType"] = i.CurrencyType }, ["cost"] = new Dictionary { ["amount"] = i.Price, ["currencyType"] = i.CurrencyType }, ["CurrencyType"] = i.CurrencyType, ["currencyType"] = i.CurrencyType, ["Amount"] = i.Price, ["amount"] = i.Price, ["ImageName"] = i.ImageName, ["imageName"] = i.ImageName, ["Image"] = i.ImageName, ["image"] = i.ImageName, ["ImageUrl"] = imageUrl, ["imageUrl"] = imageUrl, ["Available"] = available, ["available"] = available, ["IsAvailable"] = available, ["isAvailable"] = available, ["IsActive"] = i.IsActive, ["isActive"] = i.IsActive, ["IsLimitedTime"] = i.IsLimitedTime, ["isLimitedTime"] = i.IsLimitedTime, ["AvailableUntil"] = i.AvailableUntil, ["availableUntil"] = i.AvailableUntil, ["Storefront"] = i.Storefront, ["storefront"] = i.Storefront, ["CreatedAt"] = i.CreatedAt, ["createdAt"] = i.CreatedAt, // Required RecNet.Commerce+SKU fields the watch's strict // Util.GetKey deserialiser throws KeyNotFoundException on // when missing. Verified at // Cpp2IL_CS/.../RecNet/Commerce.cs:80-102 — SKU has // OculusSkuId / AppleProductId / PSNProductLabel / // IsSingleUse / Data / DisplayPrice / LongDescription as // public-private (read+private-set) properties; missing // any of them at deserialize time causes the SKU import // to abort halfway and leaves the catalog in a half-built // state that the StoreScreen UI binding then native-crashes // on (output_log.txt:853 — last log line is a // RRUI.Data.TypedDataSource resolving List.Count // followed by silent process exit). ["OculusSkuId"] = string.Empty, ["oculusSkuId"] = string.Empty, ["AppleProductId"] = string.Empty, ["appleProductId"] = string.Empty, ["PSNProductLabel"] = string.Empty, ["psnProductLabel"] = string.Empty, // XboxProductId — present in GCBPKHFJKCE+DJGBECJHOKF's // required-key list at Cpp2IL_ISIL/.../GCBPKHFJKCE_NestedType_DJGBECJHOKF.txt:419. // We don't ship Xbox, so empty string is correct; missing // it entirely throws KeyNotFoundException on the watch // and breaks the whole Shop ("malformed RecNet response" // → store times out waiting for a usable catalog). ["XboxProductId"] = string.Empty, ["xboxProductId"] = string.Empty, ["IsSingleUse"] = false, ["isSingleUse"] = false, ["DisplayPrice"] = i.Price.ToString(), ["displayPrice"] = i.Price.ToString(), ["LongDescription"] = i.Description ?? string.Empty, ["longDescription"] = i.Description ?? string.Empty, // SKUData is itself an IRecNetObject with required keys — // GiftDropIds (List), Message (string), // SubscriptionPurchase (object). Empty list / empty // string / null sub-purchase are valid. ["Data"] = new Dictionary { ["AvatarItemId"] = avatarItemId, ["AvatarItemType"] = avatarItemType, // Link this SKU to its gift-drop store card (the EFFIEFEFHHB // record `storefronts/v3/giftdropstore` builds, GiftDropId = // this same id) so the client can resolve the card that carries // the AvatarItemId join key. Empty here = SKU points at no card. ["GiftDropIds"] = new[] { (int)(i.Id & 0x7fffffff) }, ["Message"] = string.Empty, ["SubscriptionPurchase"] = (object?)null, }, ["data"] = new Dictionary { ["avatarItemId"] = avatarItemId, ["avatarItemType"] = avatarItemType, ["giftDropIds"] = new[] { (int)(i.Id & 0x7fffffff) }, ["message"] = string.Empty, ["subscriptionPurchase"] = (object?)null, }, }; } }