[econ] fix custom gift box not appearing (maybe)

This commit is contained in:
Devin Zuczek
2026-09-21 15:28:07 -04:00
parent 4e070a59bd
commit ed1a9f4714
5 changed files with 389 additions and 79 deletions
+202 -65
View File
@@ -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<void> {
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<App>,
accountId: number,
giftId: number,
content: GiftContent
): Promise<void> {
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<App>,
accountId: number,
payload: Record<string, unknown> & { Id: number }
): Promise<void> {
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<string, unknown> | 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<string, unknown> {
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<string, CustomAvatarItem>
owned: Set<string>
/** Which of the bag's guids each line's RECEIVER already owns, keyed by receiver. */
owned: Map<number, Set<string>>
}
/** 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<App>({ 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<App>({ 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<number, Set<string>>()
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<App>({ 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<App>({ strict: false })
// Grant what sold, keeping each line's box so the entry built below can carry it.
const packages = new Map<BulkPurchaseLine, Record<string, unknown> | 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<App>({ 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<App>({ 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).
+2 -1
View File
@@ -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'),
+173 -11
View File
@@ -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<Record<string, unknown>>
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<Record<string, unknown>>
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),