[invention] add invention v3 purchase

This commit is contained in:
Devin Zuczek
2026-09-02 11:22:03 -04:00
parent 20196361c8
commit d441181b03
6 changed files with 514 additions and 139 deletions
+227 -96
View File
@@ -24,7 +24,7 @@ import {
toUgcPurchasable,
UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
} from '../../api/src/custom-avatar-items-db'
import { getInventionById, toSaveResult } from '../../api/src/inventions-db'
import { getInventionById, toInventionV9, toSaveResult } from '../../api/src/inventions-db'
// The profanity filter behind `api`'s `POST /api/sanitize/v1`, imported rather than copied
// so a gift note is masked by the very same word list every other player-typed string is.
import { censorSwears } from '../../api/src/sanitize'
@@ -81,7 +81,9 @@ import {
BalanceEntry,
BulkPurchaseRequest,
BulkPurchaseResponse,
BuyInventionRequest,
BuyInventionResponse,
BuyInventionV3Response,
BuyItemRequest,
BuyItemResponse,
ChallengeProgressRequest,
@@ -126,6 +128,7 @@ import { claimReward } from './reward-db'
import type { Context } from 'hono'
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
import type { CustomAvatarItem } from '../../api/src/custom-avatar-items-db'
import type { SavedInvention } from '../../api/src/inventions-db'
import type {
BalanceResponsePayload,
PurchaseBalanceModificationPayload,
@@ -2097,6 +2100,120 @@ function listRoute(summary: string, description: string, auth = false) {
})
}
/**
* A completed invention purchase: the invention that changed hands and the buyer's
* RESULTING token balance (not the change — see the envelopes both routes build from it).
*/
interface SettledInventionPurchase {
invention: SavedInvention
balance: number
}
/**
* Settle an invention purchase — the whole of buyInvention except the envelope it is
* announced in, shared by the `v2` GET and the `v3` POST. Every refusal is a `Response`
* (the `{ error }` body both routes answer with); a sale is the bought invention and the
* buyer's resulting balance, which each route then wraps in ITS OWN shape — the two
* clients want different ones, so the money is shared and the projection is not.
*
* A priced invention is settled player-to-player: the buyer is debited its `Price` in
* RecCenterTokens and the CREATOR is credited the same amount — no house cut, so the
* tokens are moved rather than minted or burned. A free invention (`Price` 0) skips the
* money entirely: nothing is debited and nobody is paid. The stored price is confirmed
* against the price the client rendered first, so a stale or tampered client cant buy
* at a price the creator no longer offers (409), and an unaffordable one is a 400 —
* the same "Insufficient balance" buyItem answers with.
*
* Ownership is recorded in `inventory_invention`; the creator is not sold their own
* invention (they own it already, via CreatorPlayerId) and a re-buy is a 409 rather
* than a second row. The inventions `NumDownloads` counter is deliberately NOT
* bumped: that column lives on the `invention` table the `api` worker owns, and this
* worker only reads it.
*/
async function settleInventionPurchase(
c: Context<App>,
id: number,
inventionId: number,
requestedPrice: number
): Promise<SettledInventionPurchase | Response> {
const invention = await getInventionById(c.env.DB, inventionId)
if (invention === null) return c.json({ error: 'Invention not found' }, 404)
// An unpublished invention is a draft: it isn't on sale, not even for free.
if (!invention.IsPublished) return c.json({ error: 'Invention is not for sale' }, 403)
if (invention.CreatorPlayerId === id) {
return c.json({ error: 'Cannot buy your own invention' }, 400)
}
if (await ownsInvention(c.env.DB, id, inventionId)) {
return c.json({ error: 'Already owned' }, 409)
}
// The price the client rendered must still be the stored one: a mismatch is a stale
// catalog or a tampered request, never a sale.
if (invention.Price !== requestedPrice) {
return c.json({ error: 'Price has changed' }, 409)
}
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
// Inventions are priced in RecCenterTokens only — the store shows no other currency
// for them, and `Price` carries no currency of its own to pick a different one from.
const price = invention.Price
if (price > 0) {
// Debit the buyer atomically; false means they couldn't afford it and nothing
// changed, so no ownership is recorded and the creator is not paid.
const paid = await spendCurrency(
c.env.DB,
id,
CurrencyType.RecCenterTokens,
price,
startingTokens
)
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
}
// Grant before paying out: these are three separate D1 writes with no transaction
// around them, so order them by what a failure costs. A buyer who paid and got the
// invention but left the creator unpaid is recoverable; a buyer charged for nothing
// is not.
await grantInvention(c.env.DB, id, inventionId)
if (price > 0) {
// Seed the creator's signup grant 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.
await ensureStartingBalances(c.env.DB, invention.CreatorPlayerId, startingTokens)
const creatorBalance = await creditCurrency(
c.env.DB,
invention.CreatorPlayerId,
CurrencyType.RecCenterTokens,
price,
startingTokens
)
// The creator is a different, probably-online player with no response to read:
// push the sale so it lands on their shown balance without a re-fetch. The frame
// carries their resulting TOTAL (what `creditCurrency` returns), not the payout —
// sending the payout would set their whole balance to it. A plain update rather
// than a purchase frame: they sold, they didn't buy. Best-effort, as everywhere.
await pushBalanceUpdate(
c,
invention.CreatorPlayerId,
CurrencyType.RecCenterTokens,
creatorBalance
)
}
// Unlike buyItem — whose `Balance` is the change applied — the reference server
// answers this one with the RESULTING total (a first read seeds the buyer's starting
// grant, as everywhere else). The buyer's frame carries that same total, so the body
// and the push land the client on one number.
const balance = await getBalance(c.env.DB, id, CurrencyType.RecCenterTokens, startingTokens)
// A free invention moved nothing, so there is no purchase to report.
if (price > 0) {
await pushBalancePurchase(c, id, CurrencyType.RecCenterTokens, -price, balance)
}
return { invention, balance }
}
// strict: false so trailing-slash routes (e.g. `/gifts/consume/`, which the client
// posts with a trailing slash) match either form. Mirrors the `api` worker.
const app = new Hono<App>({ strict: false })
@@ -3511,22 +3628,11 @@ const app = new Hono<App>({ strict: false })
}
)
// Buy an invention. [Authorize]. A GET, despite being a purchase — the client sends
// `?inventionId=…&requestedPrice=…` with no body, so that's what we answer.
//
// A priced invention is settled player-to-player: the buyer is debited its `Price` in
// RecCenterTokens and the CREATOR is credited the same amount — no house cut, so the
// tokens are moved rather than minted or burned. A free invention (`Price` 0) skips the
// money entirely: nothing is debited and nobody is paid. The stored price is confirmed
// against the price the client rendered first, so a stale or tampered client can't buy
// at a price the creator no longer offers (409), and an unaffordable one is a 400 —
// the same "Insufficient balance" buyItem answers with.
//
// Ownership is recorded in `inventory_invention`; the creator is not sold their own
// invention (they own it already, via CreatorPlayerId) and a re-buy is a 409 rather
// than a second row. The invention's `NumDownloads` counter is deliberately NOT
// bumped: that column lives on the `invention` table the `api` worker owns, and this
// worker only reads it.
// Buy an invention. [Authorize]. A GET, despite being a purchase — the 2023 client sends
// `?inventionId=…&requestedPrice=…` with no body, so thats what we answer, in the v6 save
// envelope that build reads. The 2025 build posts to `v3/buyInvention` below and wants a
// different envelope back; the two share {@link settleInventionPurchase}, which is where
// the money and the rules live, and build their own bodies from what it returns.
.get(
'/api/storefronts/v2/buyInvention',
describeRoute({
@@ -3581,91 +3687,116 @@ const app = new Hono<App>({ strict: false })
// a priced one then fails the confirmation below rather than selling for nothing.
const requestedPrice = Number.parseInt(c.req.query('requestedPrice') ?? '0', 10) || 0
const invention = await getInventionById(c.env.DB, inventionId)
if (invention === null) return c.json({ error: 'Invention not found' }, 404)
// An unpublished invention is a draft: it isn't on sale, not even for free.
if (!invention.IsPublished) return c.json({ error: 'Invention is not for sale' }, 403)
if (invention.CreatorPlayerId === id) {
return c.json({ error: 'Cannot buy your own invention' }, 400)
}
if (await ownsInvention(c.env.DB, id, inventionId)) {
return c.json({ error: 'Already owned' }, 409)
}
const settled = await settleInventionPurchase(c, id, inventionId, requestedPrice)
if (settled instanceof Response) return settled
// The price the client rendered must still be the stored one: a mismatch is a stale
// catalog or a tampered request, never a sale.
if (invention.Price !== requestedPrice) {
return c.json({ error: 'Price has changed' }, 409)
}
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
// Inventions are priced in RecCenterTokens only — the store shows no other currency
// for them, and `Price` carries no currency of its own to pick a different one from.
const price = invention.Price
if (price > 0) {
// Debit the buyer atomically; false means they couldn't afford it and nothing
// changed, so no ownership is recorded and the creator is not paid.
const paid = await spendCurrency(
c.env.DB,
id,
CurrencyType.RecCenterTokens,
price,
startingTokens
)
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
}
// Grant before paying out: these are three separate D1 writes with no transaction
// around them, so order them by what a failure costs. A buyer who paid and got the
// invention but left the creator unpaid is recoverable; a buyer charged for nothing
// is not.
await grantInvention(c.env.DB, id, inventionId)
if (price > 0) {
// Seed the creator's signup grant 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.
await ensureStartingBalances(c.env.DB, invention.CreatorPlayerId, startingTokens)
const creatorBalance = await creditCurrency(
c.env.DB,
invention.CreatorPlayerId,
CurrencyType.RecCenterTokens,
price,
startingTokens
)
// The creator is a different, probably-online player with no response to read:
// push the sale so it lands on their shown balance without a re-fetch. The frame
// carries their resulting TOTAL (what `creditCurrency` returns), not the payout —
// sending the payout would set their whole balance to it. A plain update rather
// than a purchase frame: they sold, they didn't buy. Best-effort, as everywhere.
await pushBalanceUpdate(
c,
invention.CreatorPlayerId,
CurrencyType.RecCenterTokens,
creatorBalance
)
}
// Unlike buyItem — whose `Balance` is the change applied — the reference server
// answers this one with the RESULTING total (a first read seeds the buyer's starting
// grant, as everywhere else). The buyer's frame carries that same total, so the body
// and the push land the client on one number.
const balance = await getBalance(c.env.DB, id, CurrencyType.RecCenterTokens, startingTokens)
// A free invention moved nothing, so there is no purchase to report.
if (price > 0) {
await pushBalancePurchase(c, id, CurrencyType.RecCenterTokens, -price, balance)
}
return c.json({
BalanceUpdateResponse: {
Balance: balance,
Balance: settled.balance,
BalanceType: ALL_PLATFORMS,
CurrencyType: CurrencyType.RecCenterTokens,
BalanceUpdates: [{ UpdateResponse: 0, Data: invention }],
BalanceUpdates: [{ UpdateResponse: 0, Data: settled.invention }],
},
// The bare `{ Status, Invention, InventionVersion }` the v6 save serves — this
// build's invention endpoints answer in it, and the client re-renders from it.
InventionResponse: toSaveResult(settled.invention),
})
}
)
// Buy an invention, the way the 2025 client asks for it. [Authorize]. A POST carrying
// `{ InventionId, RequestedPrice }` as JSON, where the v2 GET takes query params.
//
// The PURCHASE is identical — both settle through `settleInventionPurchase` — but the
// RESPONSE is not, and that is the whole reason this route exists rather than an alias:
// this build wraps the invention in the v9 save envelope and names its balance bucket
// `Platform`. See `BuyInventionV3Response`. Both routes stay served: the 2023 build still
// sends the GET, and it would not parse this body.
.post(
'/api/storefronts/v3/buyInvention',
describeRoute({
tags: ['Storefront'],
summary: 'Buy an invention (JSON body)',
description: [
'The same purchase as `GET /api/storefronts/v2/buyInvention` — confirms the clients',
'`RequestedPrice` still matches the inventions stored `Price`, debits the buyer and',
'pays the creator that price in RecCenterTokens (a free invention moves nothing),',
'records ownership in `inventory_invention`, and pushes both players a socket frame',
'carrying their RESULTING total — but answered in a DIFFERENT envelope, which is why',
'the route exists at all: `InventionResponse` is the v9 saves',
'`{ Value, Success, Error, error_id }` (its `InventionVersion` and `TagsResponse` null,',
'since a buy mints neither) and the balance half names its bucket `Platform`, not v2s',
'`BalanceType`.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(BuyInventionRequest, 'The invention id and the price rendered'),
responses: {
200: json(BuyInventionV3Response, 'The purchase result (invention + balance)'),
400: json(
ErrorResponse,
'Invalid body, missing InventionId, buying your own, or insufficient balance'
),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'The invention is not published, so it is not for sale'),
404: json(ErrorResponse, 'No such invention'),
409: json(ErrorResponse, 'Already owned, or the price has changed'),
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
return c.json({ error: 'Invalid request body' }, 400)
}
const inventionId = body.InventionId
if (!Number.isInteger(inventionId)) {
return c.json({ error: 'InventionId is required' }, 400)
}
// Read the same way as v2s query param: an absent or non-integer RequestedPrice is 0,
// which only matches a free invention — a priced one then fails the confirmation rather
// than selling for nothing.
const requestedPrice = Number.isInteger(body.RequestedPrice)
? (body.RequestedPrice as number)
: 0
const settled = await settleInventionPurchase(c, id, inventionId as number, requestedPrice)
if (settled instanceof Response) return settled
return c.json({
// The v9 SAVE envelope, not v6's bare `{ Status, Invention, InventionVersion }`:
// `Value` under `{ Success, Error, error_id }`, with `Invention` the client's 28-key
// `RRInvention`. A buy mints no version and takes no tags, so both of those keys are
// present and NULL — which is safe here for the same reason it is on the save: the
// client reads `Success` and `Value.Invention` and nothing else. `Value` itself must
// never be null under `Success: true` — that dereference is what crashes it.
InventionResponse: {
Value: {
Status: 0,
Invention: toInventionV9(settled.invention),
InventionVersion: null,
TagsResponse: null,
},
Success: true,
Error: null,
error_id: null,
},
// `BalanceResponseDTO`, the same one the bulk purchase answers in — so the bucket key
// is `Platform`, NOT the `BalanceType` the v2 body sends. The client's member IS named
// `BalanceType`, but it carries a [DataMember] rename to `Platform` and its decoder
// drops what it doesn't know, so spelling it `BalanceType` here would land this balance
// in bucket 0 beside the real one. `Balance` is the RESULTING total, as in v2.
BalanceUpdateResponse: {
BalanceUpdates: [{ UpdateResponse: 0, Data: toInventionV9(settled.invention) }],
Balance: settled.balance,
CurrencyType: CurrencyType.RecCenterTokens,
// The capture says 0 (SteamPurchased) because the reference server kept a wallet per
// platform. This one keeps ONE bucket and the client SUMS them, so naming 0 here
// while every socket frame names -2 is exactly the phantom second balance that
// doubled players' tokens twice before. -2, like every other surface.
Platform: ALL_PLATFORMS,
},
// The same `{ Status, Invention, InventionVersion }` envelope the invention
// save/read endpoints serve — the client re-renders the invention from it.
InventionResponse: toSaveResult(invention),
})
}
)
+66 -4
View File
@@ -375,10 +375,24 @@ export const BulkPurchaseResponse = z.object({
})
/**
* `GET /api/storefronts/v2/buyInvention` — the purchase result. Two envelopes side by
* side: the balance update (shaped like buyItem's, except `Balance` is the RESULTING
* total, not the change, and `Data` is a single invention rather than a gift-drop list)
* and the invention envelope the invention endpoints already serve.
* The JSON body `POST /api/storefronts/v3/buyInvention` takes. The same two values the v2
* GET reads off the query string (`inventionId`/`requestedPrice`), PascalCase in a body —
* that is the only difference between the two routes.
*/
export const BuyInventionRequest = z.object({
InventionId: z.int().describe('The invention to buy; missing or non-integer is 400'),
RequestedPrice: z
.int()
.optional()
.describe('The price the client rendered; a mismatch is 409. Absent reads as 0'),
})
/**
* `GET /api/storefronts/v2/buyInvention` and `POST /api/storefronts/v3/buyInvention` — the
* purchase result, identical for both. Two envelopes side by side: the balance update
* (shaped like buyItem's, except `Balance` is the RESULTING total, not the change, and
* `Data` is a single invention rather than a gift-drop list) and the invention envelope the
* invention endpoints already serve.
*/
export const BuyInventionResponse = z.object({
BalanceUpdateResponse: z.object({
@@ -401,6 +415,54 @@ export const BuyInventionResponse = z.object({
.describe('The same envelope `POST /api/inventions/v6/save` returns'),
})
/**
* `POST /api/storefronts/v3/buyInvention` — the purchase result the 2025 client wants,
* which is NOT v2's despite settling the identical purchase. Two differences, both
* recovered from a capture of the real response:
*
* - `InventionResponse` is the v9 SAVE envelope (`{ Value, Success, Error, error_id }`)
* rather than v6's bare `{ Status, Invention, InventionVersion }`, and its `Invention`
* is the client's 28-key `RRInvention`. A buy mints no version and takes no tags, so
* `InventionVersion` and `TagsResponse` are present and null.
* - The balance half is `BalanceResponseDTO`, so the bucket key is `Platform` — the
* client's `BalanceType` member under a [DataMember] rename, the same one the bulk
* purchase answers in. v2 spells it `BalanceType`; do not unify them.
*/
export const BuyInventionV3Response = z.object({
InventionResponse: z
.object({
Value: z
.object({
Status: z.int().describe('0 on success'),
Invention: JsonObject.describe('The bought invention as the 28-key `RRInvention`'),
InventionVersion: z.null().describe('Always null — a buy mints no version'),
TagsResponse: z.null().describe('Always null — a buy takes no tags'),
})
.describe('Never null under `Success: true` — the client dereferences it unguarded'),
Success: z.boolean(),
Error: z.string().nullable().describe('Null on success — not `""`'),
error_id: z.string().nullable().describe('Always null — no error-id catalog here'),
})
.describe('The same envelope `POST /api/inventions/v9/save` returns'),
BalanceUpdateResponse: z.object({
BalanceUpdates: z.array(
z.object({
UpdateResponse: z.int(),
Data: JsonObject.describe('The bought invention, the same `RRInvention` as above'),
})
),
Balance: z.int().describe('The resulting balance — NOT the change, unlike buyItem'),
CurrencyType: z.int().describe('2 = RecCenterTokens'),
Platform: z
.int()
.describe(
'The balance bucket — the clients `BalanceType` under a [DataMember] rename. -2, ' +
'account-wide: the capture said 0 (SteamPurchased) because the reference server ' +
'kept a wallet per platform; this one keeps a single bucket, and the client SUMS them'
),
}),
})
/** buyItem / buyInvention error body (`{ error }`), returned on 400/403/404/409. */
/** The JSON body `POST /api/ugcPurchasables/v1/items/bulk` takes. */
export const UgcPurchasableBulkRequest = z.object({
+163
View File
@@ -2656,6 +2656,168 @@ describe('econ endpoints', () => {
expect(await getOwnedInventionIds(env.DB, 60)).toEqual([])
})
// The 2025 client posts the same purchase as a JSON body — and wants a DIFFERENT response
// back: the v9 save envelope, and a balance bucket keyed `Platform`. The settlement is
// shared with v2, so these pin the envelope and the money moving, not the rules v2 covers.
const buyInventionV3 = async (sub: string, body: unknown) =>
exports.default.fetch(`${ORIGIN}/api/storefronts/v3/buyInvention`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
test('POST /api/storefronts/v3/buyInvention 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v3/buyInvention`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: 8, RequestedPrice: 0 }),
})
expect(res.status).toBe(401)
})
test('POST /api/storefronts/v3/buyInvention answers the v9 envelope, not v2s', async () => {
const res = await buyInventionV3('55', { InventionId: 8, RequestedPrice: 0 })
expect(res.status).toBe(200)
const body = (await res.json()) as {
InventionResponse: {
Value: {
Status: number
Invention: Record<string, unknown>
InventionVersion: unknown
TagsResponse: unknown
} | null
Success: boolean
Error: string | null
error_id: string | null
}
BalanceUpdateResponse: {
Balance: number
CurrencyType: number
Platform: number
BalanceType?: number
BalanceUpdates: Array<{ UpdateResponse: number; Data: { InventionId: number } }>
}
}
// The v9 SAVE envelope: `Value` under `{ Success, Error, error_id }`. `Value` is never
// null under `Success: true` — the client dereferences `Value.Invention` unguarded.
expect(body.InventionResponse).toMatchObject({ Success: true, Error: null, error_id: null })
expect(body.InventionResponse.Value?.Status).toBe(0)
expect(body.InventionResponse.Value?.Invention.InventionId).toBe(8)
expect(body.InventionResponse.Value?.Invention.Name).toBe('Invention 8')
// The 28-key `RRInvention`, not the stored record: the version rides nowhere here, and
// `IsPublished` is a stored field this projection drops.
expect(body.InventionResponse.Value?.Invention.CurrentVersion).toBeUndefined()
expect(body.InventionResponse.Value?.Invention.IsPublished).toBeUndefined()
expect(body.InventionResponse.Value?.Invention.LatestVersionNumber).toBe(1)
// A buy mints no version and takes no tags — present and null, not absent.
expect(body.InventionResponse.Value).toHaveProperty('InventionVersion', null)
expect(body.InventionResponse.Value).toHaveProperty('TagsResponse', null)
// `BalanceResponseDTO`: the bucket key is `Platform`. Spelling it `BalanceType` (which is
// what v2 sends) would be dropped by the client's decoder and default this balance into
// bucket 0, beside the -2 the socket frames set — a phantom second balance.
expect(body.BalanceUpdateResponse.Platform).toBe(-2)
expect(body.BalanceUpdateResponse.BalanceType).toBeUndefined()
// Nothing was debited, so `Balance` is the resulting total, not a change.
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS)
expect(body.BalanceUpdateResponse.CurrencyType).toBe(CurrencyType.RecCenterTokens)
expect(body.BalanceUpdateResponse.BalanceUpdates[0].Data.InventionId).toBe(8)
expect(await getOwnedInventionIds(env.DB, 55)).toEqual([8])
// Owning it is boolean here too — the route shares v2's settlement.
expect((await buyInventionV3('55', { InventionId: 8, RequestedPrice: 0 })).status).toBe(409)
})
test('GET v2 and POST v3 buyInvention answer the SAME buy in different envelopes', async () => {
// The one thing that must not drift: two builds buying the same invention get the same
// invention back, shaped for each. v2 serves the stored record under a bare status
// envelope; v3 serves the 28-key projection under the v9 one. Don't unify them.
const v2 = (await (await buyInvention('58', 8)).json()) as {
InventionResponse: { Status: number; Invention: Record<string, unknown> }
BalanceUpdateResponse: { BalanceType: number; Platform?: number }
}
const v3 = (await (
await buyInventionV3('59', { InventionId: 8, RequestedPrice: 0 })
).json()) as {
InventionResponse: { Value: { Invention: Record<string, unknown> } | null }
BalanceUpdateResponse: { Platform: number; BalanceType?: number }
}
expect(v2.InventionResponse.Invention.InventionId).toBe(8)
expect(v3.InventionResponse.Value?.Invention.InventionId).toBe(8)
// v2 keeps the nested version; v3's projection lifts it away entirely.
expect(v2.InventionResponse.Invention.CurrentVersion).toBeDefined()
expect(v3.InventionResponse.Value?.Invention.CurrentVersion).toBeUndefined()
// The bucket is spelled differently on each, and each spells exactly one.
expect(v2.BalanceUpdateResponse).toMatchObject({ BalanceType: -2 })
expect(v2.BalanceUpdateResponse.Platform).toBeUndefined()
expect(v3.BalanceUpdateResponse).toMatchObject({ Platform: -2 })
expect(v3.BalanceUpdateResponse.BalanceType).toBeUndefined()
})
test('POST /api/storefronts/v3/buyInvention pays the creator and pushes both sides', async () => {
await drainFrames()
// Creator 999 has already been paid by the v2 tests above, so their resulting total is
// read rather than assumed — it is the payout ADDED to whatever they had.
const creatorBefore = await getBalance(
env.DB,
999,
CurrencyType.RecCenterTokens,
DEFAULT_STARTING_TOKENS
)
const res = await buyInventionV3('56', { InventionId: 9, RequestedPrice: 250 })
expect(res.status).toBe(200)
const body = (await res.json()) as { BalanceUpdateResponse: { Balance: number } }
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS - 250)
expect(
await getBalance(env.DB, 56, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(DEFAULT_STARTING_TOKENS - 250)
expect(
await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(creatorBefore + 250)
expect(await getOwnedInventionIds(env.DB, 56)).toEqual([9])
// Same two frames as the v2 buy, each carrying its player's RESULTING total into the -2
// bucket: the creator sold (a plain update), the buyer bought (a purchase frame).
expect(await drainFrames()).toEqual([
{
accountId: 999,
notificationType: NotificationType.StorefrontBalanceUpdate,
payload: {
Balance: creatorBefore + 250,
CurrencyType: CurrencyType.RecCenterTokens,
Platform: -2,
},
},
{
accountId: 56,
notificationType: NotificationType.StorefrontBalancePurchase,
payload: {
BalanceAddType: 1400,
Delta: -250,
Balance: DEFAULT_STARTING_TOKENS - 250,
Platform: -2,
CurrencyType: CurrencyType.RecCenterTokens,
},
},
])
})
test('POST /api/storefronts/v3/buyInvention rejects a stale price and a bad body', async () => {
// A body is the only difference from v2, so the price check reads it the same way: an
// absent RequestedPrice is 0, which does not match the 250-token invention 9.
expect((await buyInventionV3('57', { InventionId: 9 })).status).toBe(409)
expect((await buyInventionV3('57', { InventionId: 9, RequestedPrice: 0 })).status).toBe(409)
// No InventionId, and no body at all.
expect((await buyInventionV3('57', { RequestedPrice: 0 })).status).toBe(400)
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v3/buyInvention`, {
method: 'POST',
headers: { ...(await bearer('57')), 'Content-Type': 'application/json' },
})
expect(res.status).toBe(400)
expect(await getOwnedInventionIds(env.DB, 57)).toEqual([])
})
test('POST /api/avatar/v2/gifts/consume opens the box the way the client sends it', async () => {
// Buy an item for account 24, then consume the box the way the client does: on the
// econ host, with a form body (`Id=..&UnlockedLevel=..`).
@@ -3935,6 +4097,7 @@ describe('econ endpoints', () => {
'POST /api/objectives/v1/cleargroup',
'POST /api/objectives/v1/updateobjective',
'POST /api/storefronts/v2/buyItem',
'POST /api/storefronts/v3/buyInvention',
'POST /api/ugcPurchasables/v1/items/bulk',
'PUT /api/equipment/v1/update',
])