Files
DorkNet/docs/recroom-2023-room-consumables.md

222 lines
9.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (**Int32** — putting the descriptor
string here makes the strict reader throw
`expected:'Number Token'` → "Malformed Response" for the whole
storefront payload; the descriptor string belongs in
AvatarItemDesc), CurrencyType, Currency (enum EAFDEJBEFJB,
RecCenterTokens=2), Context (GiftContext enum IFKEEPDDNBC),
ItemSetId (int?), ItemSetFriendlyName`. AvatarItemType is the
nullable enum ONHCNMLCFMN (Outfit=0, HairDye=1, CustomOutfit=2).
2. `GET api/storefronts/v1/toptoday` → a **bare `List<int>` 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`.
## Shop Consumables tab (food / potions / KO / camera film)
The Shop's Consumables tab is fed by the same `giftdropstore` rows as
Clothing; a row is a consumable when its `GiftDrop.ConsumableItemDesc`
is set (bracketed literal item key, e.g. `[FoodConsumable_RootBeer]`)
and it carries no avatar item (`AvatarItemDesc` empty, `AvatarItemType`
null). Hair dyes are a hybrid — they set BOTH `ConsumableItemDesc` and
an `AvatarItemDesc` colour guid.
The catalog of literal item keys lives in the client's baked
`ConsumableCollectionRuntimeConfig` MonoBehaviour (in `resources.assets`,
path_id 234695 for 2023.03.21). Each record is
`[ desc "[Family_Name]" ][ guid 22ch ][ display name ][ int family ]
[ tool desc ]`; families: CameraFilter, Food, KO, HandPotion,
HeadPotion, VFX, HairDye, Coupon. Extracted (minus hair dyes, coupons,
and promo-only bubbly variants) to `DorkNet.Server/Data/consumables.json`
and seeded as `Category="consumable"` store rows;
`StoreService.TryGetConsumableItemDesc` resolves a seeded slug to its
literal desc so it flows through the gift-drop builder like a hair dye.
The exact per-item render gate (`INJHHPOOMKB.EEJKFKPAPAH`) dispatches
through a runtime service and could not be resolved statically — confirm
the tab actually populates in-game after seeding.
## 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`.