mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
222 lines
9.3 KiB
Markdown
222 lines
9.3 KiB
Markdown
# 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`.
|