mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -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
|
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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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'),
|
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'
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
Reference in New Issue
Block a user