From 4ca0e42cef56f81a26ca9f45126f906bcd767d22 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 26 Aug 2026 01:22:55 -0400 Subject: [PATCH] [econ] fix rrplus discount --- apps/econ/src/econ.app.ts | 88 ++++++++++++++++++---- apps/econ/src/test/integration/api.test.ts | 61 +++++++++++++++ 2 files changed, 135 insertions(+), 14 deletions(-) diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index c84b7c2..17539a0 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -378,6 +378,19 @@ const NOT_AN_INFLUENCER = 0 /** The operator-granted role that comes with a complimentary subscription. */ const DEVELOPER_ROLE = 'developer' +/** + * Whether the caller currently holds a Rec Room Plus subscription — the ONE definition, + * shared by `UpdateAndGetSubscription` (which reports it) and the storefront buys (which + * price off it via `SubscriberPrices`). Nothing sells subscriptions here, so holding the + * `developer` role IS the subscription; if a real subscription store ever lands, this is + * the only place that has to learn about it. Read from the token's `role` claim, never the + * body; no or an invalid token is "not subscribed". + */ +async function isSubscriber(c: Context): Promise { + const roles = await authedRoles(c) + return roles?.includes(DEVELOPER_ROLE) ?? false +} + /** `SubscriptionLevel.Gold`. 1 is Platinum. */ const SUBSCRIPTION_LEVEL_GOLD = 0 @@ -446,9 +459,10 @@ function toAvatarV2Dto(avatar: Avatar) { /** * The subset of a storefront catalog (`static/storefronts/sf{N}.json`) that `buyItem` - * reads: each store item carries the `GiftDrop` describing what you get and a list of - * `Prices` per currency. The catalogs hold more fields (SubscriberPrices, IsFeatured, - * …) that the purchase path doesn't need. + * reads: each store item carries the `GiftDrop` describing what you get, a list of + * `Prices` per currency, and optionally `SubscriberPrices` — the discounted list a Rec Room + * Plus subscriber is shown and pays. The catalogs hold more fields (IsFeatured, …) that + * the purchase path doesn't need. */ interface StoreGiftDrop { FriendlyName: string @@ -490,8 +504,49 @@ interface StorePrice { interface StoreItem { GiftDrop: StoreGiftDrop Prices: StorePrice[] + /** + * The subscriber price list, where the catalog has one (sf300's item 2263 lists 95 tokens + * in `Prices` and 85 in here). The client renders — and posts as `RequestedPrice` — the + * subscriber price to a subscriber, so the price check has to read the same list or a + * subscriber's every buy 409s as "Price has changed". + */ + SubscriberPrices?: StorePrice[] | null PurchasableItemId: number } + +/** + * The Rec Room Plus discount, in percent off the regular price, floored to whole tokens. + * + * The client applies this ITSELF: a subscriber's client posts `floor(Price * 0.9)` as + * `RequestedPrice` (95 → 85, 75 → 67, 30 → 27) whatever the catalog says — the captured + * catalogs carry a `SubscriberPrices` list, but for 1238 of 1382 items it is a verbatim copy + * of `Prices` (they were captured through a non-subscriber's view), and every entry that does + * differ is exactly this formula. So the catalog list can't be the source of truth for the + * check; the formula is, and the list is only honoured where it agrees with it (i.e. is lower). + */ +const SUBSCRIBER_DISCOUNT_PERCENT = 10 + +/** + * The price of an item in one currency for one buyer. A non-subscriber pays the `Prices` + * entry. A subscriber pays the `SubscriberPrices` entry when the catalog has a genuinely + * discounted one for that currency, else {@link SUBSCRIBER_DISCOUNT_PERCENT} off the regular + * price — what their client rendered and posted. `undefined` when the item isn't sold in + * that currency at all. + */ +function priceFor( + item: StoreItem, + currencyType: number, + subscriber: boolean +): StorePrice | undefined { + const regular = item.Prices.find((p) => p.CurrencyType === currencyType) + if (regular === undefined || !subscriber) return regular + const listed = item.SubscriberPrices?.find((p) => p.CurrencyType === currencyType) + if (listed !== undefined && listed.Price < regular.Price) return listed + return { + CurrencyType: currencyType, + Price: Math.floor((regular.Price * (100 - SUBSCRIBER_DISCOUNT_PERCENT)) / 100), + } +} interface Storefront { StoreItems: StoreItem[] } @@ -1127,15 +1182,17 @@ function toPurchaseMethodId(raw: Partial | null | undefined): * Returns the failure — with the `UpdateResponse` its entry will carry — instead when the * line can't be bought. * - * Pure — the catalog is passed in — so the whole bag resolves from ONE storefront read. - * The price check is buyItem's, per line: `RequestedPrice` is the UNIT price the client - * rendered, and a mismatch means the catalog moved under a stale client rather than that - * the player agreed to today's price. + * Pure — the catalog and the buyer's subscriber status are passed in — so the whole bag + * resolves from ONE storefront read and ONE token read. The price check is buyItem's, per + * line: `RequestedPrice` is the UNIT price the client rendered (the subscriber price, for a + * subscriber — see {@link priceFor}), and a mismatch means the catalog moved under a stale + * client rather than that the player agreed to today's price. */ function resolveBulkLine( line: PurchaseItemRequest, storefront: Storefront | null, - currencyType: number + currencyType: number, + subscriber: boolean ): BulkPurchaseLine | BulkLineFailure { const method = toPurchaseMethodId(line.ItemPurchaseMethodId) // Guid-keyed ids name UGC / custom avatar items, which no catalog here sells. Failing the @@ -1184,7 +1241,7 @@ function resolveBulkLine( error: 'This item can only be bought once per line', } } - const price = item.Prices.find((p) => p.CurrencyType === currencyType) + const price = priceFor(item, currencyType, subscriber) if (price === undefined) { return { method, @@ -2428,7 +2485,8 @@ const app = new Hono({ strict: false }) summary: 'Buy a storefront item', description: [ 'Looks the item up in its storefront catalog, confirms the client’s `RequestedPrice`', - 'still matches, debits the buyer atomically, grants the item (into the inventory or', + 'still matches (the `SubscriberPrices` entry for a Rec Room Plus subscriber — the same', + 'check as `UpdateAndGetSubscription` — else the `Prices` one), debits the buyer atomically, grants the item (into the inventory or', 'consumable table), and returns a gift box. A `Gift` block routes the item to another', 'player, but the caller always pays. `Balance` in the response is the CHANGE (negated', 'price), not the new total. Pushes a StorefrontBalancePurchase socket frame that SETS the', @@ -2475,7 +2533,9 @@ const app = new Hono({ strict: false }) const item = await findStoreItem(c, storefrontType as number, purchasableItemId as number) if (item === null) return c.json({ error: 'Item not found' }, 404) - const price = item.Prices.find((p) => p.CurrencyType === currencyType) + // A subscriber is shown, and posts, the item's `SubscriberPrices` entry; checking the + // regular price against it 409'd every subscriber buy of a discounted item. + const price = priceFor(item, currencyType as number, await isSubscriber(c)) if (price === undefined) { return c.json({ error: 'Currency type not available for this item' }, 400) } @@ -2628,8 +2688,9 @@ const app = new Hono({ strict: false }) // One catalog read for the bag; every line resolves against it in memory. const storefront = await loadStorefront(c, storefrontType as number) + const subscriber = await isSubscriber(c) const resolved = lines.map((line) => - resolveBulkLine(line, storefront, currencyType as number) + resolveBulkLine(line, storefront, currencyType as number, subscriber) ) const buyable = resolved.filter(isBulkLine) @@ -3206,8 +3267,7 @@ const app = new Hono({ strict: false }) }, }), async (c) => { - const roles = await authedRoles(c) - if (!roles?.includes(DEVELOPER_ROLE)) return c.json({}) + if (!(await isSubscriber(c))) return c.json({}) const id = await authedId(c) if (id === null) return c.json({}) return c.json({ diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index ee2907b..e23e399 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -1265,6 +1265,67 @@ describe('econ endpoints', () => { expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 }]) }) + // sf300's item 2263 is 95 tokens in `Prices` and 85 in `SubscriberPrices`. A subscriber's + // client renders and posts the 85, so the check has to read the list the buyer sees. + const buy2263 = async (headers: Record, RequestedPrice: number) => + exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + StorefrontType: 300, + PurchasableItemId: 2263, + CurrencyType: 2, + RequestedPrice, + CouponConsumablePlayerMappingId: null, + Gift: null, + }), + }) + + test('POST /api/storefronts/v2/buyItem charges a subscriber the SubscriberPrices entry', async () => { + await drainFrames() + const res = await buy2263(await bearer('322', ['gameClient', 'developer']), 85) + expect(res.status).toBe(200) + expect(((await res.json()) as { Balance: number }).Balance).toBe(-85) + const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, { + headers: await bearer('322'), + }) + expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 - 85 }]) + }) + + test('POST /api/storefronts/v2/buyItem derives the subscriber price when the catalog lists none', async () => { + // sf3's item 2184 is 95 in BOTH lists (captured through a non-subscriber's view), but a + // subscriber's client still posts floor(95 * 0.9) = 85. + const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { + method: 'POST', + headers: { + ...(await bearer('325', ['gameClient', 'developer'])), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + StorefrontType: 3, + PurchasableItemId: 2184, + CurrencyType: 2, + RequestedPrice: 85, + CouponConsumablePlayerMappingId: null, + Gift: null, + }), + }) + expect(res.status).toBe(200) + expect(((await res.json()) as { Balance: number }).Balance).toBe(-85) + }) + + test('POST /api/storefronts/v2/buyItem 409s a subscriber posting the regular price', async () => { + const res = await buy2263(await bearer('323', ['gameClient', 'developer']), 95) + expect(res.status).toBe(409) + }) + + test('POST /api/storefronts/v2/buyItem 409s a non-subscriber posting the subscriber price', async () => { + const res = await buy2263(await bearer('324'), 85) + expect(res.status).toBe(409) + const ok = await buy2263(await bearer('324'), 95) + expect(ok.status).toBe(200) + }) + test('POST /api/storefronts/v2/buyItem 404s for an unknown item', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { method: 'POST',