From 1458df16641fdb9abb8f16470ff0754e389ed6c7 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 1 Oct 2026 14:35:34 -0400 Subject: [PATCH] [econ] key purchasing --- apps/econ/README.md | 3 + apps/econ/src/econ.app.ts | 356 ++++++++++++++++ apps/econ/src/openapi.ts | 102 +++++ apps/econ/src/room-currency-db.ts | 76 ++++ apps/econ/src/room-key-db.ts | 47 +++ apps/econ/src/test/integration/api.test.ts | 455 +++++++++++++++++++++ 6 files changed, 1039 insertions(+) diff --git a/apps/econ/README.md b/apps/econ/README.md index 60caefb..0decbe8 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -52,6 +52,9 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali | GET | `/api/roomkeys/v1/room` | | The keys a room has listed | | POST | `/api/roomkeys/v1/create` | ✓ | List a key for a room (owner/co-owner) | | PUT | `/api/roomkeys/v1/updateAll` | ✓ | Edit one key (owner/co-owner) | +| GET | `/api/storefronts/v1/buyRoomKey` | ✓ | Buy a room key; pays the room owner | +| POST | `/api/storefronts/v1/PurchaseRoomKeyWithCurrency` | ✓ | Buy a room key with its room currency | +| POST | `/api/roomCurrencies/v2/purchase` | ✓ | Buy room currency with tokens (offer) | | POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | ~ | Gold year for `developer`s, else `{}` | | GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) | diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index a4759f3..686ead2 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -93,6 +93,7 @@ import { BuyInventionV3Response, BuyItemRequest, BuyItemResponse, + BuyRoomKeyResponse, ChallengeProgressRequest, ChallengeProgressResponse, ChecklistCompleteResponse, @@ -123,6 +124,10 @@ import { OPTIONAL_AUTHED, OwnsRoomKeysRequest, OwnsRoomKeysResponse, + PurchaseRoomCurrencyEnvelope, + PurchaseRoomCurrencyRequest, + PurchaseRoomKeyWithCurrencyRequest, + PurchaseRoomKeyWithCurrencyResponse, ReferralProgressResponse, RoomConsumableDto, RoomConsumableEnvelope, @@ -158,17 +163,23 @@ import { awardRoomCurrency, createPurchaseOffer, createRoomCurrency, + findPurchaseOffer, getPurchaseOffers, + getRoomBalance, getRoomCurrencies, getRoomCurrency, + spendRoomCurrency, updateRoomCurrency, } from './room-currency-db' import { awardRoomKey, + claimRoomKey, createRoomKey, getRoomKey, getRoomKeys, + getRoomOwnerId, ownsRoomKeys, + releaseRoomKey, updateRoomKey, } from './room-key-db' @@ -4277,6 +4288,134 @@ const app = new Hono({ strict: false }) c.json([]) ) + // Buy room currency with tokens, through one of the currency's purchase offers. The body + // names only the OFFER — which currency it sells is whichever one lists it — and the + // amount and price the client rendered, both of which must still match the offer. + .post( + '/api/roomCurrencies/v2/purchase', + describeRoute({ + tags: ['Econ'], + summary: 'Buy room currency with tokens', + description: [ + 'Buys from a room currency’s shop. Form-encoded', + '(`PurchaseOfferId=ffab4157-…&RequestedAmount=444&RequestedPrice=555`): the caller', + 'pays the offer’s `Price` in RecCenterTokens and is given its `CurrencyAmount` of the', + 'currency the offer belongs to (`room_balance`).', + '', + '`PurchaseOfferId` is a `CurrencyPurchaseOfferId` out of a currency’s purchase offers,', + 'and is all that says which currency is bought. `RequestedAmount` and `RequestedPrice`', + 'must EQUAL the offer’s `CurrencyAmount` and `Price` — an offer edited since the client', + 'drew it is refused rather than sold at numbers the player did not see.', + '', + 'The tokens are paid to the room’s OWNER (its `CreatorAccountId`), as a room-key sale', + 'is; an owner buying from their own shop, and a free offer, move no tokens.', + '', + 'Answers the `{ Value, Success, Error, error_id }` envelope the room-currency writes', + 'use. `Value` is BOTH balances the purchase moved, each a RESULTING total:', + '`CurrencyBalanceResponse` (`AccountId`, `CurrencyId`, `Balance`, `ModifiedAt`) is', + 'what the buyer now holds of the room currency, and `TokenBalanceResponse`', + '(`Balance`, `CurrencyType`, `Platform`) their tokens. `Platform` is -2, the one', + 'bucket every balance surface here names — any other value shows the buyer a phantom', + 'second token balance. A refusal (no such offer, a mismatch, not enough tokens) is a', + '200 with `Success: false` and a null `Value` — that form is an assumption — and', + 'nothing moved.', + '', + 'When tokens moved, both players get a socket push carrying their resulting token', + 'total — the buyer a StorefrontBalancePurchase, the owner a StorefrontBalanceUpdate.', + ].join('\n'), + security: AUTHED, + requestBody: form(PurchaseRoomCurrencyRequest, 'The offer, and the numbers the client saw'), + responses: { + 200: json(PurchaseRoomCurrencyEnvelope, 'The buyer’s new holding, or a refusal'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '') + const int = (v: unknown): number => (/^-?\d+$/.test(str(v)) ? Number(str(v)) : Number.NaN) + const refuse = (error: string) => + c.json({ Value: null, Success: false, Error: error, error_id: null }) + + const offerId = str(body.PurchaseOfferId) + const found = offerId === '' ? null : await findPurchaseOffer(c.env.DB, offerId) + if (found === null) return refuse('No such purchase offer') + const { Offer: offer, RoomId: roomId } = found + + // An offer that hands over nothing, or costs less than nothing, is not for sale. + if (!Number.isInteger(offer.CurrencyAmount) || offer.CurrencyAmount <= 0) { + return refuse('Purchase offer is not available') + } + if (!Number.isInteger(offer.Price) || offer.Price < 0) { + return refuse('Purchase offer is not available') + } + if (int(body.RequestedAmount) !== offer.CurrencyAmount) { + return refuse('Requested amount does not match') + } + if (int(body.RequestedPrice) !== offer.Price) return refuse('Requested price does not match') + + const ownerId = await getRoomOwnerId(c.env.DB, roomId) + if (ownerId === null) return refuse('Purchase offer is not available') + + // The owner paying themselves would be a debit and a credit of the same tokens. + const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) + const tokens = CurrencyType.RecCenterTokens + const charged = offer.Price > 0 && ownerId !== id + // Charged FIRST, and atomically: a buyer who cannot pay is handed nothing. + if (charged && !(await spendCurrency(c.env.DB, id, tokens, offer.Price, startingTokens))) { + return refuse('Not enough tokens') + } + + const { Balance: currencyBalance } = await awardRoomCurrency( + c.env.DB, + offer.CurrencyId, + id, + offer.CurrencyAmount + ) + + if (charged) { + // Seed the owner's signup grant BEFORE crediting them — `creditCurrency` upserts + // the row, which would otherwise cost a never-seen owner their starting tokens. + await ensureStartingBalances(c.env.DB, ownerId, startingTokens) + const ownerBalance = await creditCurrency( + c.env.DB, + ownerId, + tokens, + offer.Price, + startingTokens + ) + // Both frames carry a RESULTING total — see the frame rule above pushBalanceUpdate. + await pushBalanceUpdate(c, ownerId, tokens, ownerBalance) + } + const tokenBalance = await getBalance(c.env.DB, id, tokens, startingTokens) + if (charged) await pushBalancePurchase(c, id, tokens, -offer.Price, tokenBalance) + + // Both balances are RESULTING totals. `Platform` names the one account-wide bucket, + // like every other token balance this worker reports. + return c.json({ + Value: { + CurrencyBalanceResponse: { + AccountId: id, + CurrencyId: offer.CurrencyId, + Balance: currencyBalance, + ModifiedAt: new Date().toISOString(), + }, + TokenBalanceResponse: { + Balance: tokenBalance, + CurrencyType: tokens, + Platform: ALL_PLATFORMS, + }, + }, + Success: true, + Error: null, + error_id: null, + }) + } + ) + // The room-economy surface the client asks for on entering a room: the room's own // inventory/offers/gift-drop shops and the caller's slice of them. Nothing here is // stored yet, so every one is an empty list — the client reads that as "this room @@ -5642,6 +5781,223 @@ const app = new Hono({ strict: false }) } ) + // Buy a room key with tokens. A GET with query params — that is how the client sends it. + // The key's price leaves the buyer and is paid to the room's owner; holding the key is a + // `room_key_player` row. Every refusal is a 200 in the same shape, told apart by the + // entry's `UpdateResponse` — only a missing token answers with a status of its own. + .get( + '/api/storefronts/v1/buyRoomKey', + describeRoute({ + tags: ['Storefront'], + summary: 'Buy a room key', + description: [ + 'Looks the key up by `RoomKeyId`, confirms the client’s `RequestedPrice` matches its', + 'stored `Price`, debits the buyer that price in RecCenterTokens, pays it to the', + 'room’s OWNER (its `CreatorAccountId`), and records the buyer as holding the key', + '(`room_key_player`). A free key, and an owner buying their own room’s key, move no', + 'tokens. A GET because that is how the client sends it.', + '', + 'Answers the client’s `RoomKeyPurchaseResponseDTO`: `RoomKeyResponse` is the', + '`{ Status, RoomKey }` the create answers in, and `BalanceUpdateResponse` carries one', + 'entry (`UpdateResponse`, `Data` = the key) and the buyer’s RESULTING `Balance`.', + '`Platform` is -2, the one bucket every balance surface here names — any other value', + 'shows the buyer a phantom second balance.', + '', + 'A refusal is a 200 in the same shape — `Status` 1, a null `RoomKey`, the balance', + 'untouched — with the reason in the entry’s `UpdateResponse`: 6 the price does not', + 'match, 3 already held, 2 not enough tokens, 4 no such key (or no such room, or a key', + 'priced in a ROOM CURRENCY, which this token route does not sell).', + '', + 'When tokens moved, both players get a socket push carrying their resulting total —', + 'the buyer a StorefrontBalancePurchase, the owner a StorefrontBalanceUpdate.', + ].join('\n'), + security: AUTHED, + parameters: [ + { + name: 'RoomKeyId', + in: 'query', + required: true, + description: 'The key to buy — a `room_key` id', + schema: { type: 'integer' }, + }, + { + name: 'RequestedPrice', + in: 'query', + required: false, + description: 'The price the client rendered; must equal the key’s. Defaults to 0', + schema: { type: 'integer' }, + }, + ], + responses: { + 200: json(BuyRoomKeyResponse, 'The purchase result, or a refusal in the same shape'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) + const currencyType = CurrencyType.RecCenterTokens + const roomKeyId = Number.parseInt(c.req.query('RoomKeyId') ?? '', 10) + // Absent/non-numeric reads as 0, which only matches a free key — 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 key = Number.isInteger(roomKeyId) ? await getRoomKey(c.env.DB, roomKeyId) : null + + // Every key is always sent: the client reads an omitted one as its enum's 0 member, + // which is success for `Status`/`UpdateResponse` and SteamPurchased for `Platform`. + const answer = async (code: number) => + c.json({ + RoomKeyResponse: { + Status: code === UpdateResponse.OK ? ROOM_KEY_STATUS_OK : ROOM_KEY_STATUS_FAILED, + RoomKey: code === UpdateResponse.OK ? key : null, + }, + BalanceUpdateResponse: { + BalanceUpdates: [{ UpdateResponse: code, Data: key }], + Balance: await getBalance(c.env.DB, id, currencyType, startingTokens), + CurrencyType: currencyType, + Platform: ALL_PLATFORMS, + }, + }) + + // A key priced in a room currency is not for sale in tokens. + if (key === null || key.PurchaseCurrencyId !== null) { + return answer(UpdateResponse.NoItemAvailable) + } + if (requestedPrice !== key.Price) return answer(UpdateResponse.RequestedPriceDoesNotMatch) + const ownerId = await getRoomOwnerId(c.env.DB, key.RoomId) + if (ownerId === null) return answer(UpdateResponse.NoItemAvailable) + + // Claim the key BEFORE charging: the insert is atomic, so of two concurrent buys only + // one gets past here and only one is charged. A buyer who cannot pay gives it back. + if (!(await claimRoomKey(c.env.DB, id, key.RoomKeyId))) { + return answer(UpdateResponse.AlreadyOwned) + } + // The owner paying themselves would be a debit and a credit of the same tokens. + if (key.Price > 0 && ownerId !== id) { + if (!(await spendCurrency(c.env.DB, id, currencyType, key.Price, startingTokens))) { + await releaseRoomKey(c.env.DB, id, key.RoomKeyId) + return answer(UpdateResponse.NotEnoughCredit) + } + // Seed the owner's signup grant BEFORE crediting them — `creditCurrency` upserts + // the row, which would otherwise cost a never-seen owner their starting tokens. + await ensureStartingBalances(c.env.DB, ownerId, startingTokens) + const ownerBalance = await creditCurrency( + c.env.DB, + ownerId, + currencyType, + key.Price, + startingTokens + ) + // Both frames carry a RESULTING total — see the frame rule above pushBalanceUpdate. + await pushBalanceUpdate(c, ownerId, currencyType, ownerBalance) + await pushBalancePurchase( + c, + id, + currencyType, + -key.Price, + await getBalance(c.env.DB, id, currencyType, startingTokens) + ) + } + + return answer(UpdateResponse.OK) + } + ) + + // Buy a room key with the room currency it is priced in — buyRoomKey's mirror image, and + // almost nothing shared on the wire: a POST with a form body, a bare `{ Balance, + // RoomKeyResponse }`, and a `Balance` that is an OBJECT. Nobody is paid: room currency is + // the room's own play money, so the price simply leaves the buyer. + .post( + '/api/storefronts/v1/PurchaseRoomKeyWithCurrency', + describeRoute({ + tags: ['Storefront'], + summary: 'Buy a room key with room currency', + description: [ + 'Buys a key priced in a room currency. Form-encoded', + '(`RoomKeyId=43&RequestedPrice=1000&RequestedPurchaseCurrencyId=b9f41a7c-…`): the', + 'key’s `Price` is subtracted from what the caller holds of that currency', + '(`room_balance`) and they are recorded as holding the key (`room_key_player`).', + 'NOBODY IS PAID — unlike the token sale, the price does not reach the room’s owner.', + '', + '`RequestedPurchaseCurrencyId` must be the key’s own `PurchaseCurrencyId` and', + '`RequestedPrice` its `Price`. A key priced in TOKENS (a null `PurchaseCurrencyId`)', + 'is not sold here — that is `GET /api/storefronts/v1/buyRoomKey`.', + '', + 'Answers the client’s `RoomKeyPurchaseWithCurrencyResponseDTO`, a bare object with no', + 'envelope: `Balance` is an OBJECT — `{ AccountId, CurrencyId, Balance, ModifiedAt }`,', + 'the buyer’s RESULTING holding — and `RoomKeyResponse` the `{ Status, RoomKey }` the', + 'create answers in. Not buyRoomKey’s shape; there is no `Platform` here.', + '', + 'A refusal (no such key, a currency or price that is not the key’s, a key already', + 'held, too little of the currency) is a 200 in the same shape: `Status` 1, a null', + '`RoomKey`, and the holding as it stands. That form is an assumption — only success', + 'has been observed.', + ].join('\n'), + security: AUTHED, + requestBody: form(PurchaseRoomKeyWithCurrencyRequest, 'The key, and what the client saw'), + responses: { + 200: json( + PurchaseRoomKeyWithCurrencyResponse, + 'The purchase result, or a refusal in the same shape' + ), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '') + const int = (v: unknown): number => (/^-?\d+$/.test(str(v)) ? Number(str(v)) : Number.NaN) + + const requestedCurrencyId = str(body.RequestedPurchaseCurrencyId) + const roomKeyId = int(body.RoomKeyId) + const key = Number.isInteger(roomKeyId) ? await getRoomKey(c.env.DB, roomKeyId) : null + // The currency the answer reports on: the key's own once it is known to be the one + // asked for, the requested one on a refusal that never got that far. + const currencyId = key?.PurchaseCurrencyId ?? requestedCurrencyId + + // `Status` is always sent: the client reads an omitted one as 0, which is success. + const answer = async (sold: boolean) => + c.json({ + Balance: { + AccountId: id, + CurrencyId: currencyId, + Balance: await getRoomBalance(c.env.DB, currencyId, id), + ModifiedAt: new Date().toISOString(), + }, + RoomKeyResponse: { + Status: sold ? ROOM_KEY_STATUS_OK : ROOM_KEY_STATUS_FAILED, + RoomKey: sold ? key : null, + }, + }) + + // A key priced in tokens is buyRoomKey's to sell, and a key is paid for in its own + // currency only — never whichever one the request would rather spend. + if (key === null || key.PurchaseCurrencyId === null) return answer(false) + if (requestedCurrencyId.toLowerCase() !== key.PurchaseCurrencyId.toLowerCase()) { + return answer(false) + } + if (int(body.RequestedPrice) !== key.Price) return answer(false) + + // Claim the key BEFORE charging, as the token sale does: the insert is atomic, so of + // two concurrent buys only one is charged. A buyer who cannot pay gives it back. + if (!(await claimRoomKey(c.env.DB, id, key.RoomKeyId))) return answer(false) + if ( + key.Price > 0 && + !(await spendRoomCurrency(c.env.DB, key.PurchaseCurrencyId, id, key.Price)) + ) { + await releaseRoomKey(c.env.DB, id, key.RoomKeyId) + return answer(false) + } + + return answer(true) + } + ) + // Edit a room key. "All" is the key's FIELDS, not the room's keys: the body names one // `RoomKeyId` and carries everything editable about it. Gated like the create — the room // comes off the KEY, never the request. diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index 587aae9..b58d482 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -425,6 +425,68 @@ export const BuyInventionResponse = z.object({ .describe('The same envelope `POST /api/inventions/v6/save` returns'), }) +/** + * `GET /api/storefronts/v1/buyRoomKey` — the client's `RoomKeyPurchaseResponseDTO`, recovered + * from its decoder: the room-key envelope `/api/roomkeys/v1/create` answers in, beside a + * BalanceUpdateResponse whose bucket key is `Platform` (NOT the `BalanceType` the invention + * buys echo) and whose entry `Data` is the key again. + * + * Every enum here reads an OMITTED key as its 0 member — `Status` 0 is success, + * `UpdateResponse` 0 is OK, `Platform` 0 is SteamPurchased — so all three are always sent. + */ +export const BuyRoomKeyResponse = z.object({ + RoomKeyResponse: z.object({ + Status: z.int().describe('0 on success; 1 on a refusal'), + RoomKey: JsonObject.nullable().describe( + 'The bought key (the 10-key room key); null on a refusal' + ), + }), + BalanceUpdateResponse: z.object({ + BalanceUpdates: z.array( + z.object({ + UpdateResponse: z + .int() + .describe( + '0 OK, 2 NotEnoughCredit, 3 AlreadyOwned, 4 NoItemAvailable, ' + + '6 RequestedPriceDoesNotMatch' + ), + Data: JsonObject.nullable().describe('The key the request named; null when it named none'), + }) + ), + Balance: z.int().describe('The buyer’s RESULTING token total, not the change'), + CurrencyType: z.int().describe('2 = RecCenterTokens'), + Platform: z.int().describe('-2 — the one account-wide bucket every balance surface names'), + }), +}) + +/** `POST /api/storefronts/v1/PurchaseRoomKeyWithCurrency` — form-encoded. */ +export const PurchaseRoomKeyWithCurrencyRequest = z.object({ + RoomKeyId: z.string().describe('The key to buy — a `room_key` id'), + RequestedPrice: z.string().describe('The price the client rendered; must equal the key’s'), + RequestedPurchaseCurrencyId: z + .string() + .describe('The room currency to pay in; must be the key’s own `PurchaseCurrencyId`'), +}) + +/** + * `POST /api/storefronts/v1/PurchaseRoomKeyWithCurrency` — the client's + * `RoomKeyPurchaseWithCurrencyResponseDTO`, recovered from its decoder. A BARE object, no + * envelope, and NOT buyRoomKey's shape: the outer `Balance` is an OBJECT (the 4-key + * room-currency balance) holding a numeric `Balance`, and there is no `Platform` anywhere. + */ +export const PurchaseRoomKeyWithCurrencyResponse = z.object({ + Balance: z.object({ + AccountId: z.int().describe('The buyer'), + CurrencyId: z.string().describe('The room currency paid in'), + Balance: z.int().describe('What the buyer holds of it afterwards — the RESULTING total'), + ModifiedAt: z.string().describe('ISO-8601 UTC'), + }), + RoomKeyResponse: z.object({ + Status: z.int().describe('0 on success; 1 on a refusal. Always sent — omitted reads as 0'), + RoomKey: JsonObject.nullable().describe('The bought key; null on a refusal'), + }), +}) + /** * `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 @@ -974,6 +1036,46 @@ export const AwardRoomCurrencyResult = z.object({ export const AwardRoomCurrencyResultList = z.array(AwardRoomCurrencyResult) +/** + * `POST /api/roomCurrencies/v2/purchase` — form-encoded + * (`PurchaseOfferId=ffab4157-…&RequestedAmount=444&RequestedPrice=555`). The offer alone says + * which currency is being bought; the two numbers are what the client rendered, and must match + * the offer. + */ +export const PurchaseRoomCurrencyRequest = z.object({ + PurchaseOfferId: z.string().describe('The offer — a `CurrencyPurchaseOfferId`'), + RequestedAmount: z.string().describe('How much room currency; must equal the offer’s'), + RequestedPrice: z.string().describe('What it costs in tokens; must equal the offer’s'), +}) + +/** + * What `POST /api/roomCurrencies/v2/purchase` answers: both balances the purchase moved, + * inside the `{ Value, Success, Error, error_id }` envelope the room-currency writes use. + * Each `Balance` is a RESULTING total. Only the success form has been observed; a refusal's + * null `Value` is an assumption. + */ +export const PurchaseRoomCurrencyEnvelope = z.object({ + Value: z + .object({ + CurrencyBalanceResponse: z.object({ + AccountId: z.int().describe('The buyer'), + CurrencyId: z.string().describe('The room currency bought'), + Balance: z.int().describe('What the buyer now holds of it'), + ModifiedAt: z.string().describe('ISO-8601 UTC'), + }), + TokenBalanceResponse: z.object({ + Balance: z.int().describe('The buyer’s resulting token total'), + CurrencyType: z.int().describe('2 = RecCenterTokens'), + Platform: z.int().describe('-2 — the one account-wide bucket every balance surface names'), + }), + }) + .nullable() + .describe('Null on a refusal'), + Success: z.boolean(), + Error: z.string().nullable().describe('Null on success; the refusal otherwise'), + error_id: z.null().describe('Always null. Present as a key, and lowercase'), +}) + /** * `POST /api/roomcurrencies/v1/updateCurrency` — form-encoded. Names the currency by id * rather than by room: the room it belongs to is whichever one minted it, and an edit cannot diff --git a/apps/econ/src/room-currency-db.ts b/apps/econ/src/room-currency-db.ts index eda549d..b729a82 100644 --- a/apps/econ/src/room-currency-db.ts +++ b/apps/econ/src/room-currency-db.ts @@ -14,6 +14,8 @@ * - `GET /api/roomcurrencies/v1/currencies?roomId=` lists a room's * - `POST /api/roomcurrencies/v1/awardCurrency/bulk` pays players * - the purchase offers below are the shop that sells the currency for other money + * - `POST /api/roomCurrencies/v2/purchase` buys from that shop, with tokens + * - `POST /api/storefronts/v1/PurchaseRoomKeyWithCurrency` spends it, on a room key * * Two things to know before touching it: * @@ -385,6 +387,38 @@ export async function createPurchaseOffer( } } +/** A purchase offer found by its own id, beside the room that sells it. */ +export interface FoundPurchaseOffer { + Offer: RoomCurrencyPurchaseOffer + RoomId: number +} + +/** + * Find a purchase offer by its `CurrencyPurchaseOfferId` — all a purchase names. The offer + * lives inside its currency's `purchase_offers` JSON, so this walks the shops (`json_each`) + * for the one that lists it; the currency it sells is the row it was found on. Null when no + * shop lists that id. + */ +export async function findPurchaseOffer( + db: D1Database, + purchaseOfferId: string +): Promise { + const row = await db + .prepare( + `SELECT c.currency_id, c.room_id, o.value AS offer + FROM room_currency AS c, json_each(c.purchase_offers) AS o + WHERE json_valid(c.purchase_offers) + AND lower(json_extract(o.value, '$.CurrencyPurchaseOfferId')) = lower(?1) + LIMIT 1` + ) + .bind(purchaseOfferId) + .first<{ currency_id: string; room_id: number; offer: string }>() + if (!row) return null + + const [offer] = parsePurchaseOffers(row.currency_id, `[${row.offer}]`) + return offer ? { Offer: offer, RoomId: row.room_id } : null +} + /** One currency's shop — the group the batch read answers in, one per currency asked for. */ export interface RoomCurrencyPurchaseOffers { CurrencyId: string @@ -547,3 +581,45 @@ export async function getRoomBalances( Amount: r.amount, })) } + +/** What a player holds of one room currency; 0 when they have never earned any. */ +export async function getRoomBalance( + db: D1Database, + currencyId: string, + playerId: number +): Promise { + const row = await db + .prepare('SELECT amount FROM room_balance WHERE currency_id = ?1 AND player_id = ?2') + .bind(currencyId, playerId) + .first<{ amount: number }>() + return row?.amount ?? 0 +} + +/** + * Spend `amount` of a room currency. Returns false — changing nothing — when the player + * does not hold that much. + * + * NOT {@link awardRoomCurrency} with a negative delta: that floors at zero, which is right + * for a room taking coins away and wrong for a purchase — a player 1 coin short would pay + * what they had and still be sold the thing. The `amount >= ?3` guard lives in the UPDATE + * itself, so the check and the debit are one atomic statement, as `spendCurrency` is for + * tokens. + */ +export async function spendRoomCurrency( + db: D1Database, + currencyId: string, + playerId: number, + amount: number +): Promise { + if (!Number.isInteger(amount) || amount <= 0) { + throw new Error(`spendRoomCurrency: amount must be a positive integer, got ${amount}`) + } + const { meta } = await db + .prepare( + `UPDATE room_balance SET amount = amount - ?3 + WHERE currency_id = ?1 AND player_id = ?2 AND amount >= ?3` + ) + .bind(currencyId, playerId, amount) + .run() + return meta.changes > 0 +} diff --git a/apps/econ/src/room-key-db.ts b/apps/econ/src/room-key-db.ts index 0fe690f..230b746 100644 --- a/apps/econ/src/room-key-db.ts +++ b/apps/econ/src/room-key-db.ts @@ -9,6 +9,8 @@ * - `GET /api/roomkeys/v1/room?roomId=` lists a room's * - `PUT /api/roomkeys/v1/updateAll` edits one * - `POST /api/roomkeys/v1/awardbulk` awards the caller some + * - `GET /api/storefronts/v1/buyRoomKey` sells the caller one, for tokens + * - `POST /api/storefronts/v1/PurchaseRoomKeyWithCurrency` sells one for its room currency * - `POST /api/roomkeys/v1/owns/bulk` says who holds which * * This worker (`econ`) owns the tables and their migrations — see apps/econ/migrations/0024 @@ -255,3 +257,48 @@ export async function ownsRoomKeys(db: D1Database, pairs: RoomKeyHolding[]): Pro ) return results.map((r) => r.results.length > 0) } + +/** + * Take a key for a player ahead of charging them for it: true when this call made them its + * holder, false when they already were (or the key is gone). The insert is the atomic step + * a sale hangs on — two concurrent buys of one key cannot both claim it, so only one is + * charged. A sale that then fails to collect gives the key back with {@link releaseRoomKey}. + */ +export async function claimRoomKey( + db: D1Database, + accountId: number, + roomKeyId: number +): Promise { + const { meta } = await db + .prepare( + `INSERT OR IGNORE INTO room_key_player (room_key_id, account_id, awarded_at) + SELECT room_key_id, ?2, ?3 FROM room_key WHERE room_key_id = ?1` + ) + .bind(roomKeyId, accountId, new Date().toISOString()) + .run() + return meta.changes > 0 +} + +/** Undo a {@link claimRoomKey} whose sale did not go through. */ +export async function releaseRoomKey( + db: D1Database, + accountId: number, + roomKeyId: number +): Promise { + await db + .prepare('DELETE FROM room_key_player WHERE room_key_id = ?1 AND account_id = ?2') + .bind(roomKeyId, accountId) + .run() +} + +/** + * Who a room's key sales pay — the room's `CreatorAccountId`, read off the `room` table's + * generated column (the `rooms` worker owns the table). Null when there is no such room. + */ +export async function getRoomOwnerId(db: D1Database, roomId: number): Promise { + const row = await db + .prepare('SELECT creator_account_id FROM room WHERE room_id = ?1') + .bind(roomId) + .first<{ creator_account_id: number | null }>() + return row?.creator_account_id ?? null +} diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 9c72e9e..8948f5c 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -1941,6 +1941,163 @@ describe('econ endpoints', () => { expect(res.status).toBe(200) expect((await envOf(res)).Value).toMatchObject({ Limit: 1000000, Color: -1 }) }) + + test('POST roomCurrencies/v2/purchase sells an offer’s currency for tokens, paying the owner', async () => { + const form = async (path: string, fields: Record, sub: string | null) => + exports.default.fetch(`${ORIGIN}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(sub === null ? {} : await bearer(sub)), + }, + body: new URLSearchParams(fields), + }) + const purchase = async (sub: string | null, offerId: string, amount: number, price: number) => + form( + '/api/roomCurrencies/v2/purchase', + { + PurchaseOfferId: offerId, + RequestedAmount: String(amount), + RequestedPrice: String(price), + }, + sub + ) + const addOffer = async (currencyId: string, amount: number, price: number) => + ( + (await ( + await form( + '/api/roomcurrencies/v1/createPurchaseOffer', + { + CurrencyId: currencyId, + Name: 'pack', + Amount: String(amount), + Price: String(price), + }, + '1' + ) + ).json()) as { Value: { CurrencyPurchaseOfferId: string } } + ).Value.CurrencyPurchaseOfferId + const setTokens = (accountId: number, amount: number) => + env.DB.prepare( + 'INSERT OR REPLACE INTO balance (account_id, currency_type, amount) VALUES (?1, 2, ?2)' + ) + .bind(accountId, amount) + .run() + const tokens = async (accountId: number) => + ( + await env.DB.prepare( + 'SELECT amount FROM balance WHERE account_id = ?1 AND currency_type = 2' + ) + .bind(accountId) + .first<{ amount: number }>() + )?.amount + const held = async (currencyId: string, playerId: number) => + ( + await env.DB.prepare( + 'SELECT amount FROM room_balance WHERE currency_id = ?1 AND player_id = ?2' + ) + .bind(currencyId, playerId) + .first<{ amount: number }>() + )?.amount ?? 0 + const refused = (error: string) => ({ + Value: null, + Success: false, + Error: error, + error_id: null, + }) + + // Two currencies in room 2511 (account 1's), so the offer has to pick the right one. + const other = (await envOf(await create({ ...body, Name: 'Other' }, await bearer('1')))) + .Value! + const sold = (await envOf(await create({ ...body, Name: 'Sold' }, await bearer('1')))).Value! + await addOffer(other.CurrencyId, 444, 1) + const offerId = await addOffer(sold.CurrencyId, 444, 555) + const freeOfferId = await addOffer(sold.CurrencyId, 5, 0) + await setTokens(1, 1000) + await setTokens(7401, 600) + await setTokens(7402, 100) + await drainFrames() + + expect((await purchase(null, offerId, 444, 555)).status).toBe(401) + + // Refusals: an unknown offer, numbers that are not the offer's, too few tokens. + const cases: Array<[Response, string]> = [ + [await purchase('7401', crypto.randomUUID(), 444, 555), 'No such purchase offer'], + [await purchase('7401', '', 444, 555), 'No such purchase offer'], + [await purchase('7401', offerId, 445, 555), 'Requested amount does not match'], + [await purchase('7401', offerId, 444, 554), 'Requested price does not match'], + [await purchase('7401', offerId, 444, 0), 'Requested price does not match'], + [await purchase('7402', offerId, 444, 555), 'Not enough tokens'], + ] + for (const [res, error] of cases) { + expect(res.status).toBe(200) + expect(await res.json()).toEqual(refused(error)) + } + expect([await tokens(1), await tokens(7401), await tokens(7402)]).toEqual([1000, 600, 100]) + expect(await held(sold.CurrencyId, 7401)).toBe(0) + expect(await drainFrames()).toEqual([]) + + // The sale, exactly as the client posts it: 555 tokens for 444 of the offer's currency. + const res = await purchase('7401', offerId, 444, 555) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + Value: { + CurrencyBalanceResponse: { + AccountId: 7401, + CurrencyId: sold.CurrencyId, + Balance: 444, + ModifiedAt: expect.any(String), + }, + TokenBalanceResponse: { Balance: 45, CurrencyType: 2, Platform: -2 }, + }, + Success: true, + Error: null, + error_id: null, + }) + expect([await tokens(1), await tokens(7401)]).toEqual([1555, 45]) + expect(await held(sold.CurrencyId, 7401)).toBe(444) + expect(await held(other.CurrencyId, 7401)).toBe(0) + // Both hear their RESULTING token total, in the account-wide bucket. + const frames = await drainFrames() + expect(frames.find((f) => f.accountId === 1)?.payload).toEqual({ + Balance: 1555, + CurrencyType: 2, + Platform: -2, + }) + expect(frames.find((f) => f.accountId === 7401)?.payload).toMatchObject({ + Delta: -555, + Balance: 45, + Platform: -2, + }) + + // A second purchase needs the tokens again; the holding is left where it was. + expect(await (await purchase('7401', offerId, 444, 555)).json()).toEqual( + refused('Not enough tokens') + ) + expect(await held(sold.CurrencyId, 7401)).toBe(444) + + // A free offer, and the owner buying from their own shop, move no tokens. + const gratis = (await (await purchase('7402', freeOfferId, 5, 0)).json()) as { + Value: { + CurrencyBalanceResponse: { Balance: number } + TokenBalanceResponse: { Balance: number } + } + } + expect(gratis.Value.CurrencyBalanceResponse.Balance).toBe(5) + expect(gratis.Value.TokenBalanceResponse.Balance).toBe(100) + const own = (await (await purchase('1', offerId, 444, 555)).json()) as typeof gratis + expect(own.Value.CurrencyBalanceResponse.Balance).toBe(444) + expect(own.Value.TokenBalanceResponse.Balance).toBe(1555) + expect([await tokens(1), await tokens(7402)]).toEqual([1555, 100]) + expect(await drainFrames()).toEqual([]) + + await env.DB.prepare('DELETE FROM room_balance WHERE currency_id IN (?1, ?2)') + .bind(sold.CurrencyId, other.CurrencyId) + .run() + await env.DB.prepare('DELETE FROM room_currency WHERE currency_id IN (?1, ?2)') + .bind(sold.CurrencyId, other.CurrencyId) + .run() + }) }) // Room 2511 is seeded as account 1's, with account 2 as co-owner. @@ -2415,6 +2572,301 @@ describe('econ endpoints', () => { .bind(currencyId) .run() }) + + test('GET buyRoomKey moves the key’s price from the buyer to the room’s owner', async () => { + const buy = async (sub: string | null, roomKeyId: number | string, price?: number) => + exports.default.fetch( + `${ORIGIN}/api/storefronts/v1/buyRoomKey?RoomKeyId=${roomKeyId}` + + (price === undefined ? '' : `&RequestedPrice=${price}`), + sub === null ? undefined : { headers: await bearer(sub) } + ) + const setTokens = (accountId: number, amount: number) => + env.DB.prepare( + 'INSERT OR REPLACE INTO balance (account_id, currency_type, amount) VALUES (?1, 2, ?2)' + ) + .bind(accountId, amount) + .run() + const tokens = async (accountId: number) => + ( + await env.DB.prepare( + 'SELECT amount FROM balance WHERE account_id = ?1 AND currency_type = 2' + ) + .bind(accountId) + .first<{ amount: number }>() + )?.amount + const holders = async (roomKeyId: number) => + ( + await env.DB.prepare( + 'SELECT account_id FROM room_key_player WHERE room_key_id = ?1 ORDER BY account_id' + ) + .bind(roomKeyId) + .all<{ account_id: number }>() + ).results.map((r) => r.account_id) + // A refusal: the same shape, nothing sold, the balance as it stood. + const refused = (code: number, data: RoomKey | null, balance: number) => ({ + RoomKeyResponse: { Status: 1, RoomKey: null }, + BalanceUpdateResponse: { + BalanceUpdates: [{ UpdateResponse: code, Data: data }], + Balance: balance, + CurrencyType: 2, + Platform: -2, + }, + }) + + // Room 2511 is account 1's. A 300-token key, and a free one. + const key = await keyOf(await create({ ...body, Price: '300' }, await bearer('1'))) + const free = await keyOf( + await create({ ...body, Name: 'free', Price: '0' }, await bearer('1')) + ) + await setTokens(1, 1000) + await setTokens(7301, 500) + await setTokens(7302, 100) + await drainFrames() + + expect((await buy(null, key.RoomKeyId, 300)).status).toBe(401) + + // The price must match — `RequestedPrice=0`, or none, does not buy a priced key. + for (const wrong of [0, 299, undefined]) { + const res = await buy('7301', key.RoomKeyId, wrong) + expect(res.status).toBe(200) + expect(await res.json()).toEqual(refused(6, key, 500)) + } + // No such key; not enough tokens. + expect(await (await buy('7301', 999999, 300)).json()).toEqual(refused(4, null, 500)) + expect(await (await buy('7301', 'abc', 300)).json()).toEqual(refused(4, null, 500)) + expect(await (await buy('7302', key.RoomKeyId, 300)).json()).toEqual(refused(2, key, 100)) + expect(await holders(key.RoomKeyId)).toEqual([]) + expect([await tokens(1), await tokens(7301), await tokens(7302)]).toEqual([1000, 500, 100]) + expect(await drainFrames()).toEqual([]) + + // The sale: 300 leaves the buyer, reaches the owner, and the buyer holds the key. + const sold = await buy('7301', key.RoomKeyId, 300) + expect(sold.status).toBe(200) + const soldBody = (await sold.json()) as { + BalanceUpdateResponse: Record + } + expect(soldBody).toEqual({ + RoomKeyResponse: { Status: 0, RoomKey: key }, + BalanceUpdateResponse: { + BalanceUpdates: [{ UpdateResponse: 0, Data: key }], + Balance: 200, + CurrencyType: 2, + Platform: -2, + }, + }) + // Derived-first key order, as the client's formatter runs it. + expect(Object.keys(soldBody.BalanceUpdateResponse)).toEqual([ + 'BalanceUpdates', + 'Balance', + 'CurrencyType', + 'Platform', + ]) + expect(await holders(key.RoomKeyId)).toEqual([7301]) + expect([await tokens(1), await tokens(7301)]).toEqual([1300, 200]) + // Both hear their RESULTING total, in the account-wide bucket. + const frames = await drainFrames() + expect(frames.find((f) => f.accountId === 1)?.payload).toEqual({ + Balance: 1300, + CurrencyType: 2, + Platform: -2, + }) + expect(frames.find((f) => f.accountId === 7301)?.payload).toMatchObject({ + Delta: -300, + Balance: 200, + Platform: -2, + }) + + // Held once: buying it again is refused and charges nothing. + expect(await (await buy('7301', key.RoomKeyId, 300)).json()).toEqual(refused(3, key, 200)) + expect([await tokens(1), await tokens(7301)]).toEqual([1300, 200]) + + // A free key moves nothing; nor does the owner buying their own room's key. + const gratis = (await (await buy('7302', free.RoomKeyId)).json()) as { + RoomKeyResponse: { Status: number } + BalanceUpdateResponse: { Balance: number } + } + expect(gratis.RoomKeyResponse.Status).toBe(0) + expect(gratis.BalanceUpdateResponse.Balance).toBe(100) + const own = (await (await buy('1', key.RoomKeyId, 300)).json()) as typeof gratis + expect(own.RoomKeyResponse.Status).toBe(0) + expect(own.BalanceUpdateResponse.Balance).toBe(1300) + expect(await holders(key.RoomKeyId)).toEqual([1, 7301]) + expect(await drainFrames()).toEqual([]) + + // A key priced in a room currency is not sold for tokens. + await env.DB.prepare( + "UPDATE room_key SET purchase_currency_id = 'c0ffee' WHERE room_key_id = ?1" + ) + .bind(free.RoomKeyId) + .run() + const inCurrency = { ...free, PurchaseCurrencyId: 'c0ffee' } + expect(await (await buy('7301', free.RoomKeyId, 0)).json()).toEqual( + refused(4, inCurrency, 200) + ) + + await env.DB.prepare('DELETE FROM room_key_player').run() + await env.DB.prepare('DELETE FROM room_key').run() + }) + + test('POST PurchaseRoomKeyWithCurrency subtracts the key’s price in its room currency and pays nobody', async () => { + const form = async ( + path: string, + fields: Record, + sub: string | null, + method = 'POST' + ) => + exports.default.fetch(`${ORIGIN}${path}`, { + method, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(sub === null ? {} : await bearer(sub)), + }, + body: new URLSearchParams(fields), + }) + const purchase = async ( + sub: string | null, + roomKeyId: number, + price: number, + currency: string + ) => + form( + '/api/storefronts/v1/PurchaseRoomKeyWithCurrency', + { + RoomKeyId: String(roomKeyId), + RequestedPrice: String(price), + RequestedPurchaseCurrencyId: currency, + }, + sub + ) + const mint = async (name: string) => + ( + (await ( + await form('/api/roomcurrencies/v1/createCurrency', { RoomId: '2511', Name: name }, '1') + ).json()) as { Value: { CurrencyId: string } } + ).Value.CurrencyId + const give = (currencyId: string, playerId: number, amount: number) => + env.DB.prepare( + 'INSERT OR REPLACE INTO room_balance (currency_id, player_id, amount) VALUES (?1, ?2, ?3)' + ) + .bind(currencyId, playerId, amount) + .run() + const held = async (currencyId: string, playerId: number) => + ( + await env.DB.prepare( + 'SELECT amount FROM room_balance WHERE currency_id = ?1 AND player_id = ?2' + ) + .bind(currencyId, playerId) + .first<{ amount: number }>() + )?.amount ?? 0 + const holders = async (roomKeyId: number) => + ( + await env.DB.prepare( + 'SELECT account_id FROM room_key_player WHERE room_key_id = ?1 ORDER BY account_id' + ) + .bind(roomKeyId) + .all<{ account_id: number }>() + ).results.map((r) => r.account_id) + const tokens = async (accountId: number) => + ( + await env.DB.prepare( + 'SELECT amount FROM balance WHERE account_id = ?1 AND currency_type = 2' + ) + .bind(accountId) + .first<{ amount: number }>() + )?.amount + // A refusal: the same shape, nothing sold, the holding as it stands. + const refused = (accountId: number, currencyId: string, balance: number) => ({ + Balance: { + AccountId: accountId, + CurrencyId: currencyId, + Balance: balance, + ModifiedAt: expect.any(String), + }, + RoomKeyResponse: { Status: 1, RoomKey: null }, + }) + + // A key priced at 1000 of the room's own currency; a second currency; a token key. + const coin = await mint('coin') + const other = await mint('other') + const made = await keyOf(await create(body, await bearer('1'))) + const key = ( + await envOf( + await form( + '/api/roomkeys/v1/updateAll', + { RoomKeyId: String(made.RoomKeyId), Price: '1000', PurchaseCurrencyId: coin }, + '1', + 'PUT' + ) + ) + ).RoomKey! + expect(key).toMatchObject({ Price: 1000, PurchaseCurrencyId: coin }) + const tokenKey = await keyOf(await create({ ...body, Name: 'token key' }, await bearer('1'))) + await give(coin, 7501, 1500) + await give(other, 7501, 5000) + await give(coin, 7502, 999) + const ownerTokens = await tokens(1) + await drainFrames() + + expect((await purchase(null, key.RoomKeyId, 1000, coin)).status).toBe(401) + + // Refusals: no such key, the wrong price, a currency that is not the key's, a key + // priced in tokens, and a buyer one coin short — who is NOT sold it for what they have. + const cases: Array<[Response, ReturnType]> = [ + [await purchase('7501', 999999, 1000, coin), refused(7501, coin, 1500)], + [await purchase('7501', key.RoomKeyId, 999, coin), refused(7501, coin, 1500)], + [await purchase('7501', key.RoomKeyId, 1000, other), refused(7501, coin, 1500)], + [await purchase('7501', tokenKey.RoomKeyId, 50, coin), refused(7501, coin, 1500)], + [await purchase('7502', key.RoomKeyId, 1000, coin), refused(7502, coin, 999)], + ] + for (const [res, expected] of cases) { + expect(res.status).toBe(200) + expect(await res.json()).toEqual(expected) + } + expect(await holders(key.RoomKeyId)).toEqual([]) + expect(await holders(tokenKey.RoomKeyId)).toEqual([]) + expect([await held(coin, 7501), await held(other, 7501), await held(coin, 7502)]).toEqual([ + 1500, 5000, 999, + ]) + + // The sale, exactly as the client posts it. `Balance` is an OBJECT holding the + // resulting total, and it comes first. + const res = await purchase('7501', key.RoomKeyId, 1000, coin) + expect(res.status).toBe(200) + const sold = (await res.json()) as Record + expect(sold).toEqual({ + Balance: { + AccountId: 7501, + CurrencyId: coin, + Balance: 500, + ModifiedAt: expect.any(String), + }, + RoomKeyResponse: { Status: 0, RoomKey: key }, + }) + expect(Object.keys(sold)).toEqual(['Balance', 'RoomKeyResponse']) + expect(await holders(key.RoomKeyId)).toEqual([7501]) + expect([await held(coin, 7501), await held(other, 7501)]).toEqual([500, 5000]) + + // Nobody is paid: the owner holds none of the coin, their tokens did not move, and + // no balance frame went out. + expect(await held(coin, 1)).toBe(0) + expect(await tokens(1)).toBe(ownerTokens) + expect(await drainFrames()).toEqual([]) + + // Held once: buying it again is refused and subtracts nothing. + expect(await (await purchase('7501', key.RoomKeyId, 1000, coin)).json()).toEqual( + refused(7501, coin, 500) + ) + expect(await held(coin, 7501)).toBe(500) + + await env.DB.prepare('DELETE FROM room_key_player').run() + await env.DB.prepare('DELETE FROM room_key').run() + await env.DB.prepare('DELETE FROM room_balance WHERE currency_id IN (?1, ?2)') + .bind(coin, other) + .run() + await env.DB.prepare('DELETE FROM room_currency WHERE currency_id IN (?1, ?2)') + .bind(coin, other) + .run() + }) }) test('GET /api/roomconsumables/v1/roomConsumable/room/:id/me returns []', async () => { @@ -6205,6 +6657,7 @@ describe('econ endpoints', () => { 'GET /api/roomkeys/v1/mine', 'GET /api/roomkeys/v1/room', 'GET /api/storefronts/v1/adcarouselitems', + 'GET /api/storefronts/v1/buyRoomKey', 'GET /api/storefronts/v2/buyInvention', 'GET /api/storefronts/v3/giftdropstore/{id}', 'GET /api/storefronts/v4/balance/{currencyType}', @@ -6234,6 +6687,7 @@ describe('econ endpoints', () => { 'POST /api/items/purchaseInfos', 'POST /api/objectives/v1/cleargroup', 'POST /api/objectives/v1/updateobjective', + 'POST /api/roomCurrencies/v2/purchase', 'POST /api/roomconsumables/v1/roomConsumable/awardBulk', 'POST /api/roomcurrencies/v1/awardCurrency/bulk', 'POST /api/roomcurrencies/v1/createCurrency', @@ -6242,6 +6696,7 @@ describe('econ endpoints', () => { 'POST /api/roomkeys/v1/awardbulk', 'POST /api/roomkeys/v1/create', 'POST /api/roomkeys/v1/owns/bulk', + 'POST /api/storefronts/v1/PurchaseRoomKeyWithCurrency', 'POST /api/storefronts/v2/buyItem', 'POST /api/storefronts/v3/buyInvention', 'POST /api/ugcPurchasables/v1/items/bulk',