[econ] fix box roll

This commit is contained in:
Devin Zuczek
2026-08-11 00:38:53 -04:00
parent 7c43f2a1f3
commit 9164df98a9
3 changed files with 76 additions and 17 deletions
+5 -3
View File
@@ -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
+23 -14
View File
@@ -1777,8 +1777,16 @@ const app = new Hono<App>({ 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<App>({ 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<App>({ 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,
},
],
@@ -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 sf3s 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')),