mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[econ] key purchasing
This commit is contained in:
@@ -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) |
|
||||
|
||||
|
||||
@@ -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<App>({ 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<string, unknown>
|
||||
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<App>({ 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<string, unknown>
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<FoundPurchaseOffer | null> {
|
||||
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<number> {
|
||||
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<boolean> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<void> {
|
||||
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<number | null> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<string, string>, 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<string, unknown>
|
||||
}
|
||||
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<string, string>,
|
||||
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<typeof refused>]> = [
|
||||
[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<string, unknown>
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user