From d31e05d41aacc752d90a101c86216c94193d0fe1 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 26 Aug 2026 11:14:59 -0400 Subject: [PATCH] [econ] gift purchase --- apps/econ/src/econ.app.ts | 254 ++++++++++++++------- apps/econ/src/test/integration/api.test.ts | 229 ++++++++++++++++++- packages/domain/src/gifts-db.ts | 10 + 3 files changed, 406 insertions(+), 87 deletions(-) diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 17539a0..65a31be 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -6,6 +6,7 @@ import { addXp, consumeGift, createGift, + getAccount, getGift, getOutfits, getPendingGifts, @@ -506,47 +507,76 @@ interface StoreItem { 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". + * in `Prices` and 85 in here). A subscriber's client renders and posts this as + * `RequestedPrice`, so checking their buy against `Prices` alone 409s it as "Price has + * changed". Treated as a FLOOR rather than the price to expect, because the client also + * posts the FULL price for items whose two lists agree (sf3's 2208, 150/150) — see + * {@link priceCheck}. */ SubscriberPrices?: StorePrice[] | null PurchasableItemId: number } /** - * The Rec Room Plus discount, in percent off the regular price, floored to whole tokens. + * The most Rec Room Plus can take off an item, in percent of the regular price. * - * 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). + * The client applies the discount ITSELF and posts the result as `RequestedPrice`, but it + * does NOT apply it to everything: sf3's item 2208 is 150 tokens in both catalog lists and a + * subscriber's client posts 150, while sf300's 2263 is 95/85 and posts 85. Only 144 of the + * 1382 captured items carry a discounted `SubscriberPrices` at all, and whether the rest are + * genuinely full price for a subscriber or were merely captured through a non-subscriber's + * view isn't answerable from here. So the server doesn't predict the number: it accepts + * anything from the regular price down to this much off (see {@link priceCheck}) and charges + * what the client asked to pay. Deriving one exact subscriber price instead 409'd every buy + * the client priced the other way. */ const SUBSCRIBER_DISCOUNT_PERCENT = 10 +/** The lowest a subscriber's client can render an item whose regular price is `regular`. */ +function subscriberFloor(regular: number): number { + return Math.floor((regular * (100 - SUBSCRIBER_DISCOUNT_PERCENT)) / 100) +} + /** - * 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. + * The outcome of confirming a client's `RequestedPrice` against the catalog: the price to + * actually charge, or why the line can't be sold. */ -function priceFor( +type PriceCheck = + | { charge: number } + /** The item isn't sold in the requested currency at all. */ + | 'no-currency' + /** The catalog moved under a stale client, or the price was made up. */ + | 'mismatch' + +/** + * Confirms what the buyer's client rendered, and answers what to charge them. + * + * A non-subscriber pays the `Prices` entry, exactly. A subscriber pays whatever they asked to + * pay within a BAND: the regular price at the top (their client posts it for items it doesn't + * discount) down to the catalog's `SubscriberPrices` entry or + * {@link SUBSCRIBER_DISCOUNT_PERCENT} off, whichever is lower. + * + * Charging `RequestedPrice` rather than a server-picked end of the band keeps the debit equal + * to the price the buyer was shown. The floor is what bounds the discount: a modified client + * can shave at most {@link SUBSCRIBER_DISCOUNT_PERCENT} off, and only while subscribed. + */ +function priceCheck( item: StoreItem, currencyType: number, - subscriber: boolean -): StorePrice | undefined { + subscriber: boolean, + requestedPrice: unknown +): PriceCheck { const regular = item.Prices.find((p) => p.CurrencyType === currencyType) - if (regular === undefined || !subscriber) return regular + if (regular === undefined) return 'no-currency' + if (!Number.isInteger(requestedPrice)) return 'mismatch' + const requested = requestedPrice as number + if (requested === regular.Price) return { charge: requested } + if (!subscriber) return 'mismatch' 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), - } + const floor = Math.min(subscriberFloor(regular.Price), listed?.Price ?? regular.Price) + return requested >= floor && requested < regular.Price ? { charge: requested } : 'mismatch' } + interface Storefront { StoreItems: StoreItem[] } @@ -619,15 +649,26 @@ const CONSUMABLE_GRANT_COUNT = 1 /** The "Coach" system account — the sender a self-buy or anonymous gift is attributed to. */ const COACH_ACCOUNT_ID = 1 -/** Build the stored gift-box content (the client's rendered "gift box") from a gift-drop. */ +/** + * Build the stored gift-box content (the client's rendered "gift box") from a gift-drop. + * + * `fromPlayerId` and `giftContext` are stamped on because the box outlives the request that + * made it: a gift's receiver may well be offline and meets it in `GET /api/avatar/v2/gifts`, + * with nothing but the row to say who sent it or why. They default to Coach and the drop's + * own context — a box the server handed over on nobody's behalf. + */ function toGiftContent( giftDrop: StoreGiftDrop, message: string, consumableCount: number, consumableMappingId = 0, - consumablePreExistingCount = 0 + consumablePreExistingCount = 0, + fromPlayerId = COACH_ACCOUNT_ID, + giftContext: number | null = null ): GiftContent { return { + FromPlayerId: fromPlayerId, + GiftContext: giftContext ?? giftDrop.Context, ConsumableItemDesc: giftDrop.ConsumableItemDesc, ConsumableCount: consumableCount, ConsumableMappingId: consumableMappingId, @@ -671,7 +712,8 @@ async function pushGiftReceived( accountId: number, gift: GrantedGift, message: string, - fromPlayerId: number + fromPlayerId: number, + giftContext: number | null = null ): Promise { try { await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( @@ -693,7 +735,7 @@ async function pushGiftReceived( Platform: -1, PlatformsToSpawnOn: -1, BalanceType: ALL_PLATFORMS, - GiftContext: gift.drop.Context, + GiftContext: giftContext ?? gift.drop.Context, GiftRarity: gift.drop.Rarity, Message: message, } @@ -903,6 +945,13 @@ interface GrantOptions extends RollOptions { * it is saying its own UI announces the items. */ skipGiftBox?: boolean + /** + * Who the box says it is from, and why it exists — a purchase gifted to another player + * carries the buyer (or Coach, when they sent it anonymously) and the `Gift` block's + * `GiftContext`. Default: Coach and the drop's own context, i.e. a box from the server. + */ + fromPlayerId?: number + giftContext?: number | null } /** @@ -989,7 +1038,15 @@ async function grantGiftDrop( const { id } = await createGift( db, accountId, - toGiftContent(giftDrop, message, consumableCount, consumableMappingId, consumablePreExisting) + toGiftContent( + giftDrop, + message, + consumableCount, + consumableMappingId, + consumablePreExisting, + options.fromPlayerId, + options.giftContext + ) ) return { id, drop: giftDrop } } @@ -1184,9 +1241,9 @@ function toPurchaseMethodId(raw: Partial | null | undefined): * * 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. + * line: `RequestedPrice` is the UNIT price the client rendered (for a subscriber, anywhere in + * the discount band — see {@link priceCheck}), 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, @@ -1241,30 +1298,25 @@ function resolveBulkLine( error: 'This item can only be bought once per line', } } - const price = priceFor(item, currencyType, subscriber) - if (price === undefined) { + const checked = priceCheck(item, currencyType, subscriber, line.RequestedPrice) + if (checked === 'no-currency') { return { method, code: UpdateResponse.NoItemAvailable, error: 'Currency type not available for this item', } } - if (!Number.isInteger(line.RequestedPrice)) { + if (checked === 'mismatch') { return { method, code: UpdateResponse.RequestedPriceDoesNotMatch, - error: 'RequestedPrice is required', - } - } - if (line.RequestedPrice !== price.Price) { - return { - method, - code: UpdateResponse.RequestedPriceDoesNotMatch, - error: 'Price has changed', + error: !Number.isInteger(line.RequestedPrice) + ? 'RequestedPrice is required' + : 'Price has changed', } } const gift = typeof line.Gift === 'object' && line.Gift !== null ? line.Gift : null - return { method, item, price: price.Price, count, gift } + return { method, item, price: checked.charge, count, gift } } /** Whether a resolved line is buyable or is already a failure. */ @@ -2485,10 +2537,14 @@ 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 (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', + 'still matches the `Prices` entry — a Rec Room Plus subscriber (the same check as', + '`UpdateAndGetSubscription`) may pay anywhere from that down to 10% off, since their', + 'client applies the discount itself and not to every item — debits the buyer atomically,', + 'grants the item (into the inventory or', + 'consumable table), and returns a gift box. A `Gift` block routes the item — and its', + 'box — to the player it names, who is handed it over the hub as', + '`GiftPackageReceivedImmediate`; the caller always pays, and `Anonymous` hides them', + 'from the box rather than withholding it. `Balance` in the response is the CHANGE (negated', 'price), not the new total. Pushes a StorefrontBalancePurchase socket frame that SETS the', 'buyer’s account-wide bucket to the RESULTING total, so the frame, this body and a', '`GET /balance` re-fetch all agree (`Delta` there is display-only).', @@ -2499,7 +2555,7 @@ const app = new Hono({ strict: false }) 200: json(BuyItemResponse, 'The purchase result (gift box + balance change)'), 400: json(ErrorResponse, 'Invalid body, unavailable currency, or insufficient balance'), 401: UNAUTHORIZED_RESPONSE, - 404: json(ErrorResponse, 'No such item'), + 404: json(ErrorResponse, 'No such item, or a `Gift` naming a player that does not exist'), 409: json(ErrorResponse, 'The price has changed since the client rendered it'), }, }), @@ -2533,15 +2589,21 @@ 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) - // 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) { + // A subscriber's client prices the item itself and posts the result, so the check is a + // band rather than one number — see `priceCheck`. `charge` is what they asked to pay. + const checked = priceCheck( + item, + currencyType as number, + await isSubscriber(c), + requestedPrice + ) + if (checked === 'no-currency') { return c.json({ error: 'Currency type not available for this item' }, 400) } - if (price.Price !== requestedPrice) { + if (checked === 'mismatch') { return c.json({ error: 'Price has changed' }, 409) } + const price = checked.charge // The item's currency must be an account balance we can debit (RecCenterTokens et al), // not a room-scoped or non-spendable currency. if (!isSpendable(currencyType as number)) { @@ -2556,17 +2618,19 @@ const app = new Hono({ strict: false }) // is attributed to the "Coach" system account (id 1), never a null/0 sender. const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3' + const giftContext = Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null + // A gift is paid for here and granted THERE, so an id that names nobody would take the + // buyer's tokens and strand the box on an account that will never read it. The client + // only offers players it just looked up, so this is a tampered or stale id — refuse it + // before charging rather than after. + if (receiverId !== id && (await getAccount(c.env.DB, receiverId)) === null) { + return c.json({ error: 'No such player to gift to' }, 404) + } const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) // Debit the buyer atomically; a false return means they couldn't afford it and // nothing changed, so no item is granted. - const paid = await spendCurrency( - c.env.DB, - id, - currencyType as number, - price.Price, - startingTokens - ) + const paid = await spendCurrency(c.env.DB, id, currencyType as number, price, startingTokens) if (!paid) return c.json({ error: 'Insufficient balance' }, 400) // Grant the item to the recipient, with the gift box that renders it. A box (an @@ -2574,7 +2638,18 @@ const app = new Hono({ strict: false }) // `granted.drop` is what the roll landed on — the response has to describe THAT, not // the box, or a query purchase answers with every item field empty and the client // draws an empty box. - const granted = await grantGiftDrop(c, receiverId, item.GiftDrop, message) + const granted = await grantGiftDrop(c, receiverId, item.GiftDrop, message, { + fromPlayerId, + giftContext, + }) + + // The buyer reads their own box out of the response below, but a gift's receiver has + // no response to read — they may not even be online. Hand them the box the way every + // other server-handed box arrives, so it pops in front of them instead of waiting for + // their client's next `GET /api/avatar/v2/gifts`. + if (receiverId !== id) { + await pushGiftReceived(c, receiverId, granted, message, fromPlayerId, giftContext) + } // Push the spend to the buyer (`id` — the caller is who was charged) so their client // updates without waiting for a `GET /balance` re-fetch. StorefrontBalancePurchase @@ -2582,7 +2657,7 @@ const app = new Hono({ strict: false }) // with both the response body below and any re-fetch instead of compounding with them // — see the frame rule above pushBalanceUpdate. Best-effort. const newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens) - await pushBalancePurchase(c, id, currencyType as number, -price.Price, newBalance) + await pushBalancePurchase(c, id, currencyType as number, -price, newBalance) // The response mirrors a captured real buyItem: `Balance` is the change applied (the // negated price), not the resulting balance (the client reads its new total from @@ -2591,17 +2666,10 @@ const app = new Hono({ strict: false }) BalanceUpdates: [ { UpdateResponse: 0, - Data: [ - toBalanceUpdateData( - granted, - fromPlayerId, - message, - Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null - ), - ], + Data: [toBalanceUpdateData(granted, fromPlayerId, message, giftContext)], }, ], - Balance: -price.Price, + Balance: -price, CurrencyType: currencyType, BalanceType: ALL_PLATFORMS, }) @@ -2646,6 +2714,7 @@ const app = new Hono({ strict: false }) 200: json(BulkPurchaseResponse, 'The bag’s result, or `Success: false` if nothing sold'), 400: json(BulkPurchaseResponse, 'A request that could not be evaluated at all'), 401: UNAUTHORIZED_RESPONSE, + 404: json(BulkPurchaseResponse, 'A line gifts to a player that does not exist'), }, }), async (c) => { @@ -2656,7 +2725,7 @@ const app = new Hono({ strict: false }) // this shape never has to special-case one. A null `Value` is legal here (the client's // validator only cascades into a non-null one), and it is the honest answer: nothing // was bought, so there is no balance to report and nothing to render. - const refuse = (error: string, status: 200 | 400 = 200) => + const refuse = (error: string, status: 200 | 400 | 404 = 200) => c.json({ Success: false, Error: error, error_id: null, Value: null }, status) const body = (await c.req.json().catch(() => null)) as { @@ -2703,6 +2772,20 @@ const app = new Hono({ strict: false }) const firstFailure = resolved.find((line): line is BulkLineFailure => !isBulkLine(line)) if (!allowPartial && firstFailure !== undefined) return refuse(firstFailure.error) + // Same as buyItem: a line gifting to an id that names nobody would charge the buyer and + // strand the box. One lookup per DISTINCT recipient, and the whole bag refuses — a bad + // recipient is a malformed request, not a line that merely didn't fit. + const recipients = new Set() + for (const line of buyable) { + const to = line.gift?.ToPlayerId + if (Number.isInteger(to) && to !== id) recipients.add(to as number) + } + for (const to of recipients) { + if ((await getAccount(c.env.DB, to)) === null) { + return refuse('No such player to gift to', 404) + } + } + // Decide what the balance covers BEFORE spending: lines are taken in request order // while they fit, so a bag that overruns still buys the items the player put in first. // The read is only for choosing; the single spend below is what actually settles, and @@ -2752,9 +2835,16 @@ const app = new Hono({ strict: false }) // player while the caller pays, a named gift shows the sender, and a self-buy or an // anonymous gift is attributed to the "Coach" system account. const gift = line.gift - const receiverId = Number.isInteger(gift?.ToPlayerId) ? (gift?.ToPlayerId as number) : id + // Annotated: without it the inference of this handler's own type runs through the + // hub call below and back, and tsc gives up on the initializer (TS7022). + const receiverId: number = Number.isInteger(gift?.ToPlayerId) + ? (gift?.ToPlayerId as number) + : id const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3' + const giftContext = Number.isInteger(gift?.GiftContext) + ? (gift?.GiftContext as number) + : null // One box per requested item, holding all `count` copies — the wire has one // `GiftPackage` per entry, and only a consumable can be asked for more than once // (`resolveBulkLine` refuses a bigger count on anything owned once). @@ -2762,20 +2852,22 @@ const app = new Hono({ strict: false }) rollCatalog, skipGiftBox, copies: line.count, + fromPlayerId, + giftContext, }) + // The bag's own response carries only the buyer's boxes, so a gifted line is + // announced to its receiver the same way buyItem's is. `BypassGiftPackages` skipped + // the box entirely, and there is nothing to announce. + if (receiverId !== id && !skipGiftBox) { + await pushGiftReceived(c, receiverId, granted, message, fromPlayerId, giftContext) + } packages.set( line, // Null under `BypassGiftPackages`, which is the flag asking for exactly that — // the item is granted either way. skipGiftBox ? null - : toGiftPackage( - granted, - receiverId, - fromPlayerId, - message, - Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null - ) + : toGiftPackage(granted, receiverId, fromPlayerId, message, giftContext) ) } diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index e23e399..c53c043 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -1266,7 +1266,8 @@ describe('econ endpoints', () => { }) // 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. + // client applies the Plus discount itself, but not to every item, so the server takes any + // price in the band [85, 95] from a subscriber and charges what they asked to pay. const buy2263 = async (headers: Record, RequestedPrice: number) => exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { method: 'POST', @@ -1292,9 +1293,9 @@ describe('econ endpoints', () => { 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. + test('POST /api/storefronts/v2/buyItem takes 10% off from a subscriber when the catalog lists no discount', async () => { + // sf3's item 2184 is 95 in BOTH lists, but a subscriber's client may still post + // floor(95 * 0.9) = 85. const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { method: 'POST', headers: { @@ -1314,9 +1315,38 @@ describe('econ endpoints', () => { 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) + test('POST /api/storefronts/v2/buyItem charges a subscriber the full price when their client sends it', async () => { + // sf3's item 2208 is 150 in both lists and the live client posts 150 for a subscriber: + // not every item is discounted, so the full price has to stay buyable by a subscriber. + const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { + method: 'POST', + headers: { + ...(await bearer('326', ['gameClient', 'developer'])), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + StorefrontType: 3, + PurchasableItemId: 2208, + CurrencyType: 2, + RequestedPrice: 150, + CouponConsumablePlayerMappingId: null, + Gift: null, + }), + }) + expect(res.status).toBe(200) + expect(((await res.json()) as { Balance: number }).Balance).toBe(-150) + const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, { + headers: await bearer('326'), + }) + expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 - 150 }]) + }) + + test('POST /api/storefronts/v2/buyItem 409s a subscriber below the discount band', async () => { + const res = await buy2263(await bearer('323', ['gameClient', 'developer']), 84) expect(res.status).toBe(409) + // …and above it: a made-up price is a mismatch in either direction. + const over = await buy2263(await bearer('323', ['gameClient', 'developer']), 96) + expect(over.status).toBe(409) }) test('POST /api/storefronts/v2/buyItem 409s a non-subscriber posting the subscriber price', async () => { @@ -1326,6 +1356,141 @@ describe('econ endpoints', () => { expect(ok.status).toBe(200) }) + /** Seed a real account row, so a gift naming this player has somewhere to land. */ + const seedAccount = async (accountId: number, username: string) => { + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind(JSON.stringify({ accountId, username, displayName: username })) + .run() + } + + /** Buy sf3's item 2107 (Backpack Skin (Camo), 3500) with a `Gift` block, as the client posts it. */ + const giftBackpack = async (sub: string, gift: Record | null) => + exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { + method: 'POST', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + StorefrontType: 3, + PurchasableItemId: 2107, + CurrencyType: 2, + RequestedPrice: 3500, + CouponConsumablePlayerMappingId: null, + Gift: gift, + }), + }) + + const pendingGifts = async (sub: string) => { + const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, { + headers: await bearer(sub), + }) + expect(res.status).toBe(200) + return (await res.json()) as Array> + } + + const equipment = async (sub: string) => { + const res = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`, { + headers: await bearer(sub), + }) + expect(res.status).toBe(200) + return (await res.json()) as Array<{ ModificationGuid: string }> + } + + test('POST /api/storefronts/v2/buyItem charges the buyer and hands the item to the gift’s receiver', async () => { + await seedAccount(205, 'GiftReceiver') + await drainFrames() + const res = await giftBackpack('330', { + ToPlayerId: 205, + Message: 'hello this is a message', + Anonymous: false, + GiftContext: 500, + }) + expect(res.status).toBe(200) + // The buyer pays — `Balance` is their change — even though nothing lands on them. + expect(((await res.json()) as { Balance: number }).Balance).toBe(-3500) + const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, { + headers: await bearer('330'), + }) + expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 - 3500 }]) + + // The item and its box are the RECEIVER's; the buyer keeps neither. + expect(await equipment('330')).toEqual([]) + expect(await pendingGifts('330')).toEqual([]) + const [box, ...rest] = await equipment('205') + expect(rest).toEqual([]) + expect(box?.ModificationGuid).toBe('523e3615-4633-41a3-9b2d-17d3207a684b') + const [gift, ...others] = await pendingGifts('205') + expect(others).toEqual([]) + // The box outlives the request, so it carries who sent it and why — the receiver may + // only ever meet it in this list. + expect(gift?.FromPlayerId).toBe(330) + expect(gift?.GiftContext).toBe(500) + expect(gift?.Message).toBe('hello this is a message') + + // The receiver has no response to read, so the box is pushed to them. + const frames = await drainFrames() + const received = frames.find( + (f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate + ) + expect(received?.accountId).toBe(205) + expect(received?.payload).toMatchObject({ + Id: gift?.Id, + FromPlayerId: 330, + GiftContext: 500, + Message: 'hello this is a message', + EquipmentModificationGuid: '523e3615-4633-41a3-9b2d-17d3207a684b', + }) + // …and the spend frame still goes to the BUYER, who is the one who paid. + const spend = frames.find( + (f) => f.notificationType === NotificationType.StorefrontBalancePurchase + ) + expect(spend?.accountId).toBe(330) + }) + + test('POST /api/storefronts/v2/buyItem attributes an anonymous gift to Coach', async () => { + await seedAccount(206, 'AnonReceiver') + await drainFrames() + const res = await giftBackpack('331', { + ToPlayerId: 206, + Message: 'guess who', + Anonymous: true, + }) + expect(res.status).toBe(200) + // Anonymous hides the sender from the box, it does not withhold the gift: id 1 is Coach. + const [gift] = await pendingGifts('206') + expect(gift?.FromPlayerId).toBe(1) + expect(gift?.Message).toBe('guess who') + const received = (await drainFrames()).find( + (f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate + ) + expect(received?.payload).toMatchObject({ FromPlayerId: 1 }) + }) + + test('POST /api/storefronts/v2/buyItem 404s a gift to a player that does not exist', async () => { + await drainFrames() + const res = await giftBackpack('332', { ToPlayerId: 999999, Message: 'hi', Anonymous: false }) + expect(res.status).toBe(404) + expect(await res.json()).toEqual({ error: 'No such player to gift to' }) + // Refused before the debit: the buyer still has every token, and nothing was pushed. + expect( + await getBalance(env.DB, 332, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS) + ).toBe(10000) + expect(await drainFrames()).toEqual([]) + }) + + test('POST /api/storefronts/v2/buyItem gifting to yourself is just a purchase', async () => { + await seedAccount(333, 'SelfGifter') + await drainFrames() + const res = await giftBackpack('333', { ToPlayerId: 333, Message: 'treat', Anonymous: false }) + expect(res.status).toBe(200) + const [gift] = await pendingGifts('333') + expect(gift?.FromPlayerId).toBe(333) + // No hub gift frame: the buyer read the box out of the response. + expect( + (await drainFrames()).filter( + (f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate + ) + ).toEqual([]) + }) + 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', @@ -1642,6 +1807,58 @@ describe('econ endpoints', () => { expect(list[0].friendlyName).toBe('Babydoll Dress (Blue)') }) + test('POST /api/items/bulkpurchase routes a gifted line to its receiver', async () => { + await seedAccount(207, 'BagReceiver') + await drainFrames() + const res = await bulkPurchase('960', { + PurchaseItemRequests: [ + line(10, 200), + line(2182, 100, { + Gift: { ToPlayerId: 207, Message: 'from the bag', Anonymous: false, GiftContext: 500 }, + }), + ], + }) + const body = (await res.json()) as BulkBody + expect(body.Success).toBe(true) + // The buyer pays for both lines; only the first one lands on them. + expect(body.Value!.Balance).toBe(10000 - 300) + const [own, gifted] = body.Value!.BalanceUpdates + expect(own?.Data.GiftPackage).toMatchObject({ PlayerId: 960, FromPlayerId: 1 }) + expect(gifted?.Data.GiftPackage).toMatchObject({ + PlayerId: 207, + FromPlayerId: 960, + GiftContext: 500, + }) + expect(await pendingGifts('960')).toHaveLength(1) + const [box] = await pendingGifts('207') + expect(box?.FromPlayerId).toBe(960) + expect(box?.GiftContext).toBe(500) + expect(box?.Message).toBe('from the bag') + // The bag's response is the buyer's; the receiver is told over the hub instead. + const received = (await drainFrames()).find( + (f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate + ) + expect(received?.accountId).toBe(207) + expect(received?.payload).toMatchObject({ Id: box?.Id, FromPlayerId: 960, GiftContext: 500 }) + }) + + test('POST /api/items/bulkpurchase 404s a bag gifting to a player that does not exist', async () => { + const res = await bulkPurchase('970', { + PurchaseItemRequests: [ + line(10, 200), + line(2182, 100, { Gift: { ToPlayerId: 999998, Message: 'hi', Anonymous: false } }), + ], + }) + expect(res.status).toBe(404) + const body = (await res.json()) as BulkBody + expect(body.Success).toBe(false) + expect(body.Error).toBe('No such player to gift to') + // The whole bag is refused before the debit, the good line included. + expect( + await getBalance(env.DB, 970, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS) + ).toBe(10000) + }) + test('POST /api/items/bulkpurchase reports per line what it cannot sell', async () => { await drainFrames() const res = await bulkPurchase('96', { diff --git a/packages/domain/src/gifts-db.ts b/packages/domain/src/gifts-db.ts index aff7077..1f36cad 100644 --- a/packages/domain/src/gifts-db.ts +++ b/packages/domain/src/gifts-db.ts @@ -34,6 +34,16 @@ export const RECEIVED_GIFT_SCHEMA_DDL: string[] = [ * `CreatedAt` are NOT part of this — they come from the row (see {@link StoredGift}). */ export interface GiftContent extends Record { + /** + * Who the box is from — the buyer for a named gift, the "Coach" system account (1) for a + * self-purchase or an anonymous one. The client draws the sender from the box itself, so + * a gift that doesn't carry it reads as being from nobody once the receiving player has + * to come back for it. Boxes written before this existed carry neither it nor + * {@link GiftContent.GiftContext}. + */ + FromPlayerId?: number + /** Why the box exists (the buying `Gift` block's `GiftContext`, else the drop's own). */ + GiftContext?: number ConsumableItemDesc: string ConsumableCount: number // When the gift carries a consumable, the id of the `consumable` row granted at