From ed1a9f471449388f6cb8a07b42a864d118a012e8 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 21 Sep 2026 15:28:07 -0400 Subject: [PATCH] [econ] fix custom gift box not appearing (maybe) --- CLAUDE.md | 9 +- apps/econ/src/econ.app.ts | 267 ++++++++++++++++----- apps/econ/src/openapi.ts | 3 +- apps/econ/src/test/integration/api.test.ts | 184 +++++++++++++- packages/domain/src/gifts-db.ts | 5 + 5 files changed, 389 insertions(+), 79 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ccddf17..3ae9a83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -236,8 +236,13 @@ inconsistency here without checking the client first. the `Type: 0, NumberId` catalog lines. It is a SALE between players: the price leaves the buyer in the bag's one debit and is credited to the item's `CreatorAccountId` (pushed to them as a `StorefrontBalanceUpdate` carrying their resulting total), ownership is a row in - `inventory_custom`, and the entry's `Data.CustomAvatarItem` is the whole item record with - `GiftPackage` null — no box is minted. `GET /econ/customAvatarItems/v1/owned` is that table + `inventory_custom`, and the entry's `Data.CustomAvatarItem` is the whole item record. The item + still comes in a gift box, like any purchase: its `GiftPackage` names it by + `CustomAvatarItemId` with `AvatarItemDesc`/`EquipmentPrefabName`/etc. empty — without a box + the purchase shows the player nothing. `BypassGiftPackages` skips it as it does any line. + A custom line may carry a `Gift` block: ownership and the box go to `ToPlayerId` (announced + with `GiftPackageReceivedImmediate`), the buyer still pays and the creator is still paid, and + the owned/creator refusals are checked against the RECEIVER — so a creator may gift their own. `GET /econ/customAvatarItems/v1/owned` is that table joined to the item record PLUS everything the caller created (drafts included): a creator owns their own through `CreatorAccountId`, and the bag refuses to sell it to them. - A chat report (`api`: `POST /api/chatreport/createChatReport`) is form-encoded and sends diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index fd43081..e51d18b 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -1139,6 +1139,40 @@ function toGiftContent( } } +/** + * The stored gift box for a bought CUSTOM avatar item: the item named by + * `CustomAvatarItemId` where a catalog box names an `AvatarItemDesc`, every other item field + * empty. A custom item is no storefront drop, so it has no rarity of its own, and its context + * is the `Gift` block's or 0. `fromPlayerId` is routed as {@link toGiftContent}'s is. + */ +function toCustomGiftContent( + item: CustomAvatarItem, + message: string, + fromPlayerId: number, + giftContext: number | null +): GiftContent { + return { + FromPlayerId: fromPlayerId, + GiftContext: giftContext ?? 0, + ConsumableItemDesc: '', + ConsumableCount: 0, + AvatarItemDesc: '', + AvatarItemType: 0, + CustomAvatarItemId: item.CustomAvatarItemId, + CurrencyType: 0, + Currency: 0, + Xp: 0, + PackageType: 0, + Message: message, + EquipmentPrefabName: '', + EquipmentModificationGuid: '', + GiftRarity: 0, + Platform: -1, + PlatformsToSpawnOn: -1, + BalanceType: null, + } +} + /** * Push a GiftPackageReceivedImmediate notification for a gift box the player didn't ask * for, mirroring the reference's @@ -1164,36 +1198,80 @@ async function pushGiftReceived( message: string, fromPlayerId: number, giftContext: number | null = null +): Promise { + await sendGiftReceivedFrame(c, accountId, { + Id: gift.id, + FromGiftDropId: 0, + FromPlayerId: fromPlayerId, + ConsumableItemDesc: gift.drop.ConsumableItemDesc, + AvatarItemDesc: gift.drop.AvatarItemDesc, + AvatarItemType: gift.drop.AvatarItemType ?? 0, + EquipmentPrefabName: gift.drop.EquipmentPrefabName, + EquipmentModificationGuid: gift.drop.EquipmentModificationGuid, + CurrencyType: gift.drop.CurrencyType, + Currency: gift.drop.Currency, + Xp: gift.drop.Xp ?? 0, + Level: 0, + Platform: -1, + PlatformsToSpawnOn: -1, + BalanceType: ALL_PLATFORMS, + GiftContext: giftContext ?? gift.drop.Context, + GiftRarity: gift.drop.Rarity, + Message: message, + }) +} + +/** + * {@link pushGiftReceived} for a gifted custom avatar item's box, read off its stored + * content. The frame carries `CustomAvatarItemId` beside the usual fields: the decoder + * recovered for this frame names no such member and drops unknown ones silently, so it costs + * nothing if unread, and it is the only thing on the frame that says what the box holds. + */ +async function pushCustomGiftReceived( + c: Context, + accountId: number, + giftId: number, + content: GiftContent +): Promise { + await sendGiftReceivedFrame(c, accountId, { + Id: giftId, + FromGiftDropId: 0, + FromPlayerId: content.FromPlayerId, + ConsumableItemDesc: content.ConsumableItemDesc, + AvatarItemDesc: content.AvatarItemDesc, + AvatarItemType: content.AvatarItemType, + CustomAvatarItemId: content.CustomAvatarItemId, + EquipmentPrefabName: content.EquipmentPrefabName, + EquipmentModificationGuid: content.EquipmentModificationGuid, + CurrencyType: content.CurrencyType, + Currency: content.Currency, + Xp: content.Xp, + Level: 0, + Platform: -1, + PlatformsToSpawnOn: -1, + BalanceType: ALL_PLATFORMS, + GiftContext: content.GiftContext, + GiftRarity: content.GiftRarity, + Message: content.Message, + }) +} + +/** Send one GiftPackageReceivedImmediate frame, best-effort (see {@link pushGiftReceived}). */ +async function sendGiftReceivedFrame( + c: Context, + accountId: number, + payload: Record & { Id: number } ): Promise { try { await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( accountId, NotificationType.GiftPackageReceivedImmediate, - { - Id: gift.id, - FromGiftDropId: 0, - FromPlayerId: fromPlayerId, - ConsumableItemDesc: gift.drop.ConsumableItemDesc, - AvatarItemDesc: gift.drop.AvatarItemDesc, - AvatarItemType: gift.drop.AvatarItemType ?? 0, - EquipmentPrefabName: gift.drop.EquipmentPrefabName, - EquipmentModificationGuid: gift.drop.EquipmentModificationGuid, - CurrencyType: gift.drop.CurrencyType, - Currency: gift.drop.Currency, - Xp: gift.drop.Xp ?? 0, - Level: 0, - Platform: -1, - PlatformsToSpawnOn: -1, - BalanceType: ALL_PLATFORMS, - GiftContext: giftContext ?? gift.drop.Context, - GiftRarity: gift.drop.Rarity, - Message: message, - } + payload ) } catch (err) { logger.error('failed to push GiftPackageReceivedImmediate notification', { accountId, - giftId: gift.id, + giftId: payload.Id, error: err instanceof Error ? err.message : String(err), }) } @@ -1685,7 +1763,7 @@ interface BulkCustomLine { custom: CustomAvatarItem price: number count: 1 - gift: null + gift: GiftRequest | null } /** A line that resolved to something buyable. */ @@ -1698,8 +1776,9 @@ type BulkPurchaseLine = BulkCatalogLine | BulkCustomLine * * `CustomAvatarItem` is the UGC counterpart of `PurchasableItemId`: the whole item record on * a guid-keyed line that sold, which the client has nothing else to render it from. Both it - * and `GiftPackage` are null on a line that didn't sell, and a custom item comes without a - * box — it is granted straight into `inventory_custom` rather than wrapped. + * and `GiftPackage` are null on a line that didn't sell. A custom item's box names it by + * `CustomAvatarItemId` rather than `AvatarItemDesc`; ownership is granted straight into + * `inventory_custom` either way. */ interface BulkPurchaseData { GiftPackage: Record | null @@ -1753,6 +1832,40 @@ function toGiftPackage( } } +/** + * {@link toGiftPackage} for the box a custom avatar item comes in — the same twenty keys, + * read off the stored {@link toCustomGiftContent} so the response and `GET + * /api/avatar/v2/gifts` describe one box. + */ +function toCustomGiftPackage( + id: number, + playerId: number, + content: GiftContent +): Record { + return { + Id: id, + PlayerId: playerId, + FromPlayerId: content.FromPlayerId, + ConsumableItemDesc: content.ConsumableItemDesc, + AvatarItemType: content.AvatarItemType, + AvatarItemDesc: content.AvatarItemDesc, + CustomAvatarItemId: content.CustomAvatarItemId, + EquipmentPrefabName: content.EquipmentPrefabName, + EquipmentModificationGuid: content.EquipmentModificationGuid, + CurrencyType: content.CurrencyType, + Currency: content.Currency, + Xp: content.Xp, + GiftContext: content.GiftContext, + GiftRarity: content.GiftRarity, + Message: content.Message, + Signature: null, + IsSignatureValid: false, + Platform: -1, + PlatformsToSpawnOn: -1, + BalanceType: ALL_PLATFORMS, + } +} + /** * Normalise the id a line named its item by. A line that sent no id at all still resolves * to something, so this never returns null — the checks in {@link resolveBulkLine} are what @@ -1927,19 +2040,27 @@ function resolveBulkLine( interface CustomBagContext { buyerId: number items: Map - owned: Set + /** Which of the bag's guids each line's RECEIVER already owns, keyed by receiver. */ + owned: Map> +} + +/** Who a bag line's item goes to: the `Gift` block's `ToPlayerId`, else the buyer. */ +function lineReceiver(gift: GiftRequest | null | undefined, buyerId: number): number { + return Number.isInteger(gift?.ToPlayerId) ? (gift?.ToPlayerId as number) : buyerId } /** * Resolve a guid-keyed line to the custom avatar item it names, or the failure its entry will - * carry. The rules are buyInvention's, per line: the item must exist and be published - * (`Accessibility` 0 is a draft, visible to its creator alone), the buyer must not be its - * creator (who owns it already — and would be paying themself) nor own it already, and the + * carry. The rules are buyInvention's, per line, applied to whoever RECEIVES the item — the + * buyer, or the player a `Gift` block names: the item must exist and be published + * (`Accessibility` 0 is a draft, visible to its creator alone), the receiver must not be its + * creator (who owns it already — and, buying it themself, would be paying themself) nor own + * it already, and the * posted `RequestedPrice` must be the item's `Price` — no subscriber band, since a custom * item has no `SubscriberPrices`. Custom items are priced in RecCenterTokens only, as the * store lists them, so a bag in another currency cannot buy one. Owned once, so a count above - * one is refused as it is for an avatar item; and not giftable, since a custom item is granted - * without a box and a gift here would land on the receiver unannounced. + * one is refused as it is for an avatar item. A creator may GIFT their own item: they pay the + * price and are paid it back, so the receiver gets it at no net cost to anyone. */ function resolveCustomLine( line: PurchaseItemRequest, @@ -1953,14 +2074,19 @@ function resolveCustomLine( if (guid === null || item === undefined || item.Accessibility === 0) { return { method, code: UpdateResponse.NoItemAvailable, error: 'Item not found' } } - if (item.CreatorAccountId === custom.buyerId) { + const gift = typeof line.Gift === 'object' && line.Gift !== null ? line.Gift : null + const receiverId = lineReceiver(gift, custom.buyerId) + if (item.CreatorAccountId === receiverId) { return { method, code: UpdateResponse.PlayerNotEligible, - error: 'Cannot buy your own item', + error: + receiverId === custom.buyerId + ? 'Cannot buy your own item' + : 'The recipient created this item', } } - if (custom.owned.has(guid)) { + if (custom.owned.get(receiverId)?.has(guid) === true) { return { method, code: UpdateResponse.AlreadyOwned, error: 'Already owned' } } if (currencyType !== CurrencyType.RecCenterTokens) { @@ -1977,13 +2103,6 @@ function resolveCustomLine( error: 'This item can only be bought once per line', } } - if (typeof line.Gift === 'object' && line.Gift !== null) { - return { - method, - code: UpdateResponse.PlayerNotEligible, - error: 'Custom avatar items cannot be gifted', - } - } if (line.RequestedPrice !== item.Price) { return { method, @@ -1993,7 +2112,7 @@ function resolveCustomLine( : 'Price has changed', } } - return { kind: 'custom', method, custom: item, price: item.Price, count: 1, gift: null } + return { kind: 'custom', method, custom: item, price: item.Price, count: 1, gift } } /** Whether a resolved line is buyable or is already a failure. */ @@ -4554,9 +4673,10 @@ const app = new Hono({ strict: false }) 'line’s `RequestedPrice` still matches, debits the total in ONE atomic spend, grants', 'what sold, and answers the `{ Success, Error, error_id, Value }` envelope. A custom', 'item is a sale between players: its price is paid to its creator (pushed to them as', - 'a balance update), ownership lands in `inventory_custom` with no gift box, and the', - 'entry’s `CustomAvatarItem` carries the item itself. A custom line fails with', - 'AlreadyOwned on a re-buy, PlayerNotEligible for its own creator or when gifted, and', + 'a balance update), ownership lands in `inventory_custom`, its gift box names it by', + '`CustomAvatarItemId`, and the entry’s `CustomAvatarItem` carries the item itself. A', + 'custom line may be gifted, and fails with AlreadyOwned when its receiver owns it,', + 'PlayerNotEligible when its receiver created it, and', 'NoItemAvailable for a draft, an unknown id or a currency other than RecCenterTokens.', '`Value.Balance` is the RESULTING total (not buyItem’s change) in the', '`Platform` bucket named beside it, and `BalanceUpdates` carries one entry per', @@ -4641,12 +4761,19 @@ const app = new Hono({ strict: false }) // `CustomAvatarItemId` — which resolve against the `custom_avatar_item` table rather than // the catalog. One read for the items the bag names and one for which of them the buyer // already owns, so each line still resolves in memory. - const guids = lines.flatMap((line) => { + // Ownership is checked against each line's RECEIVER, so read it once per distinct + // receiver of a guid line — the buyer, plus anyone a custom line gifts to. + const guidLines = lines.flatMap((line) => { const method = toPurchaseMethodId(line.ItemPurchaseMethodId) return method.Type === PURCHASE_METHOD_TYPE_GUID && method.Guid !== null - ? [method.Guid.toLowerCase()] + ? [{ guid: method.Guid.toLowerCase(), receiverId: lineReceiver(line.Gift, id) }] : [] }) + const guids = guidLines.map((line) => line.guid) + const owned = new Map>() + for (const receiverId of new Set(guidLines.map((line) => line.receiverId))) { + owned.set(receiverId, await ownedCustomAvatarItemIds(c.env.DB, receiverId, guids)) + } const custom: CustomBagContext = { buyerId: id, items: new Map( @@ -4655,7 +4782,7 @@ const app = new Hono({ strict: false }) item, ]) ), - owned: await ownedCustomAvatarItemIds(c.env.DB, id, guids), + owned, } const subscriber = await isSubscriber(c) const resolved = lines.map((line) => @@ -4733,6 +4860,18 @@ const app = new Hono({ strict: false }) // Grant what sold, keeping each line's box so the entry built below can carry it. const packages = new Map | null>() for (const line of affordable) { + // Same routing as buyItem, for both kinds of line: a Gift block sends the item (and + // its box) to another 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 + // 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 = lineReceiver(gift, id) + const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID + const message = giftMessage(gift) + const giftContext = Number.isInteger(gift?.GiftContext) + ? (gift?.GiftContext as number) + : null if (line.kind === 'custom') { // A custom item is a SALE between players, settled the way buyInvention settles // one: the buyer's share of the bag was debited above, so record ownership first @@ -4745,7 +4884,7 @@ const app = new Hono({ strict: false }) // different, probably-online player with no response to read, so the sale is // pushed as a plain update carrying their RESULTING total (what `creditCurrency` // returns) — sending the payout would set their whole balance to it. - await grantCustomAvatarItem(c.env.DB, id, line.custom.CustomAvatarItemId) + await grantCustomAvatarItem(c.env.DB, receiverId, line.custom.CustomAvatarItemId) if (line.price > 0) { const creatorId = line.custom.CreatorAccountId await ensureStartingBalances(c.env.DB, creatorId, startingTokens) @@ -4758,25 +4897,23 @@ const app = new Hono({ strict: false }) ) await pushBalanceUpdate(c, creatorId, currencyType as number, creatorBalance) } - // No box: the item is in the buyer's inventory outright, and the entry below - // carries the item itself for the client to render. - packages.set(line, null) + // Wrapped like any purchase: a box naming the item by `CustomAvatarItemId`, which + // the client opens to show what was bought — in the receiver's gifts, announced to + // them when that is someone else, as a catalog line's is. Ownership was granted + // above, so the box is cosmetic and `BypassGiftPackages` skips it exactly as it + // does a catalog line's. The entry below also carries the item record itself. + if (skipGiftBox) { + packages.set(line, null) + continue + } + const content = toCustomGiftContent(line.custom, message, fromPlayerId, giftContext) + const box = await createGift(c.env.DB, receiverId, content) + if (receiverId !== id) { + await pushCustomGiftReceived(c, receiverId, box.id, content) + } + packages.set(line, toCustomGiftPackage(box.id, receiverId, content)) continue } - // Same routing as buyItem: a Gift block sends the item (and its box) to another - // 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 - // 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 = giftMessage(gift) - 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). diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index fe46801..dd5624e 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -367,7 +367,8 @@ export const BulkPurchaseResponse = z.object({ ), Data: z.object({ GiftPackage: JsonObject.nullable().describe( - 'The box created for this line (20 keys). Null on a line that didn’t sell, ' + + 'The box created for this line (20 keys; a custom item’s names it by ' + + '`CustomAvatarItemId`). Null on a line that didn’t sell, ' + 'and under `BypassGiftPackages` — the item is granted either way' ), PurchasableItemId: z.int().nullable().describe('The catalog item this line named'), diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 0fd1be9..927c93d 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -74,7 +74,11 @@ import { CHALLENGE_GIFT_SCHEMA_DDL, CHALLENGE_STATUS_SCHEMA_DDL } from '../../ch import { buildRotation, rotationIndex, withWeeklyGift } from '../../challenge-rotation' import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db' import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db' -import { grantCustomAvatarItem, INVENTORY_CUSTOM_SCHEMA_DDL } from '../../inventory-custom-db' +import { + grantCustomAvatarItem, + INVENTORY_CUSTOM_SCHEMA_DDL, + ownedCustomAvatarItemIds, +} from '../../inventory-custom-db' import { INVENTORY_SCHEMA_DDL } from '../../inventory-db' import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db' import { ROOM_CONSUMABLE_SCHEMA_DDL, ROOM_INVENTORY_SCHEMA_DDL } from '../../room-consumable-db' @@ -3542,7 +3546,8 @@ describe('econ endpoints', () => { // A line whose `ItemPurchaseMethodId` is a `Guid` (Type 1) names a custom avatar item by its // `CustomAvatarItemId`, resolved against the `custom_avatar_item` table rather than the // catalog. It is a SALE between players: the price leaves the buyer and lands on the - // creator, ownership lands in `inventory_custom`, and there is no gift box. + // creator, ownership lands in `inventory_custom`, and the gift box names it by + // `CustomAvatarItemId`. /** A guid-keyed bag line, in the shape the client posts one. */ const customLine = ( @@ -3594,11 +3599,31 @@ describe('econ endpoints', () => { expect(value.Platform).toBe(-2) expect(codes(body)).toEqual([0]) // The entry names the item rather than a catalog id, carries the WHOLE record for the - // client to render, and has no box: the item went straight into the inventory. - expect(value.BalanceUpdates[0].Data).toEqual({ - GiftPackage: null, - PurchasableItemId: null, - CustomAvatarItem: { ...item, PurchaseInfo: null }, + // client to render, and a box that holds the item by `CustomAvatarItemId`. + const data = value.BalanceUpdates[0].Data + expect(data.PurchasableItemId).toBe(null) + expect(data.CustomAvatarItem).toEqual({ ...item, PurchaseInfo: null }) + expect(data.GiftPackage).toEqual({ + Id: expect.any(Number), + PlayerId: 620, + FromPlayerId: 1, + ConsumableItemDesc: '', + AvatarItemType: 0, + AvatarItemDesc: '', + CustomAvatarItemId: item.CustomAvatarItemId, + EquipmentPrefabName: '', + EquipmentModificationGuid: '', + CurrencyType: 0, + Currency: 0, + Xp: 0, + GiftContext: 0, + GiftRarity: 0, + Message: expect.any(String), + Signature: null, + IsSignatureValid: false, + Platform: -1, + PlatformsToSpawnOn: -1, + BalanceType: -2, }) // The money moved both ways. @@ -3625,7 +3650,7 @@ describe('econ endpoints', () => { }, ]) - // Owned now: the item shows on the buyer's owned list, and no gift box was left behind. + // Owned now: the item shows on the buyer's owned list, and its box waits to be opened. const owned = await exports.default.fetch(`${ORIGIN}/econ/customAvatarItems/v1/owned`, { headers: await bearer('620'), }) @@ -3636,7 +3661,13 @@ describe('econ endpoints', () => { const gifts = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, { headers: await bearer('620'), }) - expect(await gifts.json()).toEqual([]) + const pending = (await gifts.json()) as Array> + expect(pending).toHaveLength(1) + expect(pending[0]).toMatchObject({ + Id: (data.GiftPackage as { Id: number }).Id, + CustomAvatarItemId: item.CustomAvatarItemId, + AvatarItemDesc: '', + }) // Buying it again is refused as AlreadyOwned and charges nothing. const again = await bulkPurchase('620', { @@ -3649,6 +3680,137 @@ describe('econ endpoints', () => { expect(await tokens(621)).toBe(DEFAULT_STARTING_TOKENS + 900) }) + test('POST /api/items/bulkpurchase gifts a custom avatar item: receiver owns it and gets the box', async () => { + // Buyer 630 pays, creator 631 is paid, receiver 632 owns it and is handed the box. + await seedAccount(632, 'CaiGiftReceiver') + const item = await customItem(631, 200) + // The receiver already owning it refuses the gift, not the buyer owning it. + const alreadyThere = await customItem(631, 50) + await grantCustomAvatarItem(env.DB, 632, alreadyThere.CustomAvatarItemId) + await drainFrames() + const gift = { ToPlayerId: 632, Anonymous: false, Message: 'for you', GiftContext: 3 } + const res = await bulkPurchase('630', { + PurchaseItemRequests: [ + customLine(item.CustomAvatarItemId, 200, { Gift: gift }), + customLine(alreadyThere.CustomAvatarItemId, 50, { Gift: gift }), + ], + }) + const body = (await res.json()) as BulkBody + expect(body.Success).toBe(true) + expect(codes(body)).toEqual([0, 3]) + expect(body.Value!.Balance).toBe(DEFAULT_STARTING_TOKENS - 200) + const box = body.Value!.BalanceUpdates[0].Data.GiftPackage! + expect(box).toMatchObject({ + PlayerId: 632, + FromPlayerId: 630, + CustomAvatarItemId: item.CustomAvatarItemId, + GiftContext: 3, + Message: 'for you', + }) + expect(await tokens(631)).toBe(DEFAULT_STARTING_TOKENS + 200) + + // Ownership and the box are the receiver's, not the buyer's. + expect(await ownedCustomAvatarItemIds(env.DB, 632, [item.CustomAvatarItemId])).toEqual( + new Set([item.CustomAvatarItemId.toLowerCase()]) + ) + expect(await ownedCustomAvatarItemIds(env.DB, 630, [item.CustomAvatarItemId])).toEqual( + new Set() + ) + const pending = async (account: string) => + (await ( + await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, { + headers: await bearer(account), + }) + ).json()) as Array> + expect(await pending('630')).toEqual([]) + expect(await pending('632')).toEqual([ + expect.objectContaining({ Id: box.Id, CustomAvatarItemId: item.CustomAvatarItemId }), + ]) + + // Exactly three frames: the creator's payout, the receiver's box — told as a catalog + // gift's is, the box naming the item — and the buyer's one purchase frame for the bag. + expect(await drainFrames()).toEqual([ + { + accountId: 631, + notificationType: NotificationType.StorefrontBalanceUpdate, + payload: { Balance: DEFAULT_STARTING_TOKENS + 200, CurrencyType: 2, Platform: -2 }, + }, + { + accountId: 632, + notificationType: NotificationType.GiftPackageReceivedImmediate, + payload: { + Id: box.Id, + FromGiftDropId: 0, + FromPlayerId: 630, + ConsumableItemDesc: '', + AvatarItemDesc: '', + AvatarItemType: 0, + CustomAvatarItemId: item.CustomAvatarItemId, + EquipmentPrefabName: '', + EquipmentModificationGuid: '', + CurrencyType: 0, + Currency: 0, + Xp: 0, + Level: 0, + Platform: -1, + PlatformsToSpawnOn: -1, + BalanceType: -2, + GiftContext: 3, + GiftRarity: 0, + Message: 'for you', + }, + }, + { + accountId: 630, + notificationType: NotificationType.StorefrontBalancePurchase, + payload: { + BalanceAddType: 1400, + Delta: -200, + Balance: DEFAULT_STARTING_TOKENS - 200, + Platform: -2, + CurrencyType: 2, + }, + }, + ]) + }) + + test('POST /api/items/bulkpurchase lets a creator gift their own custom item', async () => { + await seedAccount(634, 'CaiCreatorGiftReceiver') + const item = await customItem(633, 150) + const res = await bulkPurchase('633', { + PurchaseItemRequests: [ + customLine(item.CustomAvatarItemId, 150, { + Gift: { ToPlayerId: 634, Anonymous: true }, + }), + ], + }) + const body = (await res.json()) as BulkBody + expect(codes(body)).toEqual([0]) + // Anonymous: from Coach. The creator paid themself, so their balance is back where it was. + expect(body.Value!.BalanceUpdates[0].Data.GiftPackage).toMatchObject({ + PlayerId: 634, + FromPlayerId: 1, + }) + expect(await tokens(633)).toBe(DEFAULT_STARTING_TOKENS) + const owned = await ownedCustomAvatarItemIds(env.DB, 634, [item.CustomAvatarItemId]) + expect(owned.size).toBe(1) + }) + + test('POST /api/items/bulkpurchase skips a custom item’s box under BypassGiftPackages', async () => { + const item = await customItem(629, 100) + const res = await bulkPurchase('628', { + PurchaseItemRequests: [customLine(item.CustomAvatarItemId, 100)], + BypassGiftPackages: true, + }) + const body = (await res.json()) as BulkBody + expect(codes(body)).toEqual([0]) + expect(body.Value!.BalanceUpdates[0].Data.GiftPackage).toBe(null) + const gifts = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, { + headers: await bearer('628'), + }) + expect(await gifts.json()).toEqual([]) + }) + test('POST /api/items/bulkpurchase mixes a custom item into a catalog bag, matching the guid case-insensitively', async () => { // The exact bag the client posts from the store: storefront 3, tokens, partial success // on. One catalog line and one custom line, the guid upper-cased as the client sometimes @@ -3689,9 +3851,9 @@ describe('econ endpoints', () => { customLine(priced.CustomAvatarItemId, 200), // Owned once: no stacking. customLine(priced.CustomAvatarItemId, 300, { DuplicateItemCount: 2 }), - // Not giftable: a custom item comes without a box to announce it with. + // Gifted to its own creator, who owns it already. customLine(priced.CustomAvatarItemId, 300, { - Gift: { ToPlayerId: 626, Anonymous: false, Message: 'hi' }, + Gift: { ToPlayerId: 625, Anonymous: false, Message: 'hi' }, }), // And the one good line, which the partial bag still buys. customLine(priced.CustomAvatarItemId, 300), diff --git a/packages/domain/src/gifts-db.ts b/packages/domain/src/gifts-db.ts index 1f36cad..94761c4 100644 --- a/packages/domain/src/gifts-db.ts +++ b/packages/domain/src/gifts-db.ts @@ -54,6 +54,11 @@ export interface GiftContent extends Record { ConsumablePreExistingCount?: number AvatarItemDesc: string AvatarItemType: number | null + /** + * The custom avatar item the box holds — set, in place of `AvatarItemDesc`, on the box a + * bulk purchase mints for a guid-keyed line. Absent on every other box. + */ + CustomAvatarItemId?: string | null CurrencyType: number Currency: number Xp: number