From 7b7eb651e3fd3e1858598d7d30fbd66da12aae77 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:27:13 +0300 Subject: [PATCH] feat: Add tests for 2023 watch shop tab and room consumables; implement new storefront endpoint and update documentation --- .../RoomConsumablesShopTests.cs | 19 ++ DorkNet.Server.Tests/WatchShop2023Tests.cs | 76 +++++++ .../API/Store/StorefrontsController.cs | 19 +- DorkNet.Server/Services/StoreService.cs | 20 +- docs/recroom-2023-room-consumables.md | 192 ++++++++++++++++++ 5 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 DorkNet.Server.Tests/WatchShop2023Tests.cs create mode 100644 docs/recroom-2023-room-consumables.md diff --git a/DorkNet.Server.Tests/RoomConsumablesShopTests.cs b/DorkNet.Server.Tests/RoomConsumablesShopTests.cs index 73870b5..534f73e 100644 --- a/DorkNet.Server.Tests/RoomConsumablesShopTests.cs +++ b/DorkNet.Server.Tests/RoomConsumablesShopTests.cs @@ -176,6 +176,25 @@ public sealed class RoomConsumablesShopTests : IClassFixture +/// The 2023 client's watch Shop tab pipeline: rows come from +/// /api/storefronts/v3/giftdropstore/{id} (each row MUST carry a +/// singular GiftDrop object — the 2023 POGBAGAHGIA formatter +/// ignores the 2020 GiftDrops array, and a null inner desc makes +/// StoreItemListModel's filter predicate NRE so the Shop shows nothing), +/// and the featured strip ids come from /v1/toptoday as a BARE +/// int list that must resolve against those rows. +/// +public sealed class WatchShop2023Tests : IClassFixture +{ + private readonly DorkNetServerFactory _factory; + + public WatchShop2023Tests(DorkNetServerFactory factory) => _factory = factory; + + [Fact] + public async Task Giftdropstore_rows_carry_singular_giftdrop_and_toptoday_ids_resolve() + { + using var client = ApiClient(); + + var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3"); + var rows = storefront.GetProperty("StoreItems"); + Assert.True(rows.GetArrayLength() > 0, "watch storefront seeded no rows"); + + var rowIds = new HashSet(); + foreach (var row in rows.EnumerateArray()) + { + rowIds.Add(row.GetProperty("PurchasableItemId").GetInt32()); + Assert.Equal(0, row.GetProperty("Type").GetInt32()); + Assert.True(row.GetProperty("Prices").GetArrayLength() > 0); + + var giftDrop = row.GetProperty("GiftDrop"); + Assert.Equal(JsonValueKind.Object, giftDrop.ValueKind); + Assert.Equal(row.GetProperty("PurchasableItemId").GetInt32(), + giftDrop.GetProperty("GiftDropId").GetInt32()); + Assert.Equal(JsonValueKind.String, giftDrop.GetProperty("FriendlyName").ValueKind); + Assert.Equal(JsonValueKind.String, giftDrop.GetProperty("TagList").ValueKind); + var rarity = giftDrop.GetProperty("Rarity").GetInt32(); + Assert.Contains(rarity, new[] { 0, 10, 20, 30, 50 }); + } + + // Featured strip: bare int ids, every one resolvable in the rows. + var topToday = await GetJsonAsync(client, "/api/storefronts/v1/toptoday"); + Assert.Equal(JsonValueKind.Array, topToday.ValueKind); + Assert.True(topToday.GetArrayLength() > 0, "toptoday returned no featured ids"); + foreach (var id in topToday.EnumerateArray()) + { + Assert.Equal(JsonValueKind.Number, id.ValueKind); + Assert.Contains(id.GetInt32(), rowIds); + } + } + + private HttpClient ApiClient() + { + var client = _factory.CreateClient(new() { AllowAutoRedirect = false }); + client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}"); + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21"); + client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21"); + return client; + } + + private static async Task GetJsonAsync(HttpClient client, string path) + { + using var response = await client.GetAsync(path); + response.EnsureSuccessStatusCode(); + using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + return document.RootElement.Clone(); + } +} diff --git a/DorkNet.Server/Controllers/API/Store/StorefrontsController.cs b/DorkNet.Server/Controllers/API/Store/StorefrontsController.cs index 2a54ae6..39d89b5 100644 --- a/DorkNet.Server/Controllers/API/Store/StorefrontsController.cs +++ b/DorkNet.Server/Controllers/API/Store/StorefrontsController.cs @@ -32,7 +32,6 @@ public class StorefrontsController( [HttpGet("api/storefronts/v1/all")] [HttpGet("api/storefronts/v2/all")] [HttpGet("api/storefronts/v3/all")] - [HttpGet("api/storefronts/v1/toptoday")] [AllowAnonymous] public async Task All() { @@ -40,6 +39,24 @@ public class StorefrontsController( return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray()); } + /// GET api/storefronts/v1/toptoday — the 2023 Shop + /// tab's "featured today" strip. The client (DCFKEFHJAGC.IDCIMNLBINC) + /// deserialises the body as a bare List<int> of + /// PurchasableItemIds and resolves each against the gift-drop cache + /// it loaded from /v3/giftdropstore/{id} — so these ids MUST + /// be ids that appear on that storefront's rows, or the client logs + /// "Could not find purchasable giftdrop" and drops them. Returning + /// item OBJECTS here (the old /all alias) made the whole featured + /// fetch fail to parse. + [HttpGet("api/storefronts/v1/toptoday")] + [AllowAnonymous] + public async Task TopToday() + { + var items = await store.GetRenderableGiftDropItemsAsync(); + var max = Math.Max(1, config.GetValue("Store:MaxTopTodayItems", 12)); + return Ok(items.Take(max).Select(StoreService.PurchasableItemIdFor).ToArray()); + } + /// GET api/storefronts/v1/adcarouselitems — the rotating /// promo banner at the top of the Shop, NOT the main catalog. The client /// (AdCarouselItemListModel) keys these by AdCarouselItemId in a diff --git a/DorkNet.Server/Services/StoreService.cs b/DorkNet.Server/Services/StoreService.cs index 4db2ee0..1321617 100644 --- a/DorkNet.Server/Services/StoreService.cs +++ b/DorkNet.Server/Services/StoreService.cs @@ -973,6 +973,13 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio // REQUIRED. GiftDropId = itemId, Rarity = MapRarity(i.Category), + // 2023 additions (EMBCEDNHFLB formatter): TagList is a CSV + // string, Currency is the CurrencyType enum repeated, ItemSet* + // group multi-part outfits (we don't sell sets). + TagList = "", + Currency = i.CurrencyType, + ItemSetId = (int?)null, + ItemSetFriendlyName = "", // Optional / nullable string fields the watch reads. FriendlyName = i.DisplayName, Tooltip = i.Description, @@ -1008,11 +1015,22 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio IsFeatured = positionalIndex < 12, // first dozen show in Featured tab Prices = new[] { price }, SubscriberPrices = Array.Empty(), - // PurchasableGiftDrop adds GiftDrops on top. + // 2020 PurchasableGiftDrop carries a GiftDrops LIST; the 2023 + // row (POGBAGAHGIA formatter) reads a single "GiftDrop" object + // instead. Ship both: whichever formatter parses the row skips + // the key it doesn't know. Missing "GiftDrop" leaves the 2023 + // row's inner desc null and StoreItemListModel's filter + // predicate NREs on every item — the Shop tab shows nothing. + GiftDrop = giftDrop, GiftDrops = new[] { giftDrop }, }; } + /// The wire PurchasableItemId for a store row — must match + /// what puts on the row so + /// id-lists (toptoday) resolve against the gift-drop cache. + public static int PurchasableItemIdFor(StoreItemEntity i) => (int)(i.Id & 0x7fffffff); + /// GiftManager.GiftRarity wire values: /// None=-1, Common=0, Uncommon=10, Rare=20, Epic=30, Legendary=50. private static int MapRarity(string? category) => category?.ToLowerInvariant() switch diff --git a/docs/recroom-2023-room-consumables.md b/docs/recroom-2023-room-consumables.md new file mode 100644 index 0000000..a07e79d --- /dev/null +++ b/docs/recroom-2023-room-consumables.md @@ -0,0 +1,192 @@ +# Rec Room 2023.03.21 — shop wire contracts (watch Shop tab + room consumables) + +How the March-2023 client's in-room shop works +(`RecRoom.Systems.RoomConsumablesManager`) and the exact wire shapes +DorkNet serves for it. Everything here was verified against the +2023.03.21 ISIL dump (`RecNet.Runtime` Utf8Json formatters) and the +Cpp2IL dummy DLLs — key names, field types, and enum values are read +from the binary, not guessed. + +Server implementation: `DorkNet.Server/Controllers/API/RoomConsumables/RoomConsumablesController.cs` +backed by the `RoomConsumables` + `RoomConsumableOwnership` tables. +Integration coverage: `DorkNet.Server.Tests/RoomConsumablesShopTests.cs`. + +## Why this exists (the crash) + +On every room join the client fetches +`GET api/roomconsumables/v1/roomConsumable/room/{roomId}/me` and feeds +the rows to `RoomConsumablesManager`. The row-processing method +(`ODAIDLGODIK.FBDIGFOANBG`) dereferences the row's `Consumable` desc +**unconditionally**. If the server's JSON keys don't match, Utf8Json +leaves the field null and the client throws +`NullReferenceException` → unobserved task exception → "Uploading Crash +Report" in `Player.log`, and the shop is dead for the session. The old +DorkNet endpoint sent `Id`/`ConsumableItemDesc`/`CreatedAt` (keys +borrowed from the *gift* DTO, which really does use +`ConsumableItemDesc`) — the 2023 room-consumable DTO does not. + +## Endpoints (client literals) + +Base `{0}` = `api/roomconsumables`. Route literals are case-mixed on +the wire (`roomConsumable` vs `roomconsumable`); ASP.NET matches both. + +| Verb | Path | Purpose | +|---|---|---| +| GET | `{0}/v1/roomConsumable/room/{roomId}` | room catalog (shop shelf) | +| GET | `{0}/v1/roomConsumable/room/{roomId}/me` | caller's inventory, fetched on room join | +| GET | `{0}/v1/roomConsumable/{id}` | single desc | +| GET | `{0}/v1/roomConsumable/{id}/isOwned` | "do non-creators hold stock" (bare bool) | +| POST | `{0}/v1/roomConsumable` | create (no id in body) / update (id in body); body = desc DTO | +| DELETE | `{0}/v1/roomConsumable/{id}` | soft delete; response = bare status int | +| POST | `{0}/v1/roomconsumable/{id}/purchase/tokens` | buy one with tokens | +| POST | `{0}/v1/roomconsumable/{id}/purchase/currency` | buy one with a room currency | +| POST | `{0}/v1/roomConsumable/{id}/consume` | spend one unit | + +## DTOs + +Consumable desc (`MOONCMIECPL`, formatter `HJPEABLEEKL`): + +```json +{ + "RoomConsumableId": "guid", + "RoomId": 116, + "Name": "Cola", + "Description": "…", + "ImageName": "…", + "PriceAndCurrency": { "Price": 10, "CurrencyId": null } +} +``` + +`CurrencyId` null = token-priced; otherwise a room-currency PublicId. +The desc also has a client-side `CreatedAt` field but it is **not** a +wire key. + +Inventory row (`HOMJKAOHGDG`, formatter `CICEHLNDLDE`): + +```json +{ + "RoomConsumableId": "guid", + "AccountId": 4, + "Count": 1, + "ConcurrencyCode": "guid", + "ModifiedAt": "2026-07-07T12:00:00Z", + "Consumable": { …desc… } +} +``` + +`AccountId` is int32 on the wire. `Consumable` must never be null (see +crash above). + +## Concurrency model + +Each (player, consumable) stack carries an opaque `ConcurrencyCode` +Guid. Purchase and consume requests send +`{ CurrentConcurrencyCode, NewConcurrencyCode }` — the **client +generates the new code** and adopts it locally on success (the purchase +response carries no inventory row), so the server must store +`NewConcurrencyCode` verbatim or the next consume fails with +`ConcurrencyCodeMismatch` (32). On a mismatch the server returns the +current row so the client can resync. + +Purchase body (`ILNJDMFNOCD`, formatter `JEBCHNDADKK`): + +```json +{ + "ConcurrencyCodes": { "CurrentConcurrencyCode": null, "NewConcurrencyCode": "guid" }, + "ExpectedPriceAndCurrency": { "Price": 10, "CurrencyId": null } +} +``` + +Consume body is the bare code pair (`CFOBCEPBBHA`, formatter +`JKJHOAKNOCF`): `{ "CurrentConcurrencyCode": "guid", "NewConcurrencyCode": "guid" }`. + +## Responses + +Create/update (`GFKDAADENEE`): `{ "Status": 0, "Consumable": {…desc…} }`. +Delete: bare status int (`0`). +Consume (`BGHBAILNNJJ`, formatter `LPFAFIGAIOE`): +`{ "Status": 0, "InventoryItem": {…inventory row…} }`. + +Token purchase (`MJKFEHPIDME`, formatter `HJOKFJKKEJM`): + +```json +{ + "OperationResult": 0, + "BalanceUpdateResult": 0, + "TokenBalanceResponse": { "CurrencyType": 2, "Balance": 4990, "Platform": 0 } +} +``` + +Room-currency purchase (`DEILBLCDNEA`, formatter `IDBFKCFEAHM`) swaps +the last key for +`"CurrencyBalanceResponse": { "AccountId", "CurrencyId", "Balance", "ModifiedAt" }`. + +## Enums + +`RoomConsumableStatus` (`DLDNGEDFMOJ`) — used by create/update/delete/consume: +`0 Success, 4 RoomConsumableNotFound, 7 PlayerDoesntHavePermission, +13 MaxConsumablesInRoom, 15 RoomIdMissing, 17 PriceOrCurrencyMissing, +18 CurrencyNotFound, 21/22 PriceTooLow/HighTokens, 23/24 NameTooShort/Long, +27 DescriptionTooLong, 32 ConcurrencyCodeMismatch, +33 PlayerDoesNotOwnConsumable, 36 RoomNotFound` (full 0–42 list in the +dummy DLL). + +Token-purchase `OperationResult` (`CABBDKFODEC`): +`0 OK, 1 TooManyRequests, 2 NotEnoughCredit, 3 AlreadyOwned, +4 NoItemAvailable, 5 CouponNotApplicable, 6 RequestedPriceDoesNotMatch, +7 RequestedAmountNotAllowed, 8 PlayerNotEligible, +9 RequestCannotBeRefunded, 10 PlayerNotApproved`. + +Currency-purchase result (`GACBLALELBP`): `0 Success, 1 NotEnoughCredit`. + +## Watch Shop tab (storefronts) + +The 2023 Shop tab is fed by two calls, both with shapes that changed +since 2020: + +1. `GET api/storefronts/v3/giftdropstore/{id}` → `HOEGLKNEIOF` + (formatter GNMDEJJAHCJ): `{ StorefrontType, StoreItems, NewUntil, + NextUpdate, SubscriberDiscountPercent }`. Each StoreItems row + (JCCCKDCHMLG, formatter POGBAGAHGIA) is + `{ PurchasableItemId, Type (0=GiftDrop enum MDCCOLGHBMN), Prices: + [{CurrencyType, Price, StorefrontSaleData}], SubscriberPrices, + IsFeatured, AvailableAt, AvailableUntil, NewUntil, GiftDrop: {…} }`. + **`GiftDrop` is SINGULAR** — the 2020 `GiftDrops` array key is + ignored by the 2023 formatter, and a missing/null `GiftDrop` makes + `RRUI.Data.StoreItemListModel`'s per-item filter predicate NRE on + every row (bools/Rarity/type reads on the null desc), so the Shop + tab renders empty. DorkNet ships both keys. + + The inner `GiftDrop` (EFFIEFEFHHB, formatter EMBCEDNHFLB) keys: + `GiftDropId (int), FriendlyName, Tooltip, TagList (CSV string), + ConsumableItemDesc (string), AvatarItemDesc (string descriptor), + AvatarItemType (int?), EquipmentPrefabName, EquipmentModificationGuid, + IsQuery, Unique, SubscribersOnly, Rarity (0/10/20/30/50 enum + BGDEDNBFOCH), AvatarItemId, CurrencyType, Currency (enum EAFDEJBEFJB, + RecCenterTokens=2), Context (GiftContext enum IFKEEPDDNBC), + ItemSetId (int?), ItemSetFriendlyName`. + +2. `GET api/storefronts/v1/toptoday` → a **bare `List` of + PurchasableItemIds** (client DCFKEFHJAGC.IDCIMNLBINC), resolved + against the gift-drop cache with "Could not find purchasable + giftdrop with purchasableItemId {0}" warnings for unknown ids. + Serving item objects here (the old `/all` alias) breaks the featured + fetch entirely. + +Coverage: `DorkNet.Server.Tests/WatchShop2023Tests.cs`. + +## Related: unprefixed 2023 commerce probes + +The 2023 client also calls these commerce paths **without** the `api/` +prefix (same host as `api/catalog/...`); missing routes surface as +"CleanupPendingTransactions failed" + unobserved `HTTP Error 404` +crash reports at startup: + +- `POST purchase/v1/cleanuppending` — ack (no pending IAP on DorkNet) +- `GET/POST purchase/v1/hasspentmoney` — bare `false` +- `GET reminder/currentTokenBundles/v2` — `[]` (no real-money offers) +- `POST purchase/v1/{initiatepurchase,processpurchase,completepurchase,cancelpurchase}` + — aliased onto the existing `api/purchase/v1` handlers + +All of these plus `/purchasecampaign` are owned by the Commerce service +in `DorkNetRouteOwnership`.