diff --git a/CLAUDE.md b/CLAUDE.md index 3ae9a83..d1e0109 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -259,6 +259,13 @@ inconsistency here without checking the client first. each stored and pushed as `MessageReceived`. The `RelationshipChanged` frames only refresh relationship state: sent alone, the request "worked" server-side and the target 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 `rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index aa62098..6230c55 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -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 * 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( giftDrop: StoreGiftDrop, message: string, @@ -1116,7 +1131,7 @@ function toGiftContent( ConsumableMappingId: consumableMappingId, ConsumablePreExistingCount: consumablePreExistingCount, AvatarItemDesc: giftDrop.AvatarItemDesc, - AvatarItemType: giftDrop.AvatarItemType, + AvatarItemType: boxAvatarItemType(giftDrop), CurrencyType: giftDrop.CurrencyType, Currency: giftDrop.Currency, Xp: giftDrop.Xp ?? 0, @@ -1197,7 +1212,7 @@ async function pushGiftReceived( FromPlayerId: fromPlayerId, ConsumableItemDesc: gift.drop.ConsumableItemDesc, AvatarItemDesc: gift.drop.AvatarItemDesc, - AvatarItemType: gift.drop.AvatarItemType ?? 0, + AvatarItemType: boxAvatarItemType(gift.drop), EquipmentPrefabName: gift.drop.EquipmentPrefabName, EquipmentModificationGuid: gift.drop.EquipmentModificationGuid, CurrencyType: gift.drop.CurrencyType, @@ -1347,7 +1362,8 @@ function toSkinStoreItem(row: CatalogRow): StoreItem { Tooltip: row.tooltip ?? '', ConsumableItemDesc: '', AvatarItemDesc: '', - AvatarItemType: 0, + // A skin is no avatar item: null, as the store lists every skin. + AvatarItemType: null, EquipmentPrefabName: row.prefab_name ?? '', EquipmentModificationGuid: row.item_key, Rarity: row.rarity, @@ -1668,7 +1684,7 @@ function toBalanceUpdateData( FromPlayerId: fromPlayerId, ConsumableItemDesc: drop.ConsumableItemDesc, AvatarItemDesc: drop.AvatarItemDesc, - AvatarItemType: drop.AvatarItemType ?? 0, + AvatarItemType: boxAvatarItemType(drop), EquipmentPrefabName: drop.EquipmentPrefabName, EquipmentModificationGuid: drop.EquipmentModificationGuid, CurrencyType: drop.CurrencyType, @@ -1816,7 +1832,7 @@ function toGiftPackage( PlayerId: playerId, FromPlayerId: fromPlayerId, ConsumableItemDesc: drop.ConsumableItemDesc, - AvatarItemType: drop.AvatarItemType ?? 0, + AvatarItemType: boxAvatarItemType(drop), AvatarItemDesc: drop.AvatarItemDesc, CustomAvatarItemId: null, EquipmentPrefabName: drop.EquipmentPrefabName, diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 1ffc2db..5a1c1fd 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -2827,7 +2827,7 @@ describe('econ endpoints', () => { Data: Array<{ ConsumableItemDesc: string AvatarItemDesc: string - AvatarItemType: number + AvatarItemType: number | null FromPlayerId: number }> }> @@ -2837,8 +2837,9 @@ describe('econ endpoints', () => { const drop = body.BalanceUpdates[0].Data[0] expect(drop.ConsumableItemDesc).toBe(consumableDesc) expect(drop.AvatarItemDesc).toBe('') - // A consumable's AvatarItemType is null in the catalog; the response coalesces it to 0. - expect(drop.AvatarItemType).toBe(0) + // A consumable's AvatarItemType is NULL, as the store lists it — never 0, which the + // 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). expect(drop.FromPlayerId).toBe(1) @@ -4888,7 +4889,9 @@ describe('econ endpoints', () => { FromPlayerId: 1, ConsumableItemDesc: '', 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, EquipmentModificationGuid: gift.EquipmentModificationGuid, CurrencyType: 0,