[econ] fix consumable/skin boxes

This commit is contained in:
Devin Zuczek
2026-09-24 14:18:06 -04:00
parent a1e89d6948
commit 72fd75e29e
3 changed files with 35 additions and 9 deletions
+7
View File
@@ -259,6 +259,13 @@ inconsistency here without checking the client first.
each stored and pushed as `MessageReceived`. The `RelationshipChanged` frames only each stored and pushed as `MessageReceived`. The `RelationshipChanged` frames only
refresh relationship state: sent alone, the request "worked" server-side and the target refresh relationship state: sent alone, the request "worked" server-side and the target
saw nothing. Same pattern as the cheer. saw nothing. Same pattern as the cheer.
- A gift box's `AvatarItemType` (`econ`: the stored box in `GET /api/avatar/v2/gifts`, `buyItem`'s
`BalanceUpdates[].Data`, `bulkpurchase`'s `GiftPackage`, and the `GiftPackageReceived*` frame)
must be NULL for anything that is not an avatar item — a consumable, a skin, a box. The client
routes the box on that field before it reads what else the box names, so a 0 sends it looking
for avatar item type 0 and it fails with "can't find avatar item". The 2025 store lists every
non-avatar drop with `AvatarItemType: null` for this reason; `boxAvatarItemType` applies it on
every surface, so don't coalesce it with `?? 0` anywhere a box is built.
- Accessibility is sent as the `RoomAccessibility` enum NAME on - Accessibility is sent as the `RoomAccessibility` enum NAME on
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the `rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
+21 -5
View File
@@ -1099,6 +1099,21 @@ function truncateGiftMessage(message: string): string {
* with nothing but the row to say who sent it or why. They default to Coach and the drop's * 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. * own context — a box the server handed over on nobody's behalf.
*/ */
/**
* `AvatarItemType` as a gift box carries it: the drop's own for an avatar item, NULL for
* anything else — a consumable, a skin, a box, a token bundle.
*
* The client routes a box by this field before it looks at what else the box names: a 0
* reads as "an avatar item of type 0", and a consumable or skin box sent with 0 fails with
* "can't find avatar item" because there is no such item to find. The 2025 store lists every
* non-avatar drop with `AvatarItemType: null` for exactly that reason, and this is what the
* old `?? 0` coalescing used to erase. Applied at every surface a box is serialized on — the
* stored box, both purchase responses and the hub frame — so they cannot disagree.
*/
function boxAvatarItemType(drop: StoreGiftDrop): number | null {
return drop.AvatarItemDesc !== '' ? (drop.AvatarItemType ?? 0) : null
}
function toGiftContent( function toGiftContent(
giftDrop: StoreGiftDrop, giftDrop: StoreGiftDrop,
message: string, message: string,
@@ -1116,7 +1131,7 @@ function toGiftContent(
ConsumableMappingId: consumableMappingId, ConsumableMappingId: consumableMappingId,
ConsumablePreExistingCount: consumablePreExistingCount, ConsumablePreExistingCount: consumablePreExistingCount,
AvatarItemDesc: giftDrop.AvatarItemDesc, AvatarItemDesc: giftDrop.AvatarItemDesc,
AvatarItemType: giftDrop.AvatarItemType, AvatarItemType: boxAvatarItemType(giftDrop),
CurrencyType: giftDrop.CurrencyType, CurrencyType: giftDrop.CurrencyType,
Currency: giftDrop.Currency, Currency: giftDrop.Currency,
Xp: giftDrop.Xp ?? 0, Xp: giftDrop.Xp ?? 0,
@@ -1197,7 +1212,7 @@ async function pushGiftReceived(
FromPlayerId: fromPlayerId, FromPlayerId: fromPlayerId,
ConsumableItemDesc: gift.drop.ConsumableItemDesc, ConsumableItemDesc: gift.drop.ConsumableItemDesc,
AvatarItemDesc: gift.drop.AvatarItemDesc, AvatarItemDesc: gift.drop.AvatarItemDesc,
AvatarItemType: gift.drop.AvatarItemType ?? 0, AvatarItemType: boxAvatarItemType(gift.drop),
EquipmentPrefabName: gift.drop.EquipmentPrefabName, EquipmentPrefabName: gift.drop.EquipmentPrefabName,
EquipmentModificationGuid: gift.drop.EquipmentModificationGuid, EquipmentModificationGuid: gift.drop.EquipmentModificationGuid,
CurrencyType: gift.drop.CurrencyType, CurrencyType: gift.drop.CurrencyType,
@@ -1347,7 +1362,8 @@ function toSkinStoreItem(row: CatalogRow): StoreItem {
Tooltip: row.tooltip ?? '', Tooltip: row.tooltip ?? '',
ConsumableItemDesc: '', ConsumableItemDesc: '',
AvatarItemDesc: '', AvatarItemDesc: '',
AvatarItemType: 0, // A skin is no avatar item: null, as the store lists every skin.
AvatarItemType: null,
EquipmentPrefabName: row.prefab_name ?? '', EquipmentPrefabName: row.prefab_name ?? '',
EquipmentModificationGuid: row.item_key, EquipmentModificationGuid: row.item_key,
Rarity: row.rarity, Rarity: row.rarity,
@@ -1668,7 +1684,7 @@ function toBalanceUpdateData(
FromPlayerId: fromPlayerId, FromPlayerId: fromPlayerId,
ConsumableItemDesc: drop.ConsumableItemDesc, ConsumableItemDesc: drop.ConsumableItemDesc,
AvatarItemDesc: drop.AvatarItemDesc, AvatarItemDesc: drop.AvatarItemDesc,
AvatarItemType: drop.AvatarItemType ?? 0, AvatarItemType: boxAvatarItemType(drop),
EquipmentPrefabName: drop.EquipmentPrefabName, EquipmentPrefabName: drop.EquipmentPrefabName,
EquipmentModificationGuid: drop.EquipmentModificationGuid, EquipmentModificationGuid: drop.EquipmentModificationGuid,
CurrencyType: drop.CurrencyType, CurrencyType: drop.CurrencyType,
@@ -1816,7 +1832,7 @@ function toGiftPackage(
PlayerId: playerId, PlayerId: playerId,
FromPlayerId: fromPlayerId, FromPlayerId: fromPlayerId,
ConsumableItemDesc: drop.ConsumableItemDesc, ConsumableItemDesc: drop.ConsumableItemDesc,
AvatarItemType: drop.AvatarItemType ?? 0, AvatarItemType: boxAvatarItemType(drop),
AvatarItemDesc: drop.AvatarItemDesc, AvatarItemDesc: drop.AvatarItemDesc,
CustomAvatarItemId: null, CustomAvatarItemId: null,
EquipmentPrefabName: drop.EquipmentPrefabName, EquipmentPrefabName: drop.EquipmentPrefabName,
+7 -4
View File
@@ -2827,7 +2827,7 @@ describe('econ endpoints', () => {
Data: Array<{ Data: Array<{
ConsumableItemDesc: string ConsumableItemDesc: string
AvatarItemDesc: string AvatarItemDesc: string
AvatarItemType: number AvatarItemType: number | null
FromPlayerId: number FromPlayerId: number
}> }>
}> }>
@@ -2837,8 +2837,9 @@ describe('econ endpoints', () => {
const drop = body.BalanceUpdates[0].Data[0] const drop = body.BalanceUpdates[0].Data[0]
expect(drop.ConsumableItemDesc).toBe(consumableDesc) expect(drop.ConsumableItemDesc).toBe(consumableDesc)
expect(drop.AvatarItemDesc).toBe('') expect(drop.AvatarItemDesc).toBe('')
// A consumable's AvatarItemType is null in the catalog; the response coalesces it to 0. // A consumable's AvatarItemType is NULL, as the store lists it — never 0, which the
expect(drop.AvatarItemType).toBe(0) // client reads as an avatar item and fails on ("can't find avatar item").
expect(drop.AvatarItemType).toBeNull()
// A self-buy is attributed to the "Coach" system account (id 1). // A self-buy is attributed to the "Coach" system account (id 1).
expect(drop.FromPlayerId).toBe(1) expect(drop.FromPlayerId).toBe(1)
@@ -4888,7 +4889,9 @@ describe('econ endpoints', () => {
FromPlayerId: 1, FromPlayerId: 1,
ConsumableItemDesc: '', ConsumableItemDesc: '',
AvatarItemDesc: gift.AvatarItemDesc, AvatarItemDesc: gift.AvatarItemDesc,
AvatarItemType: gift.AvatarItemType, // The week's reward is a SKIN, so this is null whatever the block says: a 0 would
// send the client looking for an avatar item that does not exist.
AvatarItemType: gift.AvatarItemDesc === '' ? null : gift.AvatarItemType,
EquipmentPrefabName: gift.EquipmentPrefabName, EquipmentPrefabName: gift.EquipmentPrefabName,
EquipmentModificationGuid: gift.EquipmentModificationGuid, EquipmentModificationGuid: gift.EquipmentModificationGuid,
CurrencyType: 0, CurrencyType: 0,