[econ] custom item purchases

This commit is contained in:
Devin Zuczek
2026-09-15 21:38:15 -04:00
parent 741604be24
commit 3e49ebb0ec
7 changed files with 610 additions and 45 deletions
+9
View File
@@ -207,6 +207,15 @@ inconsistency here without checking the client first.
empty `CurrentSaves`/`Tags` and null `CustomBadgeMetadata`, never a missing key. The saves 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 name their assetbundles and thumbnails by bare filename; storing the record does nothing
about serving those files. 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: <CustomAvatarItemId> }`, 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 - Accessibility is sent as the `RoomAccessibility` enum NAME on
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the `rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
+10 -2
View File
@@ -163,11 +163,19 @@ const DTO_DEFAULTS = {
PurchaseInfo: null, PurchaseInfo: null,
} as const } 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 } 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. */ /** Inserts a new player-made custom avatar item and returns it as the client's DTO. */
export async function createCustomAvatarItem( export async function createCustomAvatarItem(
db: D1Database, db: D1Database,
@@ -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)
);
+203 -33
View File
@@ -75,6 +75,11 @@ import {
grantConsumable, grantConsumable,
} from './consumables-db' } from './consumables-db'
import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db' import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db'
import {
getOwnedCustomAvatarItems,
grantCustomAvatarItem,
ownedCustomAvatarItemIds,
} from './inventory-custom-db'
import { getInventory, grantItem, toAvatarItemV4 } from './inventory-db' import { getInventory, grantItem, toAvatarItemV4 } from './inventory-db'
import { import {
AUTHED, AUTHED,
@@ -1656,8 +1661,9 @@ interface BulkLineFailure {
error: string error: string
} }
/** A line that resolved to something buyable, with the catalog's own price. */ /** A line that resolved to a catalog item, with the catalog's own price. */
interface BulkPurchaseLine { interface BulkCatalogLine {
kind: 'catalog'
method: PurchaseMethodId method: PurchaseMethodId
item: StoreItem item: StoreItem
/** The UNIT price from the catalog — `count` copies cost `price * count`. */ /** The UNIT price from the catalog — `count` copies cost `price * count`. */
@@ -1666,19 +1672,37 @@ interface BulkPurchaseLine {
gift: GiftRequest | null 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, * 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 * 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. * 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 * `CustomAvatarItem` is the UGC counterpart of `PurchasableItemId`: the whole item record on
* `GiftPackage` are null on a line that didn't sell. Nothing here fills `CustomAvatarItem`: * a guid-keyed line that sold, which the client has nothing else to render it from. Both it
* no catalog we serve sells guid-keyed items. * 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 { interface BulkPurchaseData {
GiftPackage: Record<string, unknown> | null GiftPackage: Record<string, unknown> | null
PurchasableItemId: number | null PurchasableItemId: number | null
CustomAvatarItem: null CustomAvatarItem: CustomAvatarItem | null
} }
/** `ItemPurchaseMethodId.Type` for a numeric (storefront `PurchasableItemId`) id. */ /** `ItemPurchaseMethodId.Type` for a numeric (storefront `PurchasableItemId`) id. */
@@ -1819,18 +1843,10 @@ function resolveBulkLine(
line: PurchaseItemRequest, line: PurchaseItemRequest,
storefront: Storefront | null, storefront: Storefront | null,
currencyType: number, currencyType: number,
subscriber: boolean subscriber: boolean,
custom: CustomBagContext
): BulkPurchaseLine | BulkLineFailure { ): BulkPurchaseLine | BulkLineFailure {
const method = toPurchaseMethodId(line.ItemPurchaseMethodId) 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 // Nothing issues coupons, so a line claiming one would otherwise be charged full price
// for a discount it thinks it applied. // for a discount it thinks it applied.
if ( if (
@@ -1851,6 +1867,18 @@ function resolveBulkLine(
error: 'DuplicateItemCount must be a positive integer', 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) { if (storefront === null) {
return { method, code: UpdateResponse.NoItemAvailable, error: 'No such storefront' } 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 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<string, CustomAvatarItem>
owned: Set<string>
}
/**
* 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. */ /** Whether a resolved line is buyable or is already a failure. */
function isBulkLine(resolved: BulkPurchaseLine | BulkLineFailure): resolved is BulkPurchaseLine { function isBulkLine(resolved: BulkPurchaseLine | BulkLineFailure): resolved is BulkPurchaseLine {
return 'item' in resolved return 'kind' in resolved
} }
/** /**
@@ -2540,29 +2645,34 @@ const app = new Hono<App>({ strict: false })
} }
) )
// The player's owned custom avatar items. [Authorize]; paginated. Empty stub for // The player's owned custom avatar items. [Authorize]; the paginated envelope. What the
// now (no DB binding). The client downloads these when custom-item creation is // caller has BOUGHT through bulkpurchase (`inventory_custom`) plus what they CREATED, each
// allowed; a 404 here surfaces as "Failed to download unlocked avatar items". // 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( .get(
'/econ/customAvatarItems/v1/owned', '/econ/customAvatarItems/v1/owned',
describeRoute({ describeRoute({
tags: ['Avatar'], tags: ['Avatar'],
summary: 'Owned custom avatar items', summary: 'Owned custom avatar items',
description: [ description: [
'Paginated owned custom items. Empty stub for now. The client requests this when', 'The custom avatar items the caller owns — bought (`POST /api/items/bulkpurchase` with',
'custom-item creation is allowed; a 404 shows as “Failed to download unlocked', 'a guid-keyed line) or created (`CreatorAccountId`, drafts included) — each as its full',
'avatar items”.', '`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(' '), ].join(' '),
security: AUTHED, security: AUTHED,
responses: { responses: {
200: json(CustomAvatarItemsResponse, 'Paginated results (empty for now)'), 200: json(CustomAvatarItemsResponse, 'The owned items'),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
}, },
}), }),
async (c) => { async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return unauthorized(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<App>({ strict: false })
summary: 'Buy a bag of storefront items', summary: 'Buy a bag of storefront items',
description: [ description: [
'Resolves every line against the bag’s storefront catalog (one read for the whole', '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', 'bag) — or, for a line whose `ItemPurchaseMethodId` is a `Guid` (Type 1), against the',
'atomic spend, grants what sold, and answers the `{ Success, Error, error_id, Value }`', '`custom_avatar_item` table, the guid being a `CustomAvatarItemId` — confirms each',
'envelope. `Value.Balance` is the RESULTING total (not buyItem’s change) in the', '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', '`Platform` bucket named beside it, and `BalanceUpdates` carries one entry per',
'REQUESTED item, each with its own `UpdateResponse`. `AllowPartialSuccess` lets some', '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', 'of those be non-OK while `Success` stays true; without it a single bad line refuses',
@@ -4509,9 +4626,29 @@ const app = new Hono<App>({ strict: false })
catalogItems.length === 0 catalogItems.length === 0
? storefront ? storefront
: { StoreItems: [...(storefront?.StoreItems ?? []), ...catalogItems] } : { 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 subscriber = await isSubscriber(c)
const resolved = lines.map((line) => 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) const buyable = resolved.filter(isBulkLine)
@@ -4576,13 +4713,45 @@ const app = new Hono<App>({ strict: false })
// A query drop (a loot box) rolls against sf3, the big catalog. Read it ONCE for the // 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 // 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. // 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) ? await loadRollCatalog(c)
: undefined : undefined
// 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) {
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 // 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 // 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. // anonymous gift is attributed to the "Coach" system account.
@@ -4636,12 +4805,13 @@ const app = new Hono<App>({ strict: false })
} satisfies BulkPurchaseData, } satisfies BulkPurchaseData,
} }
} }
const sold = bought.has(line)
return { return {
UpdateResponse: bought.has(line) ? UpdateResponse.OK : UpdateResponse.NotEnoughCredit, UpdateResponse: sold ? UpdateResponse.OK : UpdateResponse.NotEnoughCredit,
Data: { Data: {
GiftPackage: packages.get(line) ?? null, GiftPackage: packages.get(line) ?? null,
PurchasableItemId: line.method.NumberId, PurchasableItemId: line.method.NumberId,
CustomAvatarItem: null, CustomAvatarItem: sold && line.kind === 'custom' ? line.custom : null,
} satisfies BulkPurchaseData, } satisfies BulkPurchaseData,
} }
}) })
+98
View File
@@ -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<void> {
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<Set<string>> {
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<CustomAvatarItem[]> {
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))
}
+16 -7
View File
@@ -108,9 +108,12 @@ export const RoomEconConfig = z.object({
EnableSortingTabs: z.boolean().describe('Always false — no per-room config is stored'), 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({ export const CustomAvatarItemsResponse = z.object({
Results: JsonArray, Results: z.array(JsonObject),
TotalResults: z.int(), 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 * 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 * catalog items (a storefront `PurchasableItemId`, under `NumberId`, `Type` 0) and
* guid-keyed ones (UGC / custom avatar items). Only the numeric form resolves here — * guid-keyed ones (`Type` 1, a custom avatar item's `CustomAvatarItemId` under `Guid`),
* nothing sells guid-keyed items yet, so a `Guid` id fails its line. * which resolve against the `custom_avatar_item` table and are paid to their creator.
*/ */
export const ItemPurchaseMethodId = z.object({ 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'), 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' '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'),
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'
),
}), }),
}) })
) )
+254 -3
View File
@@ -73,6 +73,7 @@ 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 { 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'
@@ -163,6 +164,7 @@ beforeAll(async () => {
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run() 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 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 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 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_CURRENCY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of ROOM_BALANCE_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) 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`) const anon = await exports.default.fetch(`${ORIGIN}/econ/customAvatarItems/v1/owned`)
expect(anon.status).toBe(401) expect(anon.status).toBe(401)
// Nothing bought yet: the paginated envelope, empty.
const res = await exports.default.fetch(`${ORIGIN}/econ/customAvatarItems/v1/owned`, { const res = await exports.default.fetch(`${ORIGIN}/econ/customAvatarItems/v1/owned`, {
headers: await bearer(), headers: await bearer('610'),
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 }) 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 () => { test('GET /api/objectives/v1/myprogress returns the default progress (no auth)', async () => {
@@ -3113,7 +3170,7 @@ describe('econ endpoints', () => {
Data: { Data: {
GiftPackage: Record<string, unknown> | null GiftPackage: Record<string, unknown> | null
PurchasableItemId: number | null PurchasableItemId: number | null
CustomAvatarItem: null CustomAvatarItem: Record<string, unknown> | null
} }
}> }>
} | null } | null
@@ -3430,6 +3487,200 @@ describe('econ endpoints', () => {
expect(received?.payload).toMatchObject({ Id: box?.Id, FromPlayerId: 960, GiftContext: 500 }) 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<string, unknown> = {}
) => ({
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 () => { test('POST /api/items/bulkpurchase 404s a bag gifting to a player that does not exist', async () => {
const res = await bulkPurchase('970', { const res = await bulkPurchase('970', {
PurchaseItemRequests: [ PurchaseItemRequests: [