From 3e49ebb0ecb39b6f06833279069fbbdcd308b38d Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 15 Sep 2026 21:38:15 -0400 Subject: [PATCH] [econ] custom item purchases --- CLAUDE.md | 9 + apps/api/src/custom-avatar-items-db.ts | 12 +- .../econ/migrations/0023_inventory_custom.sql | 20 ++ apps/econ/src/econ.app.ts | 236 +++++++++++++--- apps/econ/src/inventory-custom-db.ts | 98 +++++++ apps/econ/src/openapi.ts | 23 +- apps/econ/src/test/integration/api.test.ts | 257 +++++++++++++++++- 7 files changed, 610 insertions(+), 45 deletions(-) create mode 100644 apps/econ/migrations/0023_inventory_custom.sql create mode 100644 apps/econ/src/inventory-custom-db.ts diff --git a/CLAUDE.md b/CLAUDE.md index 05a12fa..2bf07fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,6 +207,15 @@ inconsistency here without checking the client first. empty `CurrentSaves`/`Tags` and null `CustomBadgeMetadata`, never a missing key. The saves name their assetbundles and thumbnails by bare filename; storing the record does nothing about serving those files. +- Buying a custom avatar item goes through the ordinary bag (`econ`: `POST /api/items/bulkpurchase`) + as a line whose `ItemPurchaseMethodId` is `{ Type: 1, Guid: }`, beside + 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 + 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. - 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/api/src/custom-avatar-items-db.ts b/apps/api/src/custom-avatar-items-db.ts index d3ca29b..bb33c9e 100644 --- a/apps/api/src/custom-avatar-items-db.ts +++ b/apps/api/src/custom-avatar-items-db.ts @@ -163,11 +163,19 @@ const DTO_DEFAULTS = { PurchaseInfo: null, } as const -function toDto(row: Row): CustomAvatarItem { - const stored = JSON.parse(row.data) as CustomAvatarItem +/** + * A stored row's JSON as the client's record. Exported for the `econ` worker's owned-items + * read, which joins `inventory_custom` to this table and must serve the same shape. + */ +export function parseCustomAvatarItem(data: string): CustomAvatarItem { + const stored = JSON.parse(data) as CustomAvatarItem return { ...DTO_DEFAULTS, ...stored, RankingContext: null, PurchaseInfo: null } } +function toDto(row: Row): CustomAvatarItem { + return parseCustomAvatarItem(row.data) +} + /** Inserts a new player-made custom avatar item and returns it as the client's DTO. */ export async function createCustomAvatarItem( db: D1Database, diff --git a/apps/econ/migrations/0023_inventory_custom.sql b/apps/econ/migrations/0023_inventory_custom.sql new file mode 100644 index 0000000..dc6ce69 --- /dev/null +++ b/apps/econ/migrations/0023_inventory_custom.sql @@ -0,0 +1,20 @@ +-- Owned CUSTOM avatar items, owned by the `econ` worker. One row per (account, item): the +-- custom avatar items a player has bought through `POST /api/items/bulkpurchase` (a line whose +-- `ItemPurchaseMethodId` is a `Guid`, the item's `CustomAvatarItemId`) and read back by +-- `GET /econ/customAvatarItems/v1/owned`, which serves each owned item's full record. +-- +-- Only the id is stored: the item itself lives in `custom_avatar_item`, whose schema the `api` +-- worker owns (apps/api/migrations/0022_custom_avatar_item_json.sql) on this same database, +-- and copying its record here would leave two rows to keep in step — the same split as +-- `inventory_invention` (0008). Ownership is boolean, so the pair is the primary key and a +-- second purchase is refused rather than charged twice. The creator is NOT listed here: they +-- own their item through `CreatorAccountId`, and the buy path refuses to sell them their own. +-- +-- Kept in sync with INVENTORY_CUSTOM_SCHEMA_DDL in src/inventory-custom-db.ts. + +CREATE TABLE IF NOT EXISTS inventory_custom ( + account_id INTEGER NOT NULL, + custom_avatar_item_id TEXT NOT NULL, + acquired_at TEXT NOT NULL, + PRIMARY KEY (account_id, custom_avatar_item_id) + ); diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index d976d0c..7e2c7db 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -75,6 +75,11 @@ import { grantConsumable, } from './consumables-db' import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db' +import { + getOwnedCustomAvatarItems, + grantCustomAvatarItem, + ownedCustomAvatarItemIds, +} from './inventory-custom-db' import { getInventory, grantItem, toAvatarItemV4 } from './inventory-db' import { AUTHED, @@ -1656,8 +1661,9 @@ interface BulkLineFailure { error: string } -/** A line that resolved to something buyable, with the catalog's own price. */ -interface BulkPurchaseLine { +/** A line that resolved to a catalog item, with the catalog's own price. */ +interface BulkCatalogLine { + kind: 'catalog' method: PurchaseMethodId item: StoreItem /** The UNIT price from the catalog — `count` copies cost `price * count`. */ @@ -1666,19 +1672,37 @@ interface BulkPurchaseLine { gift: GiftRequest | null } +/** + * A line that resolved to a CUSTOM avatar item — a guid-keyed line, the `Guid` being the + * item's `CustomAvatarItemId` — at the item's own `Price`. Owned once, so `count` is one; the + * price goes to the item's creator rather than nowhere, which is what makes it a sale. + */ +interface BulkCustomLine { + kind: 'custom' + method: PurchaseMethodId + custom: CustomAvatarItem + price: number + count: 1 + gift: null +} + +/** A line that resolved to something buyable. */ +type BulkPurchaseLine = BulkCatalogLine | BulkCustomLine + /** * One `BalanceUpdates[].Data` — what a single requested item turned into. Unlike buyItem's, * it NAMES the purchase rather than describing the drop: the client already has the catalog * entry for `PurchasableItemId`, so the only thing it can't reconstruct is the box. * - * `CustomAvatarItem` is the UGC counterpart of `PurchasableItemId`, and both it and - * `GiftPackage` are null on a line that didn't sell. Nothing here fills `CustomAvatarItem`: - * no catalog we serve sells guid-keyed items. + * `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. */ interface BulkPurchaseData { GiftPackage: Record | null PurchasableItemId: number | null - CustomAvatarItem: null + CustomAvatarItem: CustomAvatarItem | null } /** `ItemPurchaseMethodId.Type` for a numeric (storefront `PurchasableItemId`) id. */ @@ -1819,18 +1843,10 @@ function resolveBulkLine( line: PurchaseItemRequest, storefront: Storefront | null, currencyType: number, - subscriber: boolean + subscriber: boolean, + custom: CustomBagContext ): BulkPurchaseLine | BulkLineFailure { const method = toPurchaseMethodId(line.ItemPurchaseMethodId) - // Guid-keyed ids name UGC / custom avatar items, which no catalog here sells. Failing the - // line (rather than the request) is what lets a bag of ordinary items still go through. - if (method.Type !== PURCHASE_METHOD_NUMBER_ID || method.NumberId === null) { - return { - method, - code: UpdateResponse.NoItemAvailable, - error: 'Only numeric storefront item ids can be bought', - } - } // Nothing issues coupons, so a line claiming one would otherwise be charged full price // for a discount it thinks it applied. if ( @@ -1851,6 +1867,18 @@ function resolveBulkLine( error: 'DuplicateItemCount must be a positive integer', } } + // A guid-keyed line names a CUSTOM avatar item, resolved against the `custom_avatar_item` + // table rather than the bag's catalog. + if (method.Type === PURCHASE_METHOD_TYPE_GUID) { + return resolveCustomLine(line, method, count, currencyType, custom) + } + if (method.Type !== PURCHASE_METHOD_NUMBER_ID || method.NumberId === null) { + return { + method, + code: UpdateResponse.NoItemAvailable, + error: 'Only storefront item ids and custom avatar item guids can be bought', + } + } if (storefront === null) { return { method, code: UpdateResponse.NoItemAvailable, error: 'No such storefront' } } @@ -1886,12 +1914,89 @@ function resolveBulkLine( } } const gift = typeof line.Gift === 'object' && line.Gift !== null ? line.Gift : null - return { method, item, price: checked.charge, count, gift } + return { kind: 'catalog', method, item, price: checked.charge, count, gift } +} + +/** + * What a bag's guid-keyed lines resolve against, read ONCE for the bag: the custom avatar + * items it names (keyed by lowercased id — a GUID's case is not part of its identity, and the + * client is not consistent about it) and which of them the buyer already owns. + */ +interface CustomBagContext { + buyerId: number + items: Map + owned: Set +} + +/** + * 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 + * 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. + */ +function resolveCustomLine( + line: PurchaseItemRequest, + method: PurchaseMethodId, + count: number, + currencyType: number, + custom: CustomBagContext +): BulkCustomLine | BulkLineFailure { + const guid = method.Guid?.toLowerCase() ?? null + const item = guid === null ? undefined : custom.items.get(guid) + if (guid === null || item === undefined || item.Accessibility === 0) { + return { method, code: UpdateResponse.NoItemAvailable, error: 'Item not found' } + } + if (item.CreatorAccountId === custom.buyerId) { + return { + method, + code: UpdateResponse.PlayerNotEligible, + error: 'Cannot buy your own item', + } + } + if (custom.owned.has(guid)) { + return { method, code: UpdateResponse.AlreadyOwned, error: 'Already owned' } + } + if (currencyType !== CurrencyType.RecCenterTokens) { + return { + method, + code: UpdateResponse.NoItemAvailable, + error: 'Currency type not available for this item', + } + } + if (count !== 1) { + return { + method, + code: UpdateResponse.RequestedAmountNotAllowed, + 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, + code: UpdateResponse.RequestedPriceDoesNotMatch, + error: !Number.isInteger(line.RequestedPrice) + ? 'RequestedPrice is required' + : 'Price has changed', + } + } + return { kind: 'custom', method, custom: item, price: item.Price, count: 1, gift: null } } /** Whether a resolved line is buyable or is already a failure. */ function isBulkLine(resolved: BulkPurchaseLine | BulkLineFailure): resolved is BulkPurchaseLine { - return 'item' in resolved + return 'kind' in resolved } /** @@ -2540,29 +2645,34 @@ const app = new Hono({ strict: false }) } ) - // The player's owned custom avatar items. [Authorize]; paginated. Empty stub for - // now (no DB binding). The client downloads these when custom-item creation is - // allowed; a 404 here surfaces as "Failed to download unlocked avatar items". + // The player's owned custom avatar items. [Authorize]; the paginated envelope. What the + // caller has BOUGHT through bulkpurchase (`inventory_custom`) plus what they CREATED, each + // the whole `CustomAvatarItem` record out of the item table. The client downloads these + // when custom-item creation is allowed; a 404 here surfaces as "Failed to download + // unlocked avatar items". .get( '/econ/customAvatarItems/v1/owned', describeRoute({ tags: ['Avatar'], summary: 'Owned custom avatar items', description: [ - 'Paginated owned custom items. Empty stub for now. The client requests this when', - 'custom-item creation is allowed; a 404 shows as “Failed to download unlocked', - 'avatar items”.', + 'The custom avatar items the caller owns — bought (`POST /api/items/bulkpurchase` with', + 'a guid-keyed line) or created (`CreatorAccountId`, drafts included) — each as its full', + '`CustomAvatarItem` record out of the `custom_avatar_item` table, oldest first by when', + 'it became theirs, in the `{ Results, TotalResults }` envelope. No paging is applied', + '(the client sends none), so `TotalResults` is the list’s length.', ].join(' '), security: AUTHED, responses: { - 200: json(CustomAvatarItemsResponse, 'Paginated results (empty for now)'), + 200: json(CustomAvatarItemsResponse, 'The owned items'), 401: UNAUTHORIZED_RESPONSE, }, }), async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - return c.json({ Results: [], TotalResults: 0 }) + const Results = await getOwnedCustomAvatarItems(c.env.DB, id) + return c.json({ Results, TotalResults: Results.length }) } ) @@ -4428,9 +4538,16 @@ const app = new Hono({ strict: false }) summary: 'Buy a bag of storefront items', description: [ 'Resolves every line against the bag’s storefront catalog (one read for the whole', - 'bag), confirms each line’s `RequestedPrice` still matches, debits the total in ONE', - 'atomic spend, grants what sold, and answers the `{ Success, Error, error_id, Value }`', - 'envelope. `Value.Balance` is the RESULTING total (not buyItem’s change) in the', + 'bag) — or, for a line whose `ItemPurchaseMethodId` is a `Guid` (Type 1), against the', + '`custom_avatar_item` table, the guid being a `CustomAvatarItemId` — confirms each', + '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', + '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', 'REQUESTED item, each with its own `UpdateResponse`. `AllowPartialSuccess` lets some', 'of those be non-OK while `Success` stays true; without it a single bad line refuses', @@ -4509,9 +4626,29 @@ const app = new Hono({ strict: false }) catalogItems.length === 0 ? storefront : { StoreItems: [...(storefront?.StoreItems ?? []), ...catalogItems] } + // The bag may also name CUSTOM avatar items — a line whose id is a `Guid`, the item's + // `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) => { + const method = toPurchaseMethodId(line.ItemPurchaseMethodId) + return method.Type === PURCHASE_METHOD_TYPE_GUID && method.Guid !== null + ? [method.Guid.toLowerCase()] + : [] + }) + const custom: CustomBagContext = { + buyerId: id, + items: new Map( + (await getCustomAvatarItems(c.env.DB, guids)).map((item) => [ + item.CustomAvatarItemId.toLowerCase(), + item, + ]) + ), + owned: await ownedCustomAvatarItemIds(c.env.DB, id, guids), + } const subscriber = await isSubscriber(c) const resolved = lines.map((line) => - resolveBulkLine(line, bagCatalog, currencyType as number, subscriber) + resolveBulkLine(line, bagCatalog, currencyType as number, subscriber, custom) ) const buyable = resolved.filter(isBulkLine) @@ -4576,13 +4713,45 @@ const app = new Hono({ strict: false }) // A query drop (a loot box) rolls against sf3, the big catalog. Read it ONCE for the // whole bag and only when a line actually holds one — a bag of ordinary items should // not pull a thousand-item catalog in to grant them. - const rollCatalog = affordable.some((line) => line.item.GiftDrop.IsQuery === true) + const rollCatalog = affordable.some( + (line) => line.kind === 'catalog' && line.item.GiftDrop.IsQuery === true + ) ? await loadRollCatalog(c) : undefined // 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) { + 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 + // (a buyer who paid and got the item but left the creator unpaid is recoverable; + // a buyer charged for nothing is not), then pay the creator the item's price. + // The creator's signup grant is seeded BEFORE crediting them: `creditCurrency` + // upserts the balance row, and `ensureStartingBalances` is an INSERT OR IGNORE, + // so a creator who had never touched their balance would otherwise have the row + // created here and lose their starting tokens forever. The creator is a + // 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) + if (line.price > 0) { + const creatorId = line.custom.CreatorAccountId + await ensureStartingBalances(c.env.DB, creatorId, startingTokens) + const creatorBalance = await creditCurrency( + c.env.DB, + creatorId, + currencyType as number, + line.price, + startingTokens + ) + 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) + 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. @@ -4636,12 +4805,13 @@ const app = new Hono({ strict: false }) } satisfies BulkPurchaseData, } } + const sold = bought.has(line) return { - UpdateResponse: bought.has(line) ? UpdateResponse.OK : UpdateResponse.NotEnoughCredit, + UpdateResponse: sold ? UpdateResponse.OK : UpdateResponse.NotEnoughCredit, Data: { GiftPackage: packages.get(line) ?? null, PurchasableItemId: line.method.NumberId, - CustomAvatarItem: null, + CustomAvatarItem: sold && line.kind === 'custom' ? line.custom : null, } satisfies BulkPurchaseData, } }) diff --git a/apps/econ/src/inventory-custom-db.ts b/apps/econ/src/inventory-custom-db.ts new file mode 100644 index 0000000..288b0fe --- /dev/null +++ b/apps/econ/src/inventory-custom-db.ts @@ -0,0 +1,98 @@ +/** + * Owned CUSTOM avatar items on the shared `recflare` D1 database — the custom avatar items + * (player-made shirts and the imported first-party items alike) a player has bought. One row + * per (account, item), written by `POST /api/items/bulkpurchase` when a line names an item + * by `Guid`, and read back by `GET /econ/customAvatarItems/v1/owned`. + * + * Only the id is stored: the item record lives in `custom_avatar_item`, whose schema the + * `api` worker owns (apps/api/src/custom-avatar-items-db.ts) on this same database, and the + * owned read joins across to serve it whole. A creator is not listed here — they own their + * item through `CreatorAccountId`, which the owned read folds in and the buy path refuses to + * sell them — the same arrangement as `inventory_invention`. + * + * This worker (`econ`) owns the table and its migration — see apps/econ/migrations/ + * 0023_inventory_custom.sql. + */ + +import { parseCustomAvatarItem } from '../../api/src/custom-avatar-items-db' + +import type { CustomAvatarItem } from '../../api/src/custom-avatar-items-db' + +/** Schema DDL (mirror of migrations 0023_inventory_custom.sql) — also builds the table in tests. */ +export const INVENTORY_CUSTOM_SCHEMA_DDL: string[] = [ + `CREATE TABLE IF NOT EXISTS inventory_custom ( + account_id INTEGER NOT NULL, + custom_avatar_item_id TEXT NOT NULL, + acquired_at TEXT NOT NULL, + PRIMARY KEY (account_id, custom_avatar_item_id) + )`, +] + +/** + * Grant a custom avatar item to a player. INSERT OR IGNORE on the (account, item) primary + * key: owning an item is boolean, so a second grant keeps the original `acquired_at` rather + * than back-dating the purchase to now. + */ +export async function grantCustomAvatarItem( + db: D1Database, + accountId: number, + customAvatarItemId: string, + now: Date = new Date() +): Promise { + await db + .prepare( + 'INSERT OR IGNORE INTO inventory_custom (account_id, custom_avatar_item_id, acquired_at) VALUES (?1, ?2, ?3)' + ) + .bind(accountId, customAvatarItemId, now.toISOString()) + .run() +} + +/** + * Which of `ids` a player has BOUGHT. Bought only — the creator of an item owns it without a + * row here, so a caller that means "may wear this" checks `CreatorAccountId` as well. Ids are + * matched case-insensitively, since a GUID's case is not part of its identity and the client + * is not consistent about it. + */ +export async function ownedCustomAvatarItemIds( + db: D1Database, + accountId: number, + ids: string[] +): Promise> { + if (ids.length === 0) return new Set() + const placeholders = ids.map((_, i) => `?${i + 2}`).join(', ') + const { results } = await db + .prepare( + `SELECT custom_avatar_item_id FROM inventory_custom + WHERE account_id = ?1 AND lower(custom_avatar_item_id) IN (${placeholders})` + ) + .bind(accountId, ...ids.map((id) => id.toLowerCase())) + .all<{ custom_avatar_item_id: string }>() + return new Set(results.map((r) => r.custom_avatar_item_id.toLowerCase())) +} + +/** + * Every custom avatar item a player owns, as the client's full `CustomAvatarItem` record: + * what they BOUGHT (a row here) and what they CREATED (`CreatorAccountId`, drafts included — + * it is their shirt whether or not it is published), oldest first by when it became theirs + * (the purchase, or the item's creation). Read off `custom_avatar_item` itself so a deleted + * item simply drops out rather than leaving an id that resolves to nothing, and so an item + * both made and (somehow) bought appears once. `PurchaseInfo` is null: the owned list is + * not a store page. + */ +export async function getOwnedCustomAvatarItems( + db: D1Database, + accountId: number +): Promise { + const { results } = await db + .prepare( + `SELECT i.data, coalesce(o.acquired_at, i.created_at) AS owned_at + FROM custom_avatar_item i + LEFT JOIN inventory_custom o + ON o.custom_avatar_item_id = i.custom_avatar_item_id AND o.account_id = ?1 + WHERE o.account_id IS NOT NULL OR i.creator_account_id = ?1 + ORDER BY owned_at, i.custom_avatar_item_id` + ) + .bind(accountId) + .all<{ data: string }>() + return results.map((r) => parseCustomAvatarItem(r.data)) +} diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index 1477032..fe46801 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -108,9 +108,12 @@ export const RoomEconConfig = z.object({ EnableSortingTabs: z.boolean().describe('Always false — no per-room config is stored'), }) -/** `GET /econ/customAvatarItems/v1/owned` — paginated owned custom items. */ +/** + * `GET /econ/customAvatarItems/v1/owned` — the caller's bought custom items, each the full + * `CustomAvatarItem` record as the `api` worker serves it (see its `CustomAvatarItemDto`). + */ export const CustomAvatarItemsResponse = z.object({ - Results: JsonArray, + Results: z.array(JsonObject), TotalResults: z.int(), }) @@ -310,13 +313,17 @@ export const BuyItemResponse = z.object({ /** * How a bulk-purchase line names its item. A discriminated id: the client buys both * catalog items (a storefront `PurchasableItemId`, under `NumberId`, `Type` 0) and - * guid-keyed ones (UGC / custom avatar items). Only the numeric form resolves here — - * nothing sells guid-keyed items yet, so a `Guid` id fails its line. + * guid-keyed ones (`Type` 1, a custom avatar item's `CustomAvatarItemId` under `Guid`), + * which resolve against the `custom_avatar_item` table and are paid to their creator. */ export const ItemPurchaseMethodId = z.object({ - Type: z.int().describe('0 = NumberId. Anything else names a guid-keyed item we can’t sell'), + Type: z.int().describe('0 = NumberId (a storefront item); 1 = Guid (a custom avatar item)'), NumberId: z.int().nullable().optional().describe('The storefront PurchasableItemId'), - Guid: z.string().nullable().optional().describe('The guid-keyed item id; always null here'), + Guid: z + .string() + .nullable() + .optional() + .describe('The `CustomAvatarItemId` of a custom avatar item, for Type 1'), }) /** @@ -364,7 +371,9 @@ export const BulkPurchaseResponse = z.object({ 'and under `BypassGiftPackages` — the item is granted either way' ), PurchasableItemId: z.int().nullable().describe('The catalog item this line named'), - CustomAvatarItem: z.null().describe('The UGC counterpart; never sold here'), + CustomAvatarItem: JsonObject.nullable().describe( + 'The whole `CustomAvatarItem` record on a guid-keyed line that sold; null otherwise' + ), }), }) ) diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index a4e801b..16682c2 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -73,6 +73,7 @@ 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 { 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' @@ -163,6 +164,7 @@ beforeAll(async () => { for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of INVENTORY_CUSTOM_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of CATALOG_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of ROOM_CURRENCY_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of ROOM_BALANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() @@ -523,14 +525,69 @@ describe('econ endpoints', () => { expect(res.status).toBe(404) }) - test('GET /econ/customAvatarItems/v1/owned 401s without a token, returns an empty paginated stub', async () => { + test('GET /econ/customAvatarItems/v1/owned 401s without a token, lists what the caller bought', async () => { const anon = await exports.default.fetch(`${ORIGIN}/econ/customAvatarItems/v1/owned`) expect(anon.status).toBe(401) + // Nothing bought yet: the paginated envelope, empty. const res = await exports.default.fetch(`${ORIGIN}/econ/customAvatarItems/v1/owned`, { - headers: await bearer(), + headers: await bearer('610'), }) expect(res.status).toBe(200) expect(await res.json()).toEqual({ Results: [], TotalResults: 0 }) + + // Two bought items and two the caller made (one still a draft): the owned list is what + // was BOUGHT plus what they CREATED, each as the whole record out of + // `custom_avatar_item`, oldest first by when it became theirs — a purchase's time for + // a bought item, the item's creation for a made one. + const make = (name: string, creator: number, accessibility = 1, createdAt?: Date) => + createCustomAvatarItem( + env.DB, + { + customAvatarItemId: crypto.randomUUID(), + creatorAccountId: creator, + name, + description: '', + price: 100, + baseAvatarItemId: 1, + baseAvatarItemColor: '#fff', + accessibility, + designFilename: 'design_x.bin', + thumbnailImageFilename: 'thumb_x.png', + }, + createdAt + ) + const first = await make('Bought First', 205) + const second = await make('Bought Second', 206) + const made = await make('Made Myself', 610, 1, new Date('2026-08-03T00:00:00Z')) + const draft = await make('Made Myself, Unpublished', 610, 0, new Date('2026-08-04T00:00:00Z')) + await grantCustomAvatarItem( + env.DB, + 610, + second.CustomAvatarItemId, + new Date('2026-08-02T00:00:00Z') + ) + await grantCustomAvatarItem( + env.DB, + 610, + first.CustomAvatarItemId, + new Date('2026-08-01T00:00:00Z') + ) + // Someone else's purchase of the same item is theirs, not the caller's. + await grantCustomAvatarItem(env.DB, 611, first.CustomAvatarItemId) + + const owned = await exports.default.fetch(`${ORIGIN}/econ/customAvatarItems/v1/owned`, { + headers: await bearer('610'), + }) + expect(owned.status).toBe(200) + expect(await owned.json()).toEqual({ + Results: [ + { ...first, PurchaseInfo: null }, + { ...second, PurchaseInfo: null }, + { ...made, PurchaseInfo: null }, + { ...draft, PurchaseInfo: null }, + ], + TotalResults: 4, + }) }) test('GET /api/objectives/v1/myprogress returns the default progress (no auth)', async () => { @@ -3113,7 +3170,7 @@ describe('econ endpoints', () => { Data: { GiftPackage: Record | null PurchasableItemId: number | null - CustomAvatarItem: null + CustomAvatarItem: Record | null } }> } | null @@ -3430,6 +3487,200 @@ describe('econ endpoints', () => { expect(received?.payload).toMatchObject({ Id: box?.Id, FromPlayerId: 960, GiftContext: 500 }) }) + // ---- custom avatar items in the bag -------------------------------------------------- + // 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. + + /** A guid-keyed bag line, in the shape the client posts one. */ + const customLine = ( + guid: string, + requestedPrice: number, + extra: Record = {} + ) => ({ + ItemPurchaseMethodId: { Type: 1, NumberId: null, Guid: guid }, + RequestedPrice: requestedPrice, + Gift: null, + CouponConsumablePlayerMappingId: null, + DuplicateItemCount: 1, + ...extra, + }) + + /** A published custom item by `creator`, at `price` tokens. */ + const customItem = (creator: number, price: number, accessibility = 1) => + createCustomAvatarItem(env.DB, { + customAvatarItemId: crypto.randomUUID(), + creatorAccountId: creator, + name: 'Bag Visor', + description: '', + price, + baseAvatarItemId: 1, + baseAvatarItemColor: '#fff', + accessibility, + designFilename: 'design_x.bin', + thumbnailImageFilename: 'thumb_x.png', + }) + + const tokens = (accountId: number) => + getBalance(env.DB, accountId, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS) + + test('POST /api/items/bulkpurchase sells a custom avatar item: buyer pays, creator is paid, item is owned', async () => { + // Buyer 620 and creator 621 are both fresh, so each starts on the default grant — the + // creator's is seeded before the payout lands, or the credit would create their row and + // eat their signup tokens. + const item = await customItem(621, 900) + await drainFrames() + const res = await bulkPurchase('620', { + PurchaseItemRequests: [customLine(item.CustomAvatarItemId, 900)], + }) + expect(res.status).toBe(200) + const body = (await res.json()) as BulkBody + expect(body.Success).toBe(true) + expect(body.Error).toBe(null) + const value = body.Value! + expect(value.Balance).toBe(DEFAULT_STARTING_TOKENS - 900) + 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 }, + }) + + // The money moved both ways. + expect(await tokens(620)).toBe(DEFAULT_STARTING_TOKENS - 900) + expect(await tokens(621)).toBe(DEFAULT_STARTING_TOKENS + 900) + // The creator hears about the sale as a plain update carrying their RESULTING total, + // then the buyer's one purchase frame for the bag — both set the account-wide bucket. + expect(await drainFrames()).toEqual([ + { + accountId: 621, + notificationType: NotificationType.StorefrontBalanceUpdate, + payload: { Balance: DEFAULT_STARTING_TOKENS + 900, CurrencyType: 2, Platform: -2 }, + }, + { + accountId: 620, + notificationType: NotificationType.StorefrontBalancePurchase, + payload: { + BalanceAddType: 1400, + Delta: -900, + Balance: DEFAULT_STARTING_TOKENS - 900, + Platform: -2, + CurrencyType: 2, + }, + }, + ]) + + // Owned now: the item shows on the buyer's owned list, and no gift box was left behind. + const owned = await exports.default.fetch(`${ORIGIN}/econ/customAvatarItems/v1/owned`, { + headers: await bearer('620'), + }) + expect(await owned.json()).toEqual({ + Results: [{ ...item, PurchaseInfo: null }], + TotalResults: 1, + }) + const gifts = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, { + headers: await bearer('620'), + }) + expect(await gifts.json()).toEqual([]) + + // Buying it again is refused as AlreadyOwned and charges nothing. + const again = await bulkPurchase('620', { + PurchaseItemRequests: [customLine(item.CustomAvatarItemId, 900)], + }) + const againBody = (await again.json()) as BulkBody + expect(againBody.Success).toBe(false) + expect(againBody.Error).toBe('Already owned') + expect(await tokens(620)).toBe(DEFAULT_STARTING_TOKENS - 900) + expect(await tokens(621)).toBe(DEFAULT_STARTING_TOKENS + 900) + }) + + 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 + // sends it, settle together in ONE debit. + const item = await customItem(623, 100) + const res = await bulkPurchase('622', { + PurchaseItemRequests: [ + line(SF3_ITEM.id, SF3_ITEM.price), + customLine(item.CustomAvatarItemId.toUpperCase(), 100), + ], + }) + const body = (await res.json()) as BulkBody + expect(body.Success).toBe(true) + expect(codes(body)).toEqual([0, 0]) + expect(body.Value!.Balance).toBe(DEFAULT_STARTING_TOKENS - SF3_ITEM.price - 100) + expect(body.Value!.BalanceUpdates[0].Data.PurchasableItemId).toBe(SF3_ITEM.id) + expect(body.Value!.BalanceUpdates[0].Data.CustomAvatarItem).toBe(null) + expect(body.Value!.BalanceUpdates[1].Data.PurchasableItemId).toBe(null) + expect(body.Value!.BalanceUpdates[1].Data.CustomAvatarItem).toMatchObject({ + CustomAvatarItemId: item.CustomAvatarItemId, + }) + expect(await tokens(623)).toBe(DEFAULT_STARTING_TOKENS + 100) + }) + + test('POST /api/items/bulkpurchase refuses the custom lines that cannot be sold, per line', async () => { + const draft = await customItem(625, 100, 0) + const own = await customItem(624, 100) + const priced = await customItem(625, 300) + const res = await bulkPurchase('624', { + PurchaseItemRequests: [ + // A draft (Accessibility 0) is visible to its creator alone; to a buyer it is no item. + customLine(draft.CustomAvatarItemId, 100), + // An id nothing has. + customLine(crypto.randomUUID(), 100), + // The buyer's own item: they own it already, and would be paying themself. + customLine(own.CustomAvatarItemId, 100), + // A stale price. + 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. + customLine(priced.CustomAvatarItemId, 300, { + Gift: { ToPlayerId: 626, Anonymous: false, Message: 'hi' }, + }), + // And the one good line, which the partial bag still buys. + customLine(priced.CustomAvatarItemId, 300), + ], + }) + const body = (await res.json()) as BulkBody + expect(body.Success).toBe(true) + // 4 NoItemAvailable, 4, 8 PlayerNotEligible, 6 RequestedPriceDoesNotMatch, + // 7 RequestedAmountNotAllowed, 8, 0 OK. + expect(codes(body)).toEqual([4, 4, 8, 6, 7, 8, 0]) + expect(body.Value!.Balance).toBe(DEFAULT_STARTING_TOKENS - 300) + expect( + body.Value!.BalanceUpdates.slice(0, 6).every((u) => u.Data.CustomAvatarItem === null) + ).toBe(true) + expect(await tokens(625)).toBe(DEFAULT_STARTING_TOKENS + 300) + + // Without partial success the first bad line refuses the whole bag, unpaid. + const strict = await bulkPurchase('627', { + PurchaseItemRequests: [ + customLine(priced.CustomAvatarItemId, 300), + customLine(draft.CustomAvatarItemId, 100), + ], + AllowPartialSuccess: false, + }) + const strictBody = (await strict.json()) as BulkBody + expect(strictBody.Success).toBe(false) + expect(strictBody.Error).toBe('Item not found') + expect(await tokens(627)).toBe(DEFAULT_STARTING_TOKENS) + expect(await tokens(625)).toBe(DEFAULT_STARTING_TOKENS + 300) + + // A custom item is priced in tokens only: a bag in another currency cannot buy one. + const wrongCurrency = await bulkPurchase('627', { + PurchaseItemRequests: [customLine(priced.CustomAvatarItemId, 300)], + CurrencyType: 1, + }) + const wrongBody = (await wrongCurrency.json()) as BulkBody + expect(wrongBody.Success).toBe(false) + expect(wrongBody.Error).toBe('Currency type not available for this item') + }) + test('POST /api/items/bulkpurchase 404s a bag gifting to a player that does not exist', async () => { const res = await bulkPurchase('970', { PurchaseItemRequests: [