mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[econ] custom item purchases
This commit is contained in:
@@ -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: <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
|
||||
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
|
||||
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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<string, unknown> | 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<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. */
|
||||
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
|
||||
// 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<App>({ 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<App>({ 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<App>({ 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<BulkPurchaseLine, Record<string, unknown> | 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<App>({ 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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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'
|
||||
),
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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<string, unknown> | null
|
||||
PurchasableItemId: number | null
|
||||
CustomAvatarItem: null
|
||||
CustomAvatarItem: Record<string, unknown> | 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<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 () => {
|
||||
const res = await bulkPurchase('970', {
|
||||
PurchaseItemRequests: [
|
||||
|
||||
Reference in New Issue
Block a user