mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[invention] add invention v3 purchase
This commit is contained in:
@@ -249,12 +249,19 @@ export interface InventionTagsV9Dto {
|
||||
Tags: string[]
|
||||
}
|
||||
|
||||
/** The four keys inside a v9 save envelope's `Value`. `Status` is 0 on success. */
|
||||
/**
|
||||
* The four keys inside a v9 save envelope's `Value`. `Status` is 0 on success.
|
||||
*
|
||||
* `InventionVersion` and `TagsResponse` are nullable because the envelope is not the save
|
||||
* route's alone: econ's `POST /api/storefronts/v3/buyInvention` answers in it too, and a
|
||||
* BUY mints neither a version nor a tag result — it sends both as null, and the client
|
||||
* (which reads only `Success` and `Value.Invention`) never looks. The keys stay present.
|
||||
*/
|
||||
export interface InventionSaveV9Value {
|
||||
Status: number
|
||||
Invention: InventionV9Dto
|
||||
InventionVersion: InventionVersionV9Dto
|
||||
TagsResponse: InventionTagsV9Dto
|
||||
InventionVersion: InventionVersionV9Dto | null
|
||||
TagsResponse: InventionTagsV9Dto | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -282,15 +289,53 @@ export interface InventionSaveV9Result {
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a stored invention into the v9 save envelope. `tags` are the ones stored with
|
||||
* it, answered as the bare names `v1/settags` answers with; `tagResult` says whether they
|
||||
* were taken (see {@link INVENTION_TAG_RESULT}) — a tag the rules refuse costs the tags,
|
||||
* never the save, because the save is the thing the player would have to redo.
|
||||
* Project a stored invention into the client's 28-key `RRInvention`. Shared by the v9 save
|
||||
* envelope below and by econ's `v3/buyInvention`, which answers in that same envelope — so
|
||||
* the two can never drift into serving one build two different inventions.
|
||||
*
|
||||
* Fields the stored record has no equivalent for are served as what they are here rather
|
||||
* than guessed: nothing forces an invention not to publish, and nothing in this server
|
||||
* approves one.
|
||||
*/
|
||||
export function toInventionV9(invention: SavedInvention): InventionV9Dto {
|
||||
return {
|
||||
InventionId: invention.InventionId,
|
||||
ReplicationId: invention.ReplicationId,
|
||||
CreatorPlayerId: invention.CreatorPlayerId,
|
||||
Name: invention.Name,
|
||||
Description: invention.Description,
|
||||
ImageName: invention.ImageName,
|
||||
UgcVersion: invention.UgcVersion ?? 0,
|
||||
CurrentVersionNumber: invention.CurrentVersionNumber,
|
||||
// One save, one version: the newest is the current one.
|
||||
LatestVersionNumber: invention.CurrentVersionNumber,
|
||||
Accessibility: invention.Accessibility,
|
||||
ForceCannotPublish: false,
|
||||
ModifiedAt: invention.ModifiedAt,
|
||||
CreatedAt: invention.CreatedAt,
|
||||
FirstPublishedAt: invention.FirstPublishedAt,
|
||||
CreationRoomId: invention.CreationRoomId,
|
||||
NumPlayersHaveUsedInRoom: invention.NumPlayersHaveUsedInRoom,
|
||||
NumDownloads: invention.NumDownloads,
|
||||
CheerCount: invention.CheerCount,
|
||||
CreatorPermission: invention.CreatorPermission,
|
||||
GeneralPermission: invention.GeneralPermission,
|
||||
IsAGInvention: invention.IsAGInvention,
|
||||
IsCertifiedInvention: invention.IsCertifiedInvention,
|
||||
IsRecRoomApproved: false,
|
||||
AllowTrial: invention.AllowTrial,
|
||||
Price: invention.Price,
|
||||
HideFromPlayer: invention.HideFromPlayer,
|
||||
DisplayMetadataJson: invention.DisplayMetadataJson ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a stored invention into the v9 save envelope. `tags` are the ones stored with
|
||||
* it, answered as the bare names `v1/settags` answers with; `tagResult` says whether they
|
||||
* were taken (see {@link INVENTION_TAG_RESULT}) — a tag the rules refuse costs the tags,
|
||||
* never the save, because the save is the thing the player would have to redo.
|
||||
*/
|
||||
export function toSaveResultV9(
|
||||
invention: SavedInvention,
|
||||
tags: InventionTag[],
|
||||
@@ -300,36 +345,7 @@ export function toSaveResultV9(
|
||||
return {
|
||||
Value: {
|
||||
Status: 0,
|
||||
Invention: {
|
||||
InventionId: invention.InventionId,
|
||||
ReplicationId: invention.ReplicationId,
|
||||
CreatorPlayerId: invention.CreatorPlayerId,
|
||||
Name: invention.Name,
|
||||
Description: invention.Description,
|
||||
ImageName: invention.ImageName,
|
||||
UgcVersion: invention.UgcVersion ?? 0,
|
||||
CurrentVersionNumber: invention.CurrentVersionNumber,
|
||||
// One save, one version: the newest is the current one.
|
||||
LatestVersionNumber: invention.CurrentVersionNumber,
|
||||
Accessibility: invention.Accessibility,
|
||||
ForceCannotPublish: false,
|
||||
ModifiedAt: invention.ModifiedAt,
|
||||
CreatedAt: invention.CreatedAt,
|
||||
FirstPublishedAt: invention.FirstPublishedAt,
|
||||
CreationRoomId: invention.CreationRoomId,
|
||||
NumPlayersHaveUsedInRoom: invention.NumPlayersHaveUsedInRoom,
|
||||
NumDownloads: invention.NumDownloads,
|
||||
CheerCount: invention.CheerCount,
|
||||
CreatorPermission: invention.CreatorPermission,
|
||||
GeneralPermission: invention.GeneralPermission,
|
||||
IsAGInvention: invention.IsAGInvention,
|
||||
IsCertifiedInvention: invention.IsCertifiedInvention,
|
||||
IsRecRoomApproved: false,
|
||||
AllowTrial: invention.AllowTrial,
|
||||
Price: invention.Price,
|
||||
HideFromPlayer: invention.HideFromPlayer,
|
||||
DisplayMetadataJson: invention.DisplayMetadataJson ?? null,
|
||||
},
|
||||
Invention: toInventionV9(invention),
|
||||
InventionVersion: {
|
||||
InventionId: version.InventionId,
|
||||
ReplicationId: version.ReplicationId,
|
||||
|
||||
@@ -1926,7 +1926,10 @@ describe('public endpoints', () => {
|
||||
if (value === null) throw new Error('Value must not be null on a successful save')
|
||||
expect(value.Invention.UgcVersion).toBe(0)
|
||||
expect(value.Invention.DisplayMetadataJson).toBeNull()
|
||||
expect(value.InventionVersion.HasBetaContent).toBe(false)
|
||||
// A save always mints a version; the key is nullable only because econ's
|
||||
// `v3/buyInvention` answers in this same envelope and a buy mints none.
|
||||
expect(value.InventionVersion).not.toBeNull()
|
||||
expect(value.InventionVersion?.HasBetaContent).toBe(false)
|
||||
expect(value.TagsResponse).toEqual({ Result: 0, Tags: [] })
|
||||
|
||||
const one = await exports.default.fetch(
|
||||
|
||||
+227
-96
@@ -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 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.
|
||||
*/
|
||||
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 that’s 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 client’s',
|
||||
'`RequestedPrice` still matches the invention’s 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 save’s',
|
||||
'`{ Value, Success, Error, error_id }` (its `InventionVersion` and `TagsResponse` null,',
|
||||
'since a buy mints neither) and the balance half names its bucket `Platform`, not v2’s',
|
||||
'`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 v2’s 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),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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 client’s `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({
|
||||
|
||||
@@ -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 v2’s', 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',
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user