mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
d471b418b9
Targets Rec Room 2020.03.10 client (also serves 2020.03.06).
Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces dorknet-clientpatch-{key}.zip
for v1-march-* Release uploads.
- .github/workflows/release.yml — on tag push publishes both
dorknet-server-march_2020_03_10-win-x64.zip and
dorknet-clientpatch-march_2020_03_10.zip as Release assets.
Same scrub rules as december; tools/build-patcher-zip.ps1 added to
the maintainer-side allow-list so it survives.
213 lines
9.5 KiB
C#
213 lines
9.5 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using DorkNet.Server.Services;
|
|
|
|
namespace DorkNet.Server.Controllers.Commerce;
|
|
|
|
/// <summary>
|
|
/// 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 <c>[]</c>; this
|
|
/// controller wins on specific routes via ASP.NET Core's
|
|
/// specific-over-wildcard precedence and returns the real
|
|
/// <see cref="StoreService"/> 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.
|
|
/// </summary>
|
|
[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<IActionResult> 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<IActionResult> Sku(string slug)
|
|
{
|
|
var item = await store.GetBySlugAsync(slug);
|
|
if (item is null) return NotFound();
|
|
return Ok(ToCatalogDto(item, domain.Apex));
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
[HttpPost("/api/catalog/v1/byids")]
|
|
[HttpPost("/api/catalog/v2/byids")]
|
|
public async Task<IActionResult> 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<object>());
|
|
|
|
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<object>());
|
|
|
|
var all = await store.GetAllActiveAsync();
|
|
return Ok(all.Where(i => ids.Contains(i.Id)).Select(i => ToCatalogDto(i, domain.Apex)).ToArray());
|
|
}
|
|
|
|
/// <summary>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 (<c>SkuId</c> vs
|
|
/// <c>skuId</c>, etc.) — same trick we use in
|
|
/// RelationshipsController.BuildRelationshipResponse.</summary>
|
|
private static Dictionary<string, object?> 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}";
|
|
return new Dictionary<string, object?>
|
|
{
|
|
// Identity / naming — every alias the watch's strict
|
|
// deserialiser might key on.
|
|
//
|
|
// CRITICAL: <c>SkuId</c> is typed <c>int</c> on
|
|
// <c>RecNet.Commerce+SKU</c>
|
|
// (Cpp2IL_CS/.../RecNet/Commerce.cs:80
|
|
// <c>private int <SkuId>k__BackingField</c>).
|
|
// Util.GetKey<int>("SkuId") calls Convert.ChangeType,
|
|
// which throws FormatException if the value isn't
|
|
// parseable as an int. The previous shape shipped
|
|
// <c>i.Slug</c> (a "wardrobe-<guid>" string) under
|
|
// <c>SkuId</c> — that's the FormatException at
|
|
// <c>output_log.txt:1655</c> 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 <c>SkuId</c>; keep the slug
|
|
// under <c>Slug</c>/<c>Sku</c> 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,
|
|
|
|
["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<string, object?> { ["Amount"] = i.Price, ["CurrencyType"] = i.CurrencyType },
|
|
["cost"] = new Dictionary<string, object?> { ["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<string>.Count
|
|
// followed by silent process exit).
|
|
["OculusSkuId"] = string.Empty,
|
|
["oculusSkuId"] = string.Empty,
|
|
["AppleProductId"] = string.Empty,
|
|
["appleProductId"] = string.Empty,
|
|
["PSNProductLabel"] = string.Empty,
|
|
["psnProductLabel"] = 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<int>), Message (string),
|
|
// SubscriptionPurchase (object). Empty list / empty
|
|
// string / null sub-purchase are valid.
|
|
["Data"] = new Dictionary<string, object?>
|
|
{
|
|
["GiftDropIds"] = Array.Empty<int>(),
|
|
["Message"] = string.Empty,
|
|
["SubscriptionPurchase"] = (object?)null,
|
|
},
|
|
["data"] = new Dictionary<string, object?>
|
|
{
|
|
["giftDropIds"] = Array.Empty<int>(),
|
|
["message"] = string.Empty,
|
|
["subscriptionPurchase"] = (object?)null,
|
|
},
|
|
};
|
|
}
|
|
}
|