From 9164df98a9c0e61689938586700ebb6599ea0b2f Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 11 Aug 2026 00:38:53 -0400 Subject: [PATCH] [econ] fix box roll --- apps/econ/README.md | 8 ++-- apps/econ/src/econ.app.ts | 37 ++++++++++------- apps/econ/src/test/integration/api.test.ts | 48 ++++++++++++++++++++++ 3 files changed, 76 insertions(+), 17 deletions(-) diff --git a/apps/econ/README.md b/apps/econ/README.md index 8c768cb..337ee9b 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -125,9 +125,11 @@ weekly gift — hand over a real item rather than an unopenable box: - **`QueryRedirectRarity` wins over `Rarity`** when present — sf2 carries both and they agree; sf3's boxes carry only `Rarity`. - **An empty pool grants nothing** (logged `query gift-drop rolled nothing`) — an owner of - every 4-star item still gets the box, just nothing in it. The `buyItem` response still - echoes the drop the player _bought_, i.e. the box; the rolled item shows up in the box - itself, via `GET /api/avatar/v2/gifts`. + every 4-star item still gets the box, just nothing in it. +- **`buyItem` answers with the ROLLED item, not the box.** The client draws the purchase + from `BalanceUpdates[0].Data[0]`, and a query drop's own item fields are all empty — echo + those and the player sees an empty box for a purchase that actually granted something. The + stored box was always correct; only the response was wrong. ## Consume envelopes diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 579508c..61b776e 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -1777,8 +1777,16 @@ const app = new Hono({ strict: false }) 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 - // `IsQuery` drop, e.g. sf2's "4-Star Unique Box") rolls its prize in here. - const { id: giftId } = await grantGiftDrop(c, receiverId, item.GiftDrop, message) + // `IsQuery` drop, e.g. sf2's "4-Star Unique Box") rolls its prize in here, and + // `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 { id: giftId, drop: granted } = await grantGiftDrop( + c, + receiverId, + item.GiftDrop, + message + ) // Push the debit over the socket so the buyer's client updates the shown total // immediately — the buyer (`id`) is who was charged, in the currency they spent. The @@ -1789,8 +1797,9 @@ const app = new Hono({ strict: false }) // 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 // `GET /balance/:type`); `BalanceType` is -2 (account-wide, all platforms). The Data - // entry is the gift-drop the client received — it carries no FriendlyName or - // consumable count (the count is a getUnlocked concept; each box is one instance). + // entry is the gift-drop the client RECEIVED — the rolled item for a query box, the + // bought drop otherwise — and it carries no FriendlyName or consumable count (the + // count is a getUnlocked concept; each box is one instance). return c.json({ BalanceUpdates: [ { @@ -1799,22 +1808,22 @@ const app = new Hono({ strict: false }) { Id: giftId, FromPlayerId: fromPlayerId, - ConsumableItemDesc: item.GiftDrop.ConsumableItemDesc, - AvatarItemDesc: item.GiftDrop.AvatarItemDesc, - AvatarItemType: item.GiftDrop.AvatarItemType ?? 0, - EquipmentPrefabName: item.GiftDrop.EquipmentPrefabName, - EquipmentModificationGuid: item.GiftDrop.EquipmentModificationGuid, - CurrencyType: item.GiftDrop.CurrencyType, - Currency: item.GiftDrop.Currency, - Xp: 0, + ConsumableItemDesc: granted.ConsumableItemDesc, + AvatarItemDesc: granted.AvatarItemDesc, + AvatarItemType: granted.AvatarItemType ?? 0, + EquipmentPrefabName: granted.EquipmentPrefabName, + EquipmentModificationGuid: granted.EquipmentModificationGuid, + CurrencyType: granted.CurrencyType, + Currency: granted.Currency, + Xp: granted.Xp ?? 0, Level: 0, Platform: -1, PlatformsToSpawnOn: -1, BalanceType: ALL_PLATFORMS, GiftContext: Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) - : item.GiftDrop.Context, - GiftRarity: item.GiftDrop.Rarity, + : granted.Context, + GiftRarity: granted.Rarity, Message: message, }, ], diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 9bda268..8e8ac5d 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -1611,10 +1611,26 @@ describe('econ endpoints', () => { }) expect(res.status).toBe(200) + // The RESPONSE describes what the roll landed on, not the box that was bought: the + // client draws the purchase from this entry, and the box's own fields are all empty. + const bought = (await res.json()) as { + BalanceUpdates: Array<{ + Data: Array<{ + AvatarItemDesc: string + EquipmentModificationGuid: string + GiftRarity: number + }> + }> + } + const entry = bought.BalanceUpdates[0]?.Data[0] + expect(entry?.GiftRarity).toBe(30) + expect(`${entry?.AvatarItemDesc ?? ''}${entry?.EquipmentModificationGuid ?? ''}`).not.toBe('') + const boxes = await giftBoxes('76') expect(boxes).toHaveLength(1) // The box shows what was rolled — a real 4-star item, not the empty box drop. expect(boxes[0]?.GiftRarity).toBe(30) + expect(entry?.AvatarItemDesc).toBe(boxes[0]?.AvatarItemDesc) const key = (box?: { AvatarItemDesc: string; EquipmentModificationGuid: string }) => `${box?.AvatarItemDesc ?? ''}|${box?.EquipmentModificationGuid ?? ''}` expect(key(boxes[0])).not.toBe('|') @@ -1647,6 +1663,38 @@ describe('econ endpoints', () => { expect(key(after[0])).not.toBe(key(after[1])) }) + test('buying sf3’s Uncommon Random box answers with the rolled item', async () => { + // The purchase that came back as an empty box: an sf3 query drop, rolled out of the very + // catalog it sells in. + const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { + method: 'POST', + headers: { ...(await bearer('77')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + StorefrontType: 3, + PurchasableItemId: 2455, + CurrencyType: CurrencyType.RecCenterTokens, + RequestedPrice: 200, + CouponConsumablePlayerMappingId: null, + Gift: null, + }), + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { + BalanceUpdates: Array<{ + Data: Array<{ Id: number; AvatarItemDesc: string; GiftRarity: number }> + }> + } + const entry = body.BalanceUpdates[0]?.Data[0] + // Uncommon: rarity 10, and a real item rather than the box's empty fields. + expect(entry?.GiftRarity).toBe(10) + expect(entry?.AvatarItemDesc).not.toBe('') + + const boxes = await giftBoxes('77') + expect(boxes).toHaveLength(1) + expect(boxes[0]?.Id).toBe(entry?.Id) + expect(boxes[0]?.AvatarItemDesc).toBe(entry?.AvatarItemDesc) + }) + test('POST /api/gamerewards/v1/request claims once an hour per reward type', async () => { const headers = { ...(await bearer('80')),