From d441181b03e100545a54d91aeffee3c553221977 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 2 Sep 2026 11:22:03 -0400 Subject: [PATCH] [invention] add invention v3 purchase --- apps/api/src/inventions-db.ts | 90 +++-- apps/api/src/test/integration/api.test.ts | 5 +- apps/econ/src/econ.app.ts | 323 ++++++++++++------ apps/econ/src/openapi.ts | 70 +++- apps/econ/src/test/integration/api.test.ts | 163 +++++++++ packages/domain/src/inventory-invention-db.ts | 2 +- 6 files changed, 514 insertions(+), 139 deletions(-) diff --git a/apps/api/src/inventions-db.ts b/apps/api/src/inventions-db.ts index 75df0c1..21bf955 100644 --- a/apps/api/src/inventions-db.ts +++ b/apps/api/src/inventions-db.ts @@ -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, diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index b619909..b9004eb 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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( diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 03d7b8c..a7b8991 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -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, + id: number, + inventionId: number, + requestedPrice: number +): Promise { + 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({ strict: false }) @@ -3511,22 +3628,11 @@ const app = new Hono({ 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({ 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 | 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), }) } ) diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index 1533375..64aae09 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -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({ diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 34f2fea..95b5c2e 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -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 + 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 } + BalanceUpdateResponse: { BalanceType: number; Platform?: number } + } + const v3 = (await ( + await buyInventionV3('59', { InventionId: 8, RequestedPrice: 0 }) + ).json()) as { + InventionResponse: { Value: { Invention: Record } | 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', ]) diff --git a/packages/domain/src/inventory-invention-db.ts b/packages/domain/src/inventory-invention-db.ts index 59c928a..443af05 100644 --- a/packages/domain/src/inventory-invention-db.ts +++ b/packages/domain/src/inventory-invention-db.ts @@ -1,7 +1,7 @@ /** * 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` - * 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` * table, whose schema the `api` worker owns (apps/api/migrations/0002_invention.sql) on