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[]
|
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 {
|
export interface InventionSaveV9Value {
|
||||||
Status: number
|
Status: number
|
||||||
Invention: InventionV9Dto
|
Invention: InventionV9Dto
|
||||||
InventionVersion: InventionVersionV9Dto
|
InventionVersion: InventionVersionV9Dto | null
|
||||||
TagsResponse: InventionTagsV9Dto
|
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
|
* Project a stored invention into the client's 28-key `RRInvention`. Shared by the v9 save
|
||||||
* it, answered as the bare names `v1/settags` answers with; `tagResult` says whether they
|
* envelope below and by econ's `v3/buyInvention`, which answers in that same envelope — so
|
||||||
* were taken (see {@link INVENTION_TAG_RESULT}) — a tag the rules refuse costs the tags,
|
* the two can never drift into serving one build two different inventions.
|
||||||
* never the save, because the save is the thing the player would have to redo.
|
|
||||||
*
|
*
|
||||||
* Fields the stored record has no equivalent for are served as what they are here rather
|
* 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
|
* than guessed: nothing forces an invention not to publish, and nothing in this server
|
||||||
* approves one.
|
* 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(
|
export function toSaveResultV9(
|
||||||
invention: SavedInvention,
|
invention: SavedInvention,
|
||||||
tags: InventionTag[],
|
tags: InventionTag[],
|
||||||
@@ -300,36 +345,7 @@ export function toSaveResultV9(
|
|||||||
return {
|
return {
|
||||||
Value: {
|
Value: {
|
||||||
Status: 0,
|
Status: 0,
|
||||||
Invention: {
|
Invention: toInventionV9(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,
|
|
||||||
},
|
|
||||||
InventionVersion: {
|
InventionVersion: {
|
||||||
InventionId: version.InventionId,
|
InventionId: version.InventionId,
|
||||||
ReplicationId: version.ReplicationId,
|
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')
|
if (value === null) throw new Error('Value must not be null on a successful save')
|
||||||
expect(value.Invention.UgcVersion).toBe(0)
|
expect(value.Invention.UgcVersion).toBe(0)
|
||||||
expect(value.Invention.DisplayMetadataJson).toBeNull()
|
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: [] })
|
expect(value.TagsResponse).toEqual({ Result: 0, Tags: [] })
|
||||||
|
|
||||||
const one = await exports.default.fetch(
|
const one = await exports.default.fetch(
|
||||||
|
|||||||
+227
-96
@@ -24,7 +24,7 @@ import {
|
|||||||
toUgcPurchasable,
|
toUgcPurchasable,
|
||||||
UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
|
UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
|
||||||
} from '../../api/src/custom-avatar-items-db'
|
} 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
|
// 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.
|
// so a gift note is masked by the very same word list every other player-typed string is.
|
||||||
import { censorSwears } from '../../api/src/sanitize'
|
import { censorSwears } from '../../api/src/sanitize'
|
||||||
@@ -81,7 +81,9 @@ import {
|
|||||||
BalanceEntry,
|
BalanceEntry,
|
||||||
BulkPurchaseRequest,
|
BulkPurchaseRequest,
|
||||||
BulkPurchaseResponse,
|
BulkPurchaseResponse,
|
||||||
|
BuyInventionRequest,
|
||||||
BuyInventionResponse,
|
BuyInventionResponse,
|
||||||
|
BuyInventionV3Response,
|
||||||
BuyItemRequest,
|
BuyItemRequest,
|
||||||
BuyItemResponse,
|
BuyItemResponse,
|
||||||
ChallengeProgressRequest,
|
ChallengeProgressRequest,
|
||||||
@@ -126,6 +128,7 @@ import { claimReward } from './reward-db'
|
|||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
|
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
|
||||||
import type { CustomAvatarItem } from '../../api/src/custom-avatar-items-db'
|
import type { CustomAvatarItem } from '../../api/src/custom-avatar-items-db'
|
||||||
|
import type { SavedInvention } from '../../api/src/inventions-db'
|
||||||
import type {
|
import type {
|
||||||
BalanceResponsePayload,
|
BalanceResponsePayload,
|
||||||
PurchaseBalanceModificationPayload,
|
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
|
// 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.
|
// posts with a trailing slash) match either form. Mirrors the `api` worker.
|
||||||
const app = new Hono<App>({ strict: false })
|
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
|
// 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.
|
// `?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
|
||||||
// A priced invention is settled player-to-player: the buyer is debited its `Price` in
|
// different envelope back; the two share {@link settleInventionPurchase}, which is where
|
||||||
// RecCenterTokens and the CREATOR is credited the same amount — no house cut, so the
|
// the money and the rules live, and build their own bodies from what it returns.
|
||||||
// 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.
|
|
||||||
.get(
|
.get(
|
||||||
'/api/storefronts/v2/buyInvention',
|
'/api/storefronts/v2/buyInvention',
|
||||||
describeRoute({
|
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.
|
// 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 requestedPrice = Number.parseInt(c.req.query('requestedPrice') ?? '0', 10) || 0
|
||||||
|
|
||||||
const invention = await getInventionById(c.env.DB, inventionId)
|
const settled = await settleInventionPurchase(c, id, inventionId, requestedPrice)
|
||||||
if (invention === null) return c.json({ error: 'Invention not found' }, 404)
|
if (settled instanceof Response) return settled
|
||||||
// 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 c.json({
|
return c.json({
|
||||||
BalanceUpdateResponse: {
|
BalanceUpdateResponse: {
|
||||||
Balance: balance,
|
Balance: settled.balance,
|
||||||
BalanceType: ALL_PLATFORMS,
|
BalanceType: ALL_PLATFORMS,
|
||||||
CurrencyType: CurrencyType.RecCenterTokens,
|
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
|
* The JSON body `POST /api/storefronts/v3/buyInvention` takes. The same two values the v2
|
||||||
* side: the balance update (shaped like buyItem's, except `Balance` is the RESULTING
|
* GET reads off the query string (`inventionId`/`requestedPrice`), PascalCase in a body —
|
||||||
* total, not the change, and `Data` is a single invention rather than a gift-drop list)
|
* that is the only difference between the two routes.
|
||||||
* and the invention envelope the invention endpoints already serve.
|
*/
|
||||||
|
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({
|
export const BuyInventionResponse = z.object({
|
||||||
BalanceUpdateResponse: z.object({
|
BalanceUpdateResponse: z.object({
|
||||||
@@ -401,6 +415,54 @@ export const BuyInventionResponse = z.object({
|
|||||||
.describe('The same envelope `POST /api/inventions/v6/save` returns'),
|
.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. */
|
/** buyItem / buyInvention error body (`{ error }`), returned on 400/403/404/409. */
|
||||||
/** The JSON body `POST /api/ugcPurchasables/v1/items/bulk` takes. */
|
/** The JSON body `POST /api/ugcPurchasables/v1/items/bulk` takes. */
|
||||||
export const UgcPurchasableBulkRequest = z.object({
|
export const UgcPurchasableBulkRequest = z.object({
|
||||||
|
|||||||
@@ -2656,6 +2656,168 @@ describe('econ endpoints', () => {
|
|||||||
expect(await getOwnedInventionIds(env.DB, 60)).toEqual([])
|
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 () => {
|
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
|
// 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=..`).
|
// econ host, with a form body (`Id=..&UnlockedLevel=..`).
|
||||||
@@ -3935,6 +4097,7 @@ describe('econ endpoints', () => {
|
|||||||
'POST /api/objectives/v1/cleargroup',
|
'POST /api/objectives/v1/cleargroup',
|
||||||
'POST /api/objectives/v1/updateobjective',
|
'POST /api/objectives/v1/updateobjective',
|
||||||
'POST /api/storefronts/v2/buyItem',
|
'POST /api/storefronts/v2/buyItem',
|
||||||
|
'POST /api/storefronts/v3/buyInvention',
|
||||||
'POST /api/ugcPurchasables/v1/items/bulk',
|
'POST /api/ugcPurchasables/v1/items/bulk',
|
||||||
'PUT /api/equipment/v1/update',
|
'PUT /api/equipment/v1/update',
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Owned inventions on the shared `recflare` D1 database — the inventions a player has
|
* Owned inventions on the shared `recflare` D1 database — the inventions a player has
|
||||||
* bought. One row per (account, invention), written at purchase time by the `econ`
|
* bought. One row per (account, invention), written at purchase time by the `econ`
|
||||||
* worker's `GET /api/storefronts/v2/buyInvention`.
|
* worker's buyInvention — the `v2` GET and the `v3` POST, which share one settlement.
|
||||||
*
|
*
|
||||||
* Only the invention id is stored: the invention record itself lives in the `invention`
|
* Only the invention id is stored: the invention record itself lives in the `invention`
|
||||||
* table, whose schema the `api` worker owns (apps/api/migrations/0002_invention.sql) on
|
* table, whose schema the `api` worker owns (apps/api/migrations/0002_invention.sql) on
|
||||||
|
|||||||
Reference in New Issue
Block a user