[econ] fix rrplus discount

This commit is contained in:
Devin Zuczek
2026-08-26 01:22:55 -04:00
parent a12ff24068
commit 4ca0e42cef
2 changed files with 135 additions and 14 deletions
+74 -14
View File
@@ -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<App>): Promise<boolean> {
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<PurchaseMethodId> | 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<App>({ strict: false })
summary: 'Buy a storefront item',
description: [
'Looks the item up in its storefront catalog, confirms the clients `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<App>({ 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<App>({ 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<App>({ 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({
@@ -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<string, string>, 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',