[econ] key purchasing

This commit is contained in:
Devin Zuczek
2026-10-01 14:35:34 -04:00
parent b09f014b6d
commit 1458df1664
6 changed files with 1039 additions and 0 deletions
+3
View File
@@ -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) |
+356
View File
@@ -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.
+102
View File
@@ -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
+76
View File
@@ -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
}
+47
View File
@@ -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
}
+455
View File
@@ -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',