[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
+7 -2
View File
@@ -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 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 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 `StorefrontBalanceUpdate` carrying their resulting total), ownership is a row in
`inventory_custom`, and the entry's `Data.CustomAvatarItem` is the whole item record with `inventory_custom`, and the entry's `Data.CustomAvatarItem` is the whole item record. The item
`GiftPackage` null — no box is minted. `GET /econ/customAvatarItems/v1/owned` is that table 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 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. 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 - A chat report (`api`: `POST /api/chatreport/createChatReport`) is form-encoded and sends
+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 * Push a GiftPackageReceivedImmediate notification for a gift box the player didn't ask
* for, mirroring the reference's * for, mirroring the reference's
@@ -1164,36 +1198,80 @@ async function pushGiftReceived(
message: string, message: string,
fromPlayerId: number, fromPlayerId: number,
giftContext: number | null = null 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> { ): Promise<void> {
try { try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
accountId, accountId,
NotificationType.GiftPackageReceivedImmediate, NotificationType.GiftPackageReceivedImmediate,
{ payload
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,
}
) )
} catch (err) { } catch (err) {
logger.error('failed to push GiftPackageReceivedImmediate notification', { logger.error('failed to push GiftPackageReceivedImmediate notification', {
accountId, accountId,
giftId: gift.id, giftId: payload.Id,
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
}) })
} }
@@ -1685,7 +1763,7 @@ interface BulkCustomLine {
custom: CustomAvatarItem custom: CustomAvatarItem
price: number price: number
count: 1 count: 1
gift: null gift: GiftRequest | null
} }
/** A line that resolved to something buyable. */ /** 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 * `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 * 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 * and `GiftPackage` are null on a line that didn't sell. A custom item's box names it by
* box — it is granted straight into `inventory_custom` rather than wrapped. * `CustomAvatarItemId` rather than `AvatarItemDesc`; ownership is granted straight into
* `inventory_custom` either way.
*/ */
interface BulkPurchaseData { interface BulkPurchaseData {
GiftPackage: Record<string, unknown> | null 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 * 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 * to something, so this never returns null — the checks in {@link resolveBulkLine} are what
@@ -1927,19 +2040,27 @@ function resolveBulkLine(
interface CustomBagContext { interface CustomBagContext {
buyerId: number buyerId: number
items: Map<string, CustomAvatarItem> 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 * 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 * carry. The rules are buyInvention's, per line, applied to whoever RECEIVES the item — the
* (`Accessibility` 0 is a draft, visible to its creator alone), the buyer must not be its * buyer, or the player a `Gift` block names: the item must exist and be published
* creator (who owns it already — and would be paying themself) nor own it already, and the * (`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 * 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 * 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 * 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 * one is refused as it is for an avatar item. A creator may GIFT their own item: they pay the
* without a box and a gift here would land on the receiver unannounced. * price and are paid it back, so the receiver gets it at no net cost to anyone.
*/ */
function resolveCustomLine( function resolveCustomLine(
line: PurchaseItemRequest, line: PurchaseItemRequest,
@@ -1953,14 +2074,19 @@ function resolveCustomLine(
if (guid === null || item === undefined || item.Accessibility === 0) { if (guid === null || item === undefined || item.Accessibility === 0) {
return { method, code: UpdateResponse.NoItemAvailable, error: 'Item not found' } 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 { return {
method, method,
code: UpdateResponse.PlayerNotEligible, 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' } return { method, code: UpdateResponse.AlreadyOwned, error: 'Already owned' }
} }
if (currencyType !== CurrencyType.RecCenterTokens) { if (currencyType !== CurrencyType.RecCenterTokens) {
@@ -1977,13 +2103,6 @@ function resolveCustomLine(
error: 'This item can only be bought once per line', 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) { if (line.RequestedPrice !== item.Price) {
return { return {
method, method,
@@ -1993,7 +2112,7 @@ function resolveCustomLine(
: 'Price has changed', : '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. */ /** 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', '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', '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', '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', 'a balance update), ownership lands in `inventory_custom`, its gift box names it by',
'entry’s `CustomAvatarItem` carries the item itself. A custom line fails with', '`CustomAvatarItemId`, and the entry’s `CustomAvatarItem` carries the item itself. A',
'AlreadyOwned on a re-buy, PlayerNotEligible for its own creator or when gifted, and', '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.', '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', '`Value.Balance` is the RESULTING total (not buyItem’s change) in the',
'`Platform` bucket named beside it, and `BalanceUpdates` carries one entry per', '`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 // `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 // 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. // 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) const method = toPurchaseMethodId(line.ItemPurchaseMethodId)
return method.Type === PURCHASE_METHOD_TYPE_GUID && method.Guid !== null 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 = { const custom: CustomBagContext = {
buyerId: id, buyerId: id,
items: new Map( items: new Map(
@@ -4655,7 +4782,7 @@ const app = new Hono<App>({ strict: false })
item, item,
]) ])
), ),
owned: await ownedCustomAvatarItemIds(c.env.DB, id, guids), owned,
} }
const subscriber = await isSubscriber(c) const subscriber = await isSubscriber(c)
const resolved = lines.map((line) => 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. // 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>() const packages = new Map<BulkPurchaseLine, Record<string, unknown> | null>()
for (const line of affordable) { 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') { if (line.kind === 'custom') {
// A custom item is a SALE between players, settled the way buyInvention settles // 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 // 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 // different, probably-online player with no response to read, so the sale is
// pushed as a plain update carrying their RESULTING total (what `creditCurrency` // pushed as a plain update carrying their RESULTING total (what `creditCurrency`
// returns) — sending the payout would set their whole balance to it. // 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) { if (line.price > 0) {
const creatorId = line.custom.CreatorAccountId const creatorId = line.custom.CreatorAccountId
await ensureStartingBalances(c.env.DB, creatorId, startingTokens) 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) await pushBalanceUpdate(c, creatorId, currencyType as number, creatorBalance)
} }
// No box: the item is in the buyer's inventory outright, and the entry below // Wrapped like any purchase: a box naming the item by `CustomAvatarItemId`, which
// carries the item itself for the client to render. // the client opens to show what was bought — in the receiver's gifts, announced to
packages.set(line, null) // 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 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 // 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 // `GiftPackage` per entry, and only a consumable can be asked for more than once
// (`resolveBulkLine` refuses a bigger count on anything owned 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({ Data: z.object({
GiftPackage: JsonObject.nullable().describe( 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' 'and under `BypassGiftPackages` — the item is granted either way'
), ),
PurchasableItemId: z.int().nullable().describe('The catalog item this line named'), 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 { buildRotation, rotationIndex, withWeeklyGift } from '../../challenge-rotation'
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db' import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-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 { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db' import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
import { ROOM_CONSUMABLE_SCHEMA_DDL, ROOM_INVENTORY_SCHEMA_DDL } from '../../room-consumable-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 // 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 // `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 // 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. */ /** A guid-keyed bag line, in the shape the client posts one. */
const customLine = ( const customLine = (
@@ -3594,11 +3599,31 @@ describe('econ endpoints', () => {
expect(value.Platform).toBe(-2) expect(value.Platform).toBe(-2)
expect(codes(body)).toEqual([0]) expect(codes(body)).toEqual([0])
// The entry names the item rather than a catalog id, carries the WHOLE record for the // 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. // client to render, and a box that holds the item by `CustomAvatarItemId`.
expect(value.BalanceUpdates[0].Data).toEqual({ const data = value.BalanceUpdates[0].Data
GiftPackage: null, expect(data.PurchasableItemId).toBe(null)
PurchasableItemId: null, expect(data.CustomAvatarItem).toEqual({ ...item, PurchaseInfo: null })
CustomAvatarItem: { ...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. // 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`, { const owned = await exports.default.fetch(`${ORIGIN}/econ/customAvatarItems/v1/owned`, {
headers: await bearer('620'), headers: await bearer('620'),
}) })
@@ -3636,7 +3661,13 @@ describe('econ endpoints', () => {
const gifts = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, { const gifts = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
headers: await bearer('620'), 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. // Buying it again is refused as AlreadyOwned and charges nothing.
const again = await bulkPurchase('620', { const again = await bulkPurchase('620', {
@@ -3649,6 +3680,137 @@ describe('econ endpoints', () => {
expect(await tokens(621)).toBe(DEFAULT_STARTING_TOKENS + 900) 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 () => { 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 // 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 // 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), customLine(priced.CustomAvatarItemId, 200),
// Owned once: no stacking. // Owned once: no stacking.
customLine(priced.CustomAvatarItemId, 300, { DuplicateItemCount: 2 }), 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, { 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. // And the one good line, which the partial bag still buys.
customLine(priced.CustomAvatarItemId, 300), customLine(priced.CustomAvatarItemId, 300),
+5
View File
@@ -54,6 +54,11 @@ export interface GiftContent extends Record<string, unknown> {
ConsumablePreExistingCount?: number ConsumablePreExistingCount?: number
AvatarItemDesc: string AvatarItemDesc: string
AvatarItemType: number | null 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 CurrencyType: number
Currency: number Currency: number
Xp: number Xp: number